diff --git a/examples/tutorial4.jl b/examples/tutorial4.jl new file mode 100644 index 00000000..fd14d93a --- /dev/null +++ b/examples/tutorial4.jl @@ -0,0 +1,355 @@ +#= +# Tutorial 4 - Thermal and Electromagnetic Modeling of a 525 kV Armored HVDC Cable + +This case file demonstrates how to model an armored high-voltage single-core power cable +using the [`LineCableModels.jl`](@ref) package. The objective is to build a complete representation of a single-core 525 kV cable with a 1600 mm² copper conductor, 1.2 mm tubular lead sheath and 68 x 6 mm galvanized steel armor, based on the design described in [Karmokar2025](@cite). +=# + +#= +**Tutorial outline** +```@contents +Pages = [ + "tutorial4.md", +] +Depth = 2:3 +``` +=# + +#= +## Introduction +HVDC cables are constructed around a central conductor enclosed by a triple-extruded insulation system (inner/outer semi-conductive layers and main insulation). A metallic screen and protective outer sheath are then applied for land cables. Subsea designs add galvanized steel wire armor over this structure to provide mechanical strength against water pressure. A reference design for a 525 kV HVDC cable [is shown here](https://nkt.widen.net/content/pnwgwjfudf/pdf/Extruded_DC_525kV_DS_EN_DEHV_HV_DS_DE-EN.pdf). +=# + +#= +## Getting started +=# + +# Load the package and set up the environment: +using LineCableModels +using LineCableModels.Engine.FEM +using LineCableModels.Engine.Transforms: Fortescue +using DataFrames +using Printf +fullfile(filename) = joinpath(@__DIR__, filename); #hide +set_verbosity!(1); #hide +set_backend!(:gl); #hide + +# Initialize library and the required materials for this design: +materials = MaterialsLibrary(add_defaults = true) +lead = Material(21.4e-8, 1.0, 0.999983, 20.0, 0.00400, 34.7) # Lead or lead alloy +add!(materials, "lead", lead) +steel = Material(13.8e-8, 1.0, 300.0, 20.0, 0.00450, 14.0) # Steel +add!(materials, "steel", steel) +pp = Material(1e15, 2.8, 1.0, 20.0, 0.0, 0.11) # Laminated paper propylene +add!(materials, "pp", pp) + +# Inspect the contents of the materials library: +materials_df = DataFrame(materials) + +#= +## Cable dimensions + +The cable under consideration is a high-voltage, stranded copper conductor cable with XLPE insulation, water-blocking tape, lead tubular screens, PE inner sheath, PP bedding, steel armor and PP jacket, rated for 525 kV HVDC systems. This information is typically found in the cable datasheet and is based on the design studied in [Karmokar2025](@cite). + +The cable is found to have the following configuration: +=# + +num_co_wires = 127 # number of core wires +num_ar_wires = 68 # number of armor wires +d_core = 0.0463 # nominal core overall diameter +d_w = 3.6649e-3 # nominal strand diameter of the core (minimum value to match datasheet) +t_sc_in = 2e-3 # nominal internal semicon thickness +t_ins = 26e-3 # nominal main insulation thickness +t_sc_out = 1.8e-3 # nominal external semicon thickness +t_wbt = .3e-3 # nominal thickness of the water blocking tape +t_sc = 3.3e-3 # nominal lead screen thickness +t_pe = 3e-3 # nominal PE inner sheath thickness +t_bed = 3e-3 # nominal thickness of the PP bedding +d_wa = 5.827e-3 # nominal armor wire diameter +t_jac = 10e-3 # nominal PP jacket thickness + +d_overall = d_core #hide +layers = [] #hide +push!(layers, ("Conductor", missing, d_overall * 1000)) #hide +d_overall += 2 * t_sc_in #hide +push!(layers, ("Inner semiconductor", t_sc_in * 1000, d_overall * 1000)) #hide +d_overall += 2 * t_ins #hide +push!(layers, ("Main insulation", t_ins * 1000, d_overall * 1000)) #hide +d_overall += 2 * t_sc_out #hide +push!(layers, ("Outer semiconductor", t_sc_out * 1000, d_overall * 1000)) #hide +d_overall += 2 * t_wbt #hide +push!(layers, ("Swellable tape", t_wbt * 1000, d_overall * 1000)) #hide +d_overall += 2 * t_sc #hide +push!(layers, ("Lead screen", t_sc * 1000, d_overall * 1000)) #hide +d_overall += 2 * t_pe #hide +push!(layers, ("PE inner sheath", t_pe * 1000, d_overall * 1000)) #hide +d_overall += 2 * t_bed #hide +push!(layers, ("PP bedding", t_bed * 1000, d_overall * 1000)) #hide +d_overall += 2 * d_wa #hide +push!(layers, ("Stranded wire armor", d_wa * 1000, d_overall * 1000)) #hide +d_overall += 2 * t_jac #hide +push!(layers, ("PP jacket", t_jac * 1000, d_overall * 1000)); #hide + + +# The cable structure is summarized in a table for better visualization, with dimensions in milimiters: +df = DataFrame( #hide + layer = first.(layers), #hide + thickness = [ #hide + ismissing(t) ? "-" : round(t, sigdigits = 2) for t in getindex.(layers, 2) #hide + ], #hide + diameter = [round(d, digits = 2) for d in getindex.(layers, 3)], #hide +) #hide + +#= +## Core and main insulation + +Initialize the conductor object and assign the central wire: +=# + +material = get(materials, "copper") +core = ConductorGroup(WireArray(0.0, Diameter(d_w), 1, 0.0, material)) + +# Add the subsequent layers of wires and inspect the object: +n_strands = 6 # Strands per layer +n_layers = 6 # Layers of strands +for i in 1:n_layers + add!(core, WireArray, Diameter(d_w), i * n_strands, 11.0, material) +end +core + +#= +### Inner semiconductor + +Inner semiconductor (1000 Ω.m as per IEC 840): +=# + +material = get(materials, "semicon1") +main_insu = InsulatorGroup(Semicon(core, Thickness(t_sc_in), material)) + +#= +### Main insulation + +Add the insulation layer: +=# + +material = get(materials, "pe") +add!(main_insu, Insulator, Thickness(t_ins), material) + +#= +### Outer semiconductor + +Outer semiconductor (500 Ω.m as per IEC 840): +=# + +material = get(materials, "semicon2") +add!(main_insu, Semicon, Thickness(t_sc_out), material) + +# Water blocking (swellable) tape: +material = get(materials, "polyacrylate") +add!(main_insu, Semicon, Thickness(t_wbt), material) + +# Group core-related components: +core_cc = CableComponent("core", core, main_insu) + +cable_id = "525kV_1600mm2" +datasheet_info = NominalData( + designation_code = "(N)2XH(F)RK2Y", + U0 = 500.0, # Phase (pole)-to-ground voltage [kV] + U = 525.0, # Phase (pole)-to-phase (pole) voltage [kV] + conductor_cross_section = 1600.0, # [mm²] + screen_cross_section = 1000.0, # [mm²] + resistance = nothing, # DC resistance [Ω/km] + capacitance = nothing, # Capacitance [μF/km] + inductance = nothing, # Inductance in trifoil [mH/km] +) +cable_design = CableDesign(cable_id, core_cc, nominal_data = datasheet_info) + +#= +### Lead screen/sheath + +Build the wire screens on top of the previous layer: +=# + +material = get(materials, "lead") +screen_con = ConductorGroup(Tubular(main_insu, Thickness(t_sc), material)) + +# PE inner sheath: +material = get(materials, "pe") +screen_insu = InsulatorGroup(Insulator(screen_con, Thickness(t_pe), material)) + +# PP bedding: +material = get(materials, "pp") +add!(screen_insu, Insulator, Thickness(t_bed), material) + +# Group sheath components and assign to design: +sheath_cc = CableComponent("sheath", screen_con, screen_insu) +add!(cable_design, sheath_cc) + +#= +### Armor and outer jacket components + +=# + +# Add the armor wires on top of the previous layer: +lay_ratio = 10.0 # typical value for wire screens +material = get(materials, "steel") +armor_con = ConductorGroup( + WireArray(screen_insu, Diameter(d_wa), num_ar_wires, lay_ratio, material)) + +# PP layer after armor: +material = get(materials, "pp") +armor_insu = InsulatorGroup(Insulator(armor_con, Thickness(t_jac), material)) + +# Assign the armor parts directly to the design: +add!(cable_design, "armor", armor_con, armor_insu) + +# # Inspect the finished cable design: +# plt1, _ = preview(cable_design) +# plt1 #hide + +#= +## Examining the cable parameters (RLC) + +=# + +# Summarize DC lumped parameters (R, L, C): +core_df = DataFrame(cable_design, :baseparams) + +# Obtain the equivalent electromagnetic properties of the cable: +components_df = DataFrame(cable_design, :components) + +#= +## Saving the cable design + +Load an existing [`CablesLibrary`](@ref) file or create a new one: +=# + + +library = CablesLibrary() +library_file = fullfile("cables_library.json") +load!(library, file_name = library_file) +add!(library, cable_design) +library_df = DataFrame(library) + +# Save to file for later use: +save(library, file_name = library_file); + +#= +## Defining a cable system + +=# + +#= +### Earth model + +Define a constant frequency earth model: +=# + +f = [1e-3] # Near DC frequency for the analysis +earth_params = EarthModel(f, 100.0, 10.0, 1.0, 1.5) # 100 Ω·m resistivity, εr=10, μr=1, kappa=0.0 + +# Earth model base (DC) properties: +earthmodel_df = DataFrame(earth_params) + +#= +### Underground bipole configuration + +=# + +# Define the coordinates for both cables: +xp, xn, y0 = -0.5, 0.5, -1.0; + +# Initialize the `LineCableSystem` with positive pole: +cablepos = CablePosition(cable_design, xp, y0, + Dict("core" => 1, "sheath" => 0, "armor" => 0)) +cable_system = LineCableSystem("525kV_1600mm2_bipole", 1000.0, cablepos) + +# Add the other pole (negative) to the system: +add!(cable_system, cable_design, xn, y0, + Dict("core" => 2, "sheath" => 0, "armor" => 0)) + +#= +### Cable system preview + +In this section the complete bipole cable system is examined. +=# + +# Display system details: +system_df = DataFrame(cable_system) + +# # Visualize the cross-section of the three-phase system: +# plt2, _ = preview(cable_system, earth_model = earth_params, zoom_factor = 2.0) +# plt2 #hide + + +#= +## FEM calculations +=# + +# Define a AmpacityProblem with the cable system and earth model +problem = AmpacityProblem( + cable_system, + temperature = 20.0, # Operating temperature + earth_props=earth_params, + frequencies=[60.0], + energizations=[1e3 + 0im, 1e3 + 200im], + wind_velocity = 2.0, # m/s + +); + +# Estimate domain size based on skin depth in the earth +domain_radius = calc_domain_size(earth_params, f); + +# Define custom mesh transitions around each cable +mesh_transition1 = MeshTransition( + cable_system, + [1], + r_min = 0.08, + r_length = 0.25, + mesh_factor_min = 0.01 / (domain_radius / 5), + mesh_factor_max = 0.25 / (domain_radius / 5), + n_regions = 5) + +mesh_transition2 = MeshTransition( + cable_system, + [2], + r_min = 0.08, + r_length = 0.25, + mesh_factor_min = 0.01 / (domain_radius / 5), + mesh_factor_max = 0.25 / (domain_radius / 5), + n_regions = 5); + +# Define runtime options +opts = ( + force_remesh = true, # Force remeshing + force_overwrite = true, # Overwrite existing files + plot_field_maps = false, # Do not compute/ plot field maps + mesh_only = false, # Preview the mesh + save_path = fullfile("fem_output"), # Results directory + keep_run_files = true, # Archive files after each run + verbosity = 1, # Verbosity +); + +# Define the FEM formulation with the specified parameters +F = FormulationSet(:Ampacity, + analysis_type = MagnetoThermal(), + domain_radius = domain_radius, + domain_radius_inf = domain_radius * 1.25, + elements_per_length_conductor = 1, + elements_per_length_insulator = 2, + elements_per_length_semicon = 1, + elements_per_length_interfaces = 5, + points_per_circumference = 16, + mesh_size_min = 1e-6, + mesh_size_max = domain_radius / 5, + mesh_transitions = [mesh_transition1, + mesh_transition2], + mesh_size_default = domain_radius / 10, + mesh_algorithm = 5, + mesh_max_retries = 20, + materials = materials, + options = opts, +); + +# Run the FEM solver +@time ws = compute!(problem, F); diff --git a/src/LineCableModels.jl b/src/LineCableModels.jl index c6bada48..b1417978 100644 --- a/src/LineCableModels.jl +++ b/src/LineCableModels.jl @@ -1,84 +1,84 @@ -module LineCableModels - -## Public API -# ------------------------------------------------------------------------- -# Core generics: -export add!, set_verbosity!, set_backend! - -# Materials: -export Material, MaterialsLibrary - -# Data model (design + system): -export Thickness, Diameter, WireArray, Strip, Tubular, Semicon, Insulator -export ConductorGroup, InsulatorGroup -export CableComponent, CableDesign, NominalData -export CablesLibrary -export CablePosition, LineCableSystem -export trifoil_formation, flat_formation, preview, equivalent - -# Earth properties: -export EarthModel - -# Engine: -export LineParametersProblem, - FormulationSet, - compute!, SeriesImpedance, ShuntAdmittance, per_km, per_m, kronify - -# Parametric builder: -export make_stranded, make_screened -# export conductor, insulator - -# Import/Export: -export export_data, save, load! -# ------------------------------------------------------------------------- - -import DocStringExtensions: DocStringExtensions - -# Submodule `Commons` -include("commons/Commons.jl") -using .Commons: IMPORTS, EXPORTS, add! - -# Submodule `UncertainBessels` -include("uncertainbessels/UncertainBessels.jl") - -# Submodule `Utils` -include("utils/Utils.jl") -using .Utils: set_verbosity! - -# Submodule `BackendHandler` -include("backendhandler/BackendHandler.jl") -using .BackendHandler: set_backend! - -# Submodule `PlotUIComponents` -include("plotuicomponents/PlotUIComponents.jl") - -# Submodule `Validation` -include("validation/Validation.jl") - -# Submodule `Materials` -include("materials/Materials.jl") -using .Materials: Material, MaterialsLibrary - -# Submodule `EarthProps` -include("earthprops/EarthProps.jl") -using .EarthProps: EarthModel - -# Submodule `DataModel` -include("datamodel/DataModel.jl") -using .DataModel: Thickness, Diameter, WireArray, Strip, Tubular, Semicon, Insulator, - ConductorGroup, InsulatorGroup, CableComponent, CableDesign, NominalData, CablesLibrary, - CablePosition, LineCableSystem, trifoil_formation, flat_formation, preview, equivalent - -# Submodule `Engine` -include("engine/Engine.jl") -using .Engine: LineParametersProblem, compute!, SeriesImpedance, ShuntAdmittance, per_km, - per_m, kronify, FormulationSet - -# Submodule `ParametricBuilder` -include("parametricbuilder/ParametricBuilder.jl") - -# Submodule `ImportExport` -include("importexport/ImportExport.jl") -using .ImportExport: export_data, load!, save - +module LineCableModels + +## Public API +# ------------------------------------------------------------------------- +# Core generics: +export add!, set_verbosity!, set_backend! + +# Materials: +export Material, MaterialsLibrary + +# Data model (design + system): +export Thickness, Diameter, WireArray, Strip, Tubular, Semicon, Insulator +export ConductorGroup, InsulatorGroup +export CableComponent, CableDesign, NominalData +export CablesLibrary +export CablePosition, LineCableSystem +export trifoil_formation, flat_formation, preview, equivalent + +# Earth properties: +export EarthModel + +# Engine: +export LineParametersProblem, AmpacityProblem, + FormulationSet, + compute!, SeriesImpedance, ShuntAdmittance, per_km, per_m, kronify + +# Parametric builder: +export make_stranded, make_screened +# export conductor, insulator + +# Import/Export: +export export_data, save, load! +# ------------------------------------------------------------------------- + +import DocStringExtensions: DocStringExtensions + +# Submodule `Commons` +include("commons/Commons.jl") +using .Commons: IMPORTS, EXPORTS, add! + +# Submodule `UncertainBessels` +include("uncertainbessels/UncertainBessels.jl") + +# Submodule `Utils` +include("utils/Utils.jl") +using .Utils: set_verbosity! + +# Submodule `BackendHandler` +include("backendhandler/BackendHandler.jl") +using .BackendHandler: set_backend! + +# Submodule `PlotUIComponents` +include("plotuicomponents/PlotUIComponents.jl") + +# Submodule `Validation` +include("validation/Validation.jl") + +# Submodule `Materials` +include("materials/Materials.jl") +using .Materials: Material, MaterialsLibrary + +# Submodule `EarthProps` +include("earthprops/EarthProps.jl") +using .EarthProps: EarthModel + +# Submodule `DataModel` +include("datamodel/DataModel.jl") +using .DataModel: Thickness, Diameter, WireArray, Strip, Tubular, Semicon, Insulator, + ConductorGroup, InsulatorGroup, CableComponent, CableDesign, NominalData, CablesLibrary, + CablePosition, LineCableSystem, trifoil_formation, flat_formation, preview, equivalent + +# Submodule `Engine` +include("engine/Engine.jl") +using .Engine: LineParametersProblem, AmpacityProblem, compute!, SeriesImpedance, ShuntAdmittance, per_km, + per_m, kronify, FormulationSet + +# Submodule `ParametricBuilder` +include("parametricbuilder/ParametricBuilder.jl") + +# Submodule `ImportExport` +include("importexport/ImportExport.jl") +using .ImportExport: export_data, load!, save + end \ No newline at end of file diff --git a/src/backendhandler/BackendHandler.jl b/src/backendhandler/BackendHandler.jl index f05ef1ff..525e93e3 100644 --- a/src/backendhandler/BackendHandler.jl +++ b/src/backendhandler/BackendHandler.jl @@ -1,191 +1,191 @@ -""" -Makie backend handler for LineCableModels. - -Design goals: -- Precompile in any environment (never touch GL/WGL at load-time). -- Default to CairoMakie as a safe backend. -- Offer a single `set_backend!` API (no user `using` needed). -- In headless (e.g., Literate → Documenter), display PNG inline. - -How to wire it (minimal integration): - -1) Add this helper to your package module once: - - include(joinpath(@__DIR__, "..", "@INPROGRESS", "makie_backend_alt.jl")) - using .BackendHandler - -2) In Makie-based preview entrypoints, ensure a backend is active: - - # Use caller keyword `backend::Union{Nothing,Symbol}` if you keep it - BackendHandler.ensure_backend!(backend === nothing ? :cairo : backend) - -3) For GL interactive windows without directly referencing GLMakie: - - if BackendHandler.current_backend_symbol() == :gl - if (scr = BackendHandler.gl_screen("Title")) !== nothing - display(scr, fig) - else - display(fig) - end - else - display(fig) - end - -4) For docs/headless builds (Literate/Documenter) PNG inline display: - - # in your final display branch - BackendHandler.renderfig(fig) - -5) Let users select backends interactively (no extra imports): - - BackendHandler.set_backend!(:gl) # or :wgl, :cairo - -Notes: -- No `@eval import` anywhere; backends are loaded via `Base.require` using PkgId. -- Calls into newly loaded modules go through `Base.invokelatest` to avoid - world-age issues. -""" -module BackendHandler - -using Makie -using UUIDs -using ..Utils: is_headless - -export set_backend! - -# --------------------------------------------------------------------------- -# Backend registry -# --------------------------------------------------------------------------- - -const _BACKENDS = Dict{Symbol, Tuple{UUID, String}}( - :cairo => (UUID("13f3f980-e62b-5c42-98c6-ff1f3baf88f0"), "CairoMakie"), - :gl => (UUID("e9467ef8-e4e7-5192-8a1a-b1aee30e663a"), "GLMakie"), - :wgl => (UUID("276b4fcb-3e11-5398-bf8b-a0c2d153d008"), "WGLMakie"), -) - -_pkgid(sym::Symbol) = begin - tup = get(_BACKENDS, sym, nothing) - tup === nothing && throw( - ArgumentError("Unknown backend: $(sym). Valid: $(collect(keys(_BACKENDS)))"), - ) - Base.PkgId(tup[1], tup[2]) -end - -"""Return true if a backend package exists in the environment.""" -backend_available(backend::Symbol) = Base.find_package(last(_BACKENDS[backend])) !== nothing - -# Track the last activated backend symbol (separate from Makie.internal state) -const _active_backend = Base.RefValue{Symbol}(:none) - -# --------------------------------------------------------------------------- -# Activation core (lazy, world-age safe) -# --------------------------------------------------------------------------- - -function _activate_backend!(backend::Symbol; allow_interactive_in_headless::Bool = false) - if is_headless() && backend != :cairo && !allow_interactive_in_headless - @warn "Headless environment: forcing :cairo instead of $(backend)." - return _activate_backend!(:cairo; allow_interactive_in_headless) - end - - pid = _pkgid(backend) - # Load the backend module into Julia's module world; idempotent if already loaded - mod = Base.require(pid) - # Call `activate!` safely with world-age correctness - Base.invokelatest(getproperty(mod, :activate!)) - _active_backend[] = backend - return backend -end - -"""Ensure a backend is active. Defaults to :cairo the first time.""" -function ensure_backend!(backend::Union{Nothing, Symbol} = nothing) - if backend === nothing - return _active_backend[] == :none ? _activate_backend!(:cairo) : _active_backend[] - else - return set_backend!(backend) - end -end - -"""Activate a specific backend (:cairo, :gl, :wgl). - -In headless, :gl/:wgl requests fall back to :cairo unless `force=true`. -No `using` required by callers. -""" -function set_backend!(backend::Symbol; force::Bool = false) - haskey(_BACKENDS, backend) || throw( - ArgumentError("Unknown backend: $(backend). Valid: $(collect(keys(_BACKENDS)))"), - ) - if backend != :cairo && !backend_available(backend) - if !is_headless() - @warn "Backend $(last(_BACKENDS[backend])) not in environment; using :cairo." - end - - return _activate_backend!(:cairo) - end - return _activate_backend!(backend; allow_interactive_in_headless = force) -end - -"""Symbol of the current Makie backend (:cairo, :gl, :wgl, :unknown, :none).""" -function current_backend_symbol() - try - nb = nameof(Makie.current_backend()) - nb === :CairoMakie && return :cairo - nb === :GLMakie && return :gl - nb === :WGLMakie && return :wgl - return :unknown - catch - return :none - end -end - - -"""Display a figure appropriately in headless docs or interactive sessions. - -- Headless: returns `DisplayAs.Text(DisplayAs.PNG(fig))` if `DisplayAs` exists; - otherwise attempts to rasterize via CairoMakie and returns nothing. -- Interactive: calls `display(fig)` and returns its result. -""" -function renderfig(fig) - if is_headless() - try - D = Base.require( - Base.PkgId(UUID("0b91fe84-8a4c-11e9-3e1d-67c38462b6d6"), "DisplayAs"), - ) - return D.Text(D.PNG(fig)) - catch - try - ensure_backend!(:cairo) - cm = Base.require(_pkgid(:cairo)) - savef = getproperty(cm, :save) - io = IOBuffer() - Base.invokelatest(savef, io, fig) - return nothing - catch - return nothing - end - end - else - return display(fig) - end -end - -const FIG_NO = Base.Threads.Atomic{Int}(1) -next_fignum() = Base.Threads.atomic_add!(FIG_NO, 1) -reset_fignum!(n::Int = 1) = (FIG_NO[] = n) - -# --------------------------------------------------------------------------- -# Default backend at runtime load (safe: CairoMakie only) -# --------------------------------------------------------------------------- - -function __init__() - try - # Only set a default if nothing looks active yet - if current_backend_symbol() in (:none, :unknown) - ensure_backend!(:cairo) - end - catch e - @warn "Failed to initialize default CairoMakie" exception=(e, catch_backtrace()) - end -end - -end # module - +""" +Makie backend handler for LineCableModels. + +Design goals: +- Precompile in any environment (never touch GL/WGL at load-time). +- Default to CairoMakie as a safe backend. +- Offer a single `set_backend!` API (no user `using` needed). +- In headless (e.g., Literate → Documenter), display PNG inline. + +How to wire it (minimal integration): + +1) Add this helper to your package module once: + + include(joinpath(@__DIR__, "..", "@INPROGRESS", "makie_backend_alt.jl")) + using .BackendHandler + +2) In Makie-based preview entrypoints, ensure a backend is active: + + # Use caller keyword `backend::Union{Nothing,Symbol}` if you keep it + BackendHandler.ensure_backend!(backend === nothing ? :cairo : backend) + +3) For GL interactive windows without directly referencing GLMakie: + + if BackendHandler.current_backend_symbol() == :gl + if (scr = BackendHandler.gl_screen("Title")) !== nothing + display(scr, fig) + else + display(fig) + end + else + display(fig) + end + +4) For docs/headless builds (Literate/Documenter) PNG inline display: + + # in your final display branch + BackendHandler.renderfig(fig) + +5) Let users select backends interactively (no extra imports): + + BackendHandler.set_backend!(:gl) # or :wgl, :cairo + +Notes: +- No `@eval import` anywhere; backends are loaded via `Base.require` using PkgId. +- Calls into newly loaded modules go through `Base.invokelatest` to avoid + world-age issues. +""" +module BackendHandler + +using Makie +using UUIDs +using ..Utils: is_headless + +export set_backend! + +# --------------------------------------------------------------------------- +# Backend registry +# --------------------------------------------------------------------------- + +const _BACKENDS = Dict{Symbol, Tuple{UUID, String}}( + :cairo => (UUID("13f3f980-e62b-5c42-98c6-ff1f3baf88f0"), "CairoMakie"), + :gl => (UUID("e9467ef8-e4e7-5192-8a1a-b1aee30e663a"), "GLMakie"), + :wgl => (UUID("276b4fcb-3e11-5398-bf8b-a0c2d153d008"), "WGLMakie"), +) + +_pkgid(sym::Symbol) = begin + tup = get(_BACKENDS, sym, nothing) + tup === nothing && throw( + ArgumentError("Unknown backend: $(sym). Valid: $(collect(keys(_BACKENDS)))"), + ) + Base.PkgId(tup[1], tup[2]) +end + +"""Return true if a backend package exists in the environment.""" +backend_available(backend::Symbol) = Base.find_package(last(_BACKENDS[backend])) !== nothing + +# Track the last activated backend symbol (separate from Makie.internal state) +const _active_backend = Base.RefValue{Symbol}(:none) + +# --------------------------------------------------------------------------- +# Activation core (lazy, world-age safe) +# --------------------------------------------------------------------------- + +function _activate_backend!(backend::Symbol; allow_interactive_in_headless::Bool = false) + if is_headless() && backend != :cairo && !allow_interactive_in_headless + @warn "Headless environment: forcing :cairo instead of $(backend)." + return _activate_backend!(:cairo; allow_interactive_in_headless) + end + + pid = _pkgid(backend) + # Load the backend module into Julia's module world; idempotent if already loaded + mod = Base.require(pid) + # Call `activate!` safely with world-age correctness + Base.invokelatest(getproperty(mod, :activate!)) + _active_backend[] = backend + return backend +end + +"""Ensure a backend is active. Defaults to :cairo the first time.""" +function ensure_backend!(backend::Union{Nothing, Symbol} = nothing) + if backend === nothing + return _active_backend[] == :none ? _activate_backend!(:cairo) : _active_backend[] + else + return set_backend!(backend) + end +end + +"""Activate a specific backend (:cairo, :gl, :wgl). + +In headless, :gl/:wgl requests fall back to :cairo unless `force=true`. +No `using` required by callers. +""" +function set_backend!(backend::Symbol; force::Bool = false) + haskey(_BACKENDS, backend) || throw( + ArgumentError("Unknown backend: $(backend). Valid: $(collect(keys(_BACKENDS)))"), + ) + if backend != :cairo && !backend_available(backend) + if !is_headless() + @warn "Backend $(last(_BACKENDS[backend])) not in environment; using :cairo." + end + + return _activate_backend!(:cairo) + end + return _activate_backend!(backend; allow_interactive_in_headless = force) +end + +"""Symbol of the current Makie backend (:cairo, :gl, :wgl, :unknown, :none).""" +function current_backend_symbol() + try + nb = nameof(Makie.current_backend()) + nb === :CairoMakie && return :cairo + nb === :GLMakie && return :gl + nb === :WGLMakie && return :wgl + return :unknown + catch + return :none + end +end + + +"""Display a figure appropriately in headless docs or interactive sessions. + +- Headless: returns `DisplayAs.Text(DisplayAs.PNG(fig))` if `DisplayAs` exists; + otherwise attempts to rasterize via CairoMakie and returns nothing. +- Interactive: calls `display(fig)` and returns its result. +""" +function renderfig(fig) + if is_headless() + try + D = Base.require( + Base.PkgId(UUID("0b91fe84-8a4c-11e9-3e1d-67c38462b6d6"), "DisplayAs"), + ) + return D.Text(D.PNG(fig)) + catch + try + ensure_backend!(:cairo) + cm = Base.require(_pkgid(:cairo)) + savef = getproperty(cm, :save) + io = IOBuffer() + Base.invokelatest(savef, io, fig) + return nothing + catch + return nothing + end + end + else + return display(fig) + end +end + +const FIG_NO = Base.Threads.Atomic{Int}(1) +next_fignum() = Base.Threads.atomic_add!(FIG_NO, 1) +reset_fignum!(n::Int = 1) = (FIG_NO[] = n) + +# --------------------------------------------------------------------------- +# Default backend at runtime load (safe: CairoMakie only) +# --------------------------------------------------------------------------- + +function __init__() + try + # Only set a default if nothing looks active yet + if current_backend_symbol() in (:none, :unknown) + ensure_backend!(:cairo) + end + catch e + @warn "Failed to initialize default CairoMakie" exception=(e, catch_backtrace()) + end +end + +end # module + diff --git a/src/commons/Commons.jl b/src/commons/Commons.jl index 18bab257..1a9b3455 100644 --- a/src/commons/Commons.jl +++ b/src/commons/Commons.jl @@ -1,14 +1,14 @@ -module Commons - -include("docstringextension.jl") -include("consts.jl") - - -export get_description, add! - -function get_description end - -function add! end - - +module Commons + +include("docstringextension.jl") +include("consts.jl") + + +export get_description, add! + +function get_description end + +function add! end + + end \ No newline at end of file diff --git a/src/commons/consts.jl b/src/commons/consts.jl index c58b7b1d..bb0a3856 100644 --- a/src/commons/consts.jl +++ b/src/commons/consts.jl @@ -1,26 +1,26 @@ -# Export public API -export f₀, μ₀, ε₀, ρ₀, T₀, TOL, ΔTmax -export BASE_FLOAT, REALSCALAR, COMPLEXSCALAR - -# General constants -"Base power system frequency, f₀ = 50.0 [Hz]." -const f₀ = 50.0 -"Magnetic constant (vacuum permeability), μ₀ = 4π * 1e-7 [H/m]." -const μ₀ = 4π * 1e-7 -"Electric constant (vacuum permittivity), ε₀ = 8.8541878128e-12 [F/m]." -const ε₀ = 8.8541878128e-12 -"Annealed copper reference resistivity, ρ₀ = 1.724e-08 [Ω·m]." -const ρ₀ = 1.724e-08 -"Base temperature for conductor properties, T₀ = 20.0 [°C]." -const T₀ = 20.0 -"Maximum tolerance for temperature variations, ΔTmax = 150 [°C]." -const ΔTmax = 150.0 -"Default tolerance for floating-point comparisons, TOL = 1e-6." -const TOL = 1e-6 - -# Define aliases for the type constraints -using Measurements: Measurement -const BASE_FLOAT = Float64 -const REALSCALAR = Union{BASE_FLOAT, Measurement{BASE_FLOAT}} -const COMPLEXSCALAR = Union{Complex{BASE_FLOAT}, Complex{Measurement{BASE_FLOAT}}} - +# Export public API +export f₀, μ₀, ε₀, ρ₀, T₀, TOL, ΔTmax +export BASE_FLOAT, REALSCALAR, COMPLEXSCALAR + +# General constants +"Base power system frequency, f₀ = 50.0 [Hz]." +const f₀ = 50.0 +"Magnetic constant (vacuum permeability), μ₀ = 4π * 1e-7 [H/m]." +const μ₀ = 4π * 1e-7 +"Electric constant (vacuum permittivity), ε₀ = 8.8541878128e-12 [F/m]." +const ε₀ = 8.8541878128e-12 +"Annealed copper reference resistivity, ρ₀ = 1.724e-08 [Ω·m]." +const ρ₀ = 1.724e-08 +"Base temperature for conductor properties, T₀ = 20.0 [°C]." +const T₀ = 20.0 +"Maximum tolerance for temperature variations, ΔTmax = 150 [°C]." +const ΔTmax = 150.0 +"Default tolerance for floating-point comparisons, TOL = 1e-6." +const TOL = 1e-6 + +# Define aliases for the type constraints +using Measurements: Measurement +const BASE_FLOAT = Float64 +const REALSCALAR = Union{BASE_FLOAT, Measurement{BASE_FLOAT}} +const COMPLEXSCALAR = Union{Complex{BASE_FLOAT}, Complex{Measurement{BASE_FLOAT}}} + diff --git a/src/commons/docstringextension.jl b/src/commons/docstringextension.jl index 82b7ba68..03bc0692 100644 --- a/src/commons/docstringextension.jl +++ b/src/commons/docstringextension.jl @@ -1,54 +1,54 @@ -using Pkg -using DocStringExtensions: DocStringExtensions, SIGNATURES, TYPEDSIGNATURES, TYPEDEF, TYPEDFIELDS, FIELDS, FUNCTIONNAME, IMPORTS, EXPORTS - -export SIGNATURES, TYPEDSIGNATURES, TYPEDEF, TYPEDFIELDS, FIELDS, FUNCTIONNAME, METHODLIST, IMPORTS, EXPORTS - -""" -Override `DocStringExtensions.format` for `METHODLIST`. -""" -struct _CleanMethodList <: DocStringExtensions.Abbreviation end -"Modified `METHODLIST` abbreviation with sanitized file paths." -const _CLEANMETHODLIST = _CleanMethodList() -const METHODLIST = _CLEANMETHODLIST - -function DocStringExtensions.format(::_CleanMethodList, buf, doc) - local binding = doc.data[:binding] - local typesig = doc.data[:typesig] - local modname = doc.data[:module] - local func = Docs.resolve(binding) - local groups = DocStringExtensions.methodgroups(func, typesig, modname; exact=false) - if !isempty(groups) - println(buf) - local pkg_root = Pkg.pkgdir(modname) # Use Pkg.pkgdir here - if pkg_root === nothing - @warn "Could not determine package root for module $modname using METHODLIST. Paths will be shown as basenames." - end - for group in groups - println(buf, "```julia") - for method in group - DocStringExtensions.printmethod(buf, binding, func, method) - println(buf) - end - println(buf, "```\n") - if !isempty(group) - local method = group[1] - local file = string(method.file) - local line = method.line - local path = - if pkg_root !== nothing && !isempty(file) && - startswith(file, pkg_root) - basename(file) # relpath(file, pkg_root) - # elseif !isempty(file) && isfile(file) - # basename(file) - else - string(method.file) # Fallback - end - local URL = DocStringExtensions.url(method) - isempty(URL) || println(buf, "defined at [`$path:$line`]($URL).") - end - println(buf) - end - println(buf) - end - return nothing +using Pkg +using DocStringExtensions: DocStringExtensions, SIGNATURES, TYPEDSIGNATURES, TYPEDEF, TYPEDFIELDS, FIELDS, FUNCTIONNAME, IMPORTS, EXPORTS + +export SIGNATURES, TYPEDSIGNATURES, TYPEDEF, TYPEDFIELDS, FIELDS, FUNCTIONNAME, METHODLIST, IMPORTS, EXPORTS + +""" +Override `DocStringExtensions.format` for `METHODLIST`. +""" +struct _CleanMethodList <: DocStringExtensions.Abbreviation end +"Modified `METHODLIST` abbreviation with sanitized file paths." +const _CLEANMETHODLIST = _CleanMethodList() +const METHODLIST = _CLEANMETHODLIST + +function DocStringExtensions.format(::_CleanMethodList, buf, doc) + local binding = doc.data[:binding] + local typesig = doc.data[:typesig] + local modname = doc.data[:module] + local func = Docs.resolve(binding) + local groups = DocStringExtensions.methodgroups(func, typesig, modname; exact=false) + if !isempty(groups) + println(buf) + local pkg_root = Pkg.pkgdir(modname) # Use Pkg.pkgdir here + if pkg_root === nothing + @warn "Could not determine package root for module $modname using METHODLIST. Paths will be shown as basenames." + end + for group in groups + println(buf, "```julia") + for method in group + DocStringExtensions.printmethod(buf, binding, func, method) + println(buf) + end + println(buf, "```\n") + if !isempty(group) + local method = group[1] + local file = string(method.file) + local line = method.line + local path = + if pkg_root !== nothing && !isempty(file) && + startswith(file, pkg_root) + basename(file) # relpath(file, pkg_root) + # elseif !isempty(file) && isfile(file) + # basename(file) + else + string(method.file) # Fallback + end + local URL = DocStringExtensions.url(method) + isempty(URL) || println(buf, "defined at [`$path:$line`]($URL).") + end + println(buf) + end + println(buf) + end + return nothing end \ No newline at end of file diff --git a/src/datamodel/DataModel.jl b/src/datamodel/DataModel.jl index 07ae4793..8a47d1e0 100644 --- a/src/datamodel/DataModel.jl +++ b/src/datamodel/DataModel.jl @@ -1,95 +1,95 @@ -""" - LineCableModels.DataModel - -The [`DataModel`](@ref) module provides data structures, constructors and utilities for modeling power cables within the [`LineCableModels.jl`](index.md) package. This module includes definitions for various cable components, and visualization tools for cable designs. - -# Overview - -- Provides objects for detailed cable modeling with the [`CableDesign`](@ref) and supporting types: [`WireArray`](@ref), [`Strip`](@ref), [`Tubular`](@ref), [`Semicon`](@ref), and [`Insulator`](@ref). -- Includes objects for cable **system** modeling with the [`LineCableSystem`](@ref) type, and multiple formation patterns like trifoil and flat arrangements. -- Contains functions for calculating the base electric properties of all elements within a [`CableDesign`](@ref), namely: resistance, inductance (via GMR), shunt capacitance, and shunt conductance (via loss factor). -- Offers visualization tools for previewing cable cross-sections and system layouts. -- Provides a library system for storing and retrieving cable designs. - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module DataModel - -# Export public API -export Thickness, Diameter # Type definitions -export WireArray, Strip, Tubular # Conductor types -export Semicon, Insulator # Insulator types -export ConductorGroup, InsulatorGroup # Group types -export CableComponent, CableDesign # Cable design types -export CablePosition, LineCableSystem # System types -export CablesLibrary, NominalData # Support types -export trifoil_formation, flat_formation # Formation helpers -export preview, equivalent - -# Module-specific dependencies -using ..Commons -import ..Commons: add! -using ..Utils: - resolve_T, to_certain, to_nominal, is_headless, - is_in_testset, to_lower, to_upper -import ..Utils: coerce_to_T, to_lower -using ..Materials: Material -import ..BackendHandler: set_backend!, ensure_backend!, current_backend_symbol, - backend_available, renderfig, next_fignum -import ..PlotUIComponents: gl_screen, with_icon, MI_REFRESH, MI_SAVE, ICON_TTF -import ..Validation: Validation, sanitize, validate!, has_radii, has_temperature, - extra_rules, IntegerField, Positive, Finite, Normalized, IsA, required_fields, - coercive_fields, keyword_fields, keyword_defaults, _kwdefaults_nt, is_radius_input, - Nonneg, OneOf -using Measurements -using DataFrames -using Colors -using Plots -using DisplayAs: DisplayAs - -# Abstract types & interfaces -include("types.jl") -include("radii.jl") - -# Submodule `BaseParams` -include("baseparams/BaseParams.jl") -using .BaseParams - -# Constructors -include("macros.jl") -include("validation.jl") - -# Conductors -include("wirearray.jl") -include("strip.jl") -include("tubular.jl") -include("conductorgroup.jl") - -# Insulators -include("insulator.jl") -include("semicon.jl") -include("insulatorgroup.jl") - - -# Groups -include("nominaldata.jl") -include("cablecomponent.jl") -include("cabledesign.jl") - -# Library -include("cableslibrary.jl") -include("linecablesystem.jl") - -# Helpers & overrides -include("helpers.jl") -include("preview.jl") -include("io.jl") -include("typecoercion.jl") - +""" + LineCableModels.DataModel + +The [`DataModel`](@ref) module provides data structures, constructors and utilities for modeling power cables within the [`LineCableModels.jl`](index.md) package. This module includes definitions for various cable components, and visualization tools for cable designs. + +# Overview + +- Provides objects for detailed cable modeling with the [`CableDesign`](@ref) and supporting types: [`WireArray`](@ref), [`Strip`](@ref), [`Tubular`](@ref), [`Semicon`](@ref), and [`Insulator`](@ref). +- Includes objects for cable **system** modeling with the [`LineCableSystem`](@ref) type, and multiple formation patterns like trifoil and flat arrangements. +- Contains functions for calculating the base electric properties of all elements within a [`CableDesign`](@ref), namely: resistance, inductance (via GMR), shunt capacitance, and shunt conductance (via loss factor). +- Offers visualization tools for previewing cable cross-sections and system layouts. +- Provides a library system for storing and retrieving cable designs. + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module DataModel + +# Export public API +export Thickness, Diameter # Type definitions +export WireArray, Strip, Tubular # Conductor types +export Semicon, Insulator # Insulator types +export ConductorGroup, InsulatorGroup # Group types +export CableComponent, CableDesign # Cable design types +export CablePosition, LineCableSystem # System types +export CablesLibrary, NominalData # Support types +export trifoil_formation, flat_formation # Formation helpers +export preview, equivalent + +# Module-specific dependencies +using ..Commons +import ..Commons: add! +using ..Utils: + resolve_T, to_certain, to_nominal, is_headless, + is_in_testset, to_lower, to_upper +import ..Utils: coerce_to_T, to_lower +using ..Materials: Material +import ..BackendHandler: set_backend!, ensure_backend!, current_backend_symbol, + backend_available, renderfig, next_fignum +import ..PlotUIComponents: gl_screen, with_icon, MI_REFRESH, MI_SAVE, ICON_TTF +import ..Validation: Validation, sanitize, validate!, has_radii, has_temperature, + extra_rules, IntegerField, Positive, Finite, Normalized, IsA, required_fields, + coercive_fields, keyword_fields, keyword_defaults, _kwdefaults_nt, is_radius_input, + Nonneg, OneOf +using Measurements +using DataFrames +using Colors +using Plots +using DisplayAs: DisplayAs + +# Abstract types & interfaces +include("types.jl") +include("radii.jl") + +# Submodule `BaseParams` +include("baseparams/BaseParams.jl") +using .BaseParams + +# Constructors +include("macros.jl") +include("validation.jl") + +# Conductors +include("wirearray.jl") +include("strip.jl") +include("tubular.jl") +include("conductorgroup.jl") + +# Insulators +include("insulator.jl") +include("semicon.jl") +include("insulatorgroup.jl") + + +# Groups +include("nominaldata.jl") +include("cablecomponent.jl") +include("cabledesign.jl") + +# Library +include("cableslibrary.jl") +include("linecablesystem.jl") + +# Helpers & overrides +include("helpers.jl") +include("preview.jl") +include("io.jl") +include("typecoercion.jl") + end # module DataModel \ No newline at end of file diff --git a/src/datamodel/baseparams/BaseParams.jl b/src/datamodel/baseparams/BaseParams.jl index 407f8665..93bbb38f 100644 --- a/src/datamodel/baseparams/BaseParams.jl +++ b/src/datamodel/baseparams/BaseParams.jl @@ -1,1370 +1,1477 @@ -""" - LineCableModels.DataModel.BaseParams - -The [`BaseParams`](@ref) submodule provides fundamental functions for determining the base electrical parameters (R, L, C, G) of cable components within the [`LineCableModels.DataModel`](@ref) module. This includes implementations of standard engineering formulas for resistance, inductance, and geometric parameters of various conductor configurations. - -# Overview - -- Implements basic electrical engineering formulas for calculating DC resistance and inductance of different conductor geometries (tubular, strip, wire arrays). -- Implements basic formulas for capacitance and dielectric losses in insulators and semiconductors. -- Provides functions for temperature correction of material properties. -- Calculates geometric mean radii for different conductor configurations. -- Includes functions for determining the effective length for helical wire arrangements. -- Calculates equivalent electrical parameters and correction factors for different geometries and configurations. - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module BaseParams - -# Export public API -export calc_equivalent_alpha -export calc_parallel_equivalent -export calc_helical_params -export calc_strip_resistance -export calc_temperature_correction -export calc_tubular_resistance -export calc_tubular_inductance -export calc_wirearray_coords -export calc_inductance_trifoil -export calc_wirearray_gmr -export calc_tubular_gmr -export calc_equivalent_mu -export calc_shunt_capacitance -export calc_shunt_conductance -export calc_equivalent_gmr -export calc_gmd -export calc_solenoid_correction -export calc_equivalent_rho -export calc_equivalent_eps -export calc_equivalent_lossfact -export calc_sigma_lossfact - -# Module-specific dependencies -using Measurements -using ...Commons -import ..DataModel: AbstractCablePart -using ...Utils: resolve_T, coerce_to_T - - -""" -$(TYPEDSIGNATURES) - -Calculates the equivalent temperature coefficient of resistance (`alpha`) when two conductors are connected in parallel, by cross-weighted-resistance averaging: - -```math -\\alpha_{eq} = \\frac{\\alpha_1 R_2 + \\alpha_2 R1}{R_1 + R_2} -``` -where ``\\alpha_1``, ``\\alpha_2`` are the temperature coefficients of the conductors, and ``R_1``, ``R_2`` are the respective resistances. - -# Arguments - -- `alpha1`: Temperature coefficient of resistance of the first conductor \\[1/°C\\]. -- `R1`: Resistance of the first conductor \\[Ω\\]. -- `alpha2`: Temperature coefficient of resistance of the second conductor \\[1/°C\\]. -- `R2`: Resistance of the second conductor \\[Ω\\]. - -# Returns - -- The equivalent temperature coefficient \\[1/°C\\] for the parallel combination. - -# Examples - -```julia -alpha_conductor = 0.00393 # Copper -alpha_new_part = 0.00403 # Aluminum -R_conductor = 0.5 -R_new_part = 1.0 -alpha_eq = $(FUNCTIONNAME)(alpha_conductor, R_conductor, alpha_new_part, R_new_part) -println(alpha_eq) # Output: 0.00396 (approximately) -``` -""" -function calc_equivalent_alpha(alpha1::T, R1::T, alpha2::T, R2::T) where {T <: REALSCALAR} - return (alpha1 * R2 + alpha2 * R1) / (R1 + R2) -end - -function calc_equivalent_alpha(alpha1, R1, alpha2, R2) - T = resolve_T(alpha1, R1, alpha2, R2) - return calc_equivalent_alpha( - coerce_to_T(alpha1, T), - coerce_to_T(R1, T), - coerce_to_T(alpha2, T), - coerce_to_T(R2, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the parallel equivalent of two impedances (or series equivalent of two admittances): - -```math -Z_{eq} = \\frac{Z_1 Z_2}{Z_1 + Z_2} -``` - -This expression, when applied recursively to [`LineCableModels.DataModel.WireArray`](@ref) objects, implements the formula for the hexagonal wiring pattern described in CIGRE TB-345 [app14198982](@cite) [cigre345](@cite): - -```math -\\frac{1}{R_{\\text{dc}}} = \\frac{\\pi d^2}{4 \\rho} \\left( 1 + \\sum_{1}^{n} \\frac{6n}{k_n} \\right) -``` - -```math -k_n = \\left[ 1 + \\left( \\pi \\frac{D_n}{\\lambda_n} \\right)^2 \\right]^{1/2} -``` - -where ``R_{\\text{dc}}`` is the DC resistance, ``d`` is the diameter of each wire, ``\rho`` is the resistivity, ``n`` is the number of layers following the hexagonal pattern, ``D_n`` is the diameter of the ``n``-th layer, and ``\\lambda_n `` is the pitch length of the ``n``-th layer, obtained using [`calc_helical_params`](@ref). - -# Arguments - -- `Z1`: The total impedance of the existing system \\[Ω\\]. -- `Z2`: The impedance of the new layer being added \\[Ω\\]. - -# Returns - -- The parallel equivalent impedance \\[Ω\\]. - -# Examples - -```julia -Z1 = 5.0 -Z2 = 10.0 -Req = $(FUNCTIONNAME)(Z1, Z2) -println(Req) # Outputs: 3.3333333333333335 -``` - -# See also - -- [`calc_helical_params`](@ref) -""" -function calc_parallel_equivalent( - Z1::T, - Z2::T, -) where {T <: Union{REALSCALAR, COMPLEXSCALAR}} - - # Case 1: Inf / Inf -> NaN - # The parallel combination of an open circuit (Inf) and any finite impedance is the finite impedance. - if isinf(Z1) - return Z2 - elseif isinf(Z2) - return Z1 - end - - # Case 2: 0 / 0 -> NaN - # The parallel combination of two short circuits (0) is a short circuit. - # The standard formula works fine if only one is zero, but not if both are. - if iszero(Z1) && iszero(Z2) - return zero(T) - end - return (Z1 * Z2) / (Z1 + Z2) -end - -function calc_parallel_equivalent(Z1, Z2) - T = resolve_T(Z1, Z2) - return calc_parallel_equivalent( - coerce_to_T(Z1, T), - coerce_to_T(Z2, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the mean diameter, pitch length, and overlength based on cable geometry parameters. The lay ratio is defined as the ratio of the pitch length ``L_p`` to the external diameter ``D_e``: - -```math -\\lambda = \\frac{L_p}{D_e} -``` -where ``D_e`` and ``L_p`` are the dimensions represented in the figure. - -![](./assets/lay_ratio.svg) - -# Arguments - -- `radius_in`: Inner radius of the cable layer \\[m\\]. -- `radius_ext`: Outer radius of the cable layer \\[m\\]. -- `lay_ratio`: Ratio of the pitch (lay) length to the external diameter of the corresponding layer of wires \\[dimensionless\\]. - -# Returns - -- `mean_diameter`: Mean diameter of the cable layer \\[m\\]. -- `pitch_length`: The length over which the strands complete one full twist \\[m\\]. -- `overlength`: Effective length increase resulting from the helical path \\[1/m\\]. - -# Notes - -Reference values for `lay_ratio` are given under standard EN 50182 [CENELEC50182](@cite): - -| Conductor type | Steel wires | Aluminum wires | Lay ratio - Steel | Lay ratio - Aluminum | -|---------------|----------------------|---------------------|----------------------|-------------------| -| AAAC 4 layers | - | 61 (1/6/12/18/24) | - | 15/13.5/12.5/11 | -| ACSR 3 layers | 7 (1/6) | 54 (12/18/24) | 19 | 15/13/11.5 | -| ACSR 2 layers | 7 (1/6) | 26 (10/16) | 19 | 14/11.5 | -| ACSR 1 layer | 7 (1/6) | 10 | 19 | 14 | -| ACCC/TW | - | 36 (8/12/16) | - | 15/13.5/11.5 | - -# Examples - -```julia -radius_in = 0.01 -radius_ext = 0.015 -lay_ratio = 12 - -mean_diam, pitch, overlength = $(FUNCTIONNAME)(radius_in, radius_ext, lay_ratio) -# mean_diam ≈ 0.025 [m] -# pitch ≈ 0.3 [m] -# overlength > 1.0 [1/m] -``` -""" -function calc_helical_params( - radius_in::T, - radius_ext::T, - lay_ratio::T, -) where {T <: REALSCALAR} - mean_diameter = 2 * (radius_in + (radius_ext - radius_in) / 2) - pitch_length = lay_ratio * mean_diameter - overlength = - !isapprox(pitch_length, 0.0) ? sqrt(1 + (π * mean_diameter / pitch_length)^2) : 1 - - return mean_diameter, pitch_length, overlength -end - -function calc_helical_params(radius_in, radius_ext, lay_ratio) - T = resolve_T(radius_in, radius_ext, lay_ratio) - return calc_helical_params( - coerce_to_T(radius_in, T), - coerce_to_T(radius_ext, T), - coerce_to_T(lay_ratio, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the DC resistance of a strip conductor based on its geometric and material properties, using the basic resistance formula in terms of the resistivity and cross-sectional area: - -```math -R = \\rho \\frac{\\ell}{W T} -``` -where ``\\ell`` is the length of the strip, ``W`` is the width, and ``T`` is the thickness. The length is assumed to be infinite in the direction of current flow, so the resistance is calculated per unit length. - -# Arguments - -- `thickness`: Thickness of the strip \\[m\\]. -- `width`: Width of the strip \\[m\\]. -- `rho`: Electrical resistivity of the conductor material \\[Ω·m\\]. -- `alpha`: Temperature coefficient of resistivity \\[1/°C\\]. -- `T0`: Reference temperature for the material properties \\[°C\\]. -- `Top`: Operating temperature of the conductor \\[°C\\]. - -# Returns - -- DC resistance of the strip conductor \\[Ω\\]. - -# Examples - -```julia -thickness = 0.002 -width = 0.05 -rho = 1.7241e-8 -alpha = 0.00393 -T0 = 20 -T = 25 -resistance = $(FUNCTIONNAME)(thickness, width, rho, alpha, T0, T) -# Output: ~0.0001758 Ω -``` - -# See also - -- [`calc_temperature_correction`](@ref) -""" -function calc_strip_resistance( - thickness::T, - width::T, - rho::T, - alpha::T, - T0::T, - Top::T, -) where {T <: REALSCALAR} - - cross_section = thickness * width - return calc_temperature_correction(alpha, Top, T0) * rho / cross_section -end - -function calc_strip_resistance(thickness, width, rho, alpha, T0, Top) - T = resolve_T(thickness, width, rho, alpha, T0, Top) - return calc_strip_resistance( - coerce_to_T(thickness, T), - coerce_to_T(width, T), - coerce_to_T(rho, T), - coerce_to_T(alpha, T), - coerce_to_T(T0, T), - coerce_to_T(Top, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the temperature correction factor for material properties based on the standard linear temperature model [cigre345](@cite): - -```math -k(T) = 1 + \\alpha (T - T_0) -``` -where ``\\alpha`` is the temperature coefficient of the material resistivity, ``T`` is the operating temperature, and ``T_0`` is the reference temperature. - -# Arguments - -- `alpha`: Temperature coefficient of the material property \\[1/°C\\]. -- `T`: Current temperature \\[°C\\]. -- `T0`: Reference temperature at which the base material property was measured \\[°C\\]. Defaults to T₀. - -# Returns - -- Temperature correction factor to be applied to the material property \\[dimensionless\\]. - -# Examples - -```julia - # Copper resistivity correction (alpha = 0.00393 [1/°C]) - k = $(FUNCTIONNAME)(0.00393, 75.0, 20.0) # Expected output: 1.2161 -``` -""" -function calc_temperature_correction(alpha::T, Top::T, T0::T = T₀) where {T <: REALSCALAR} - @assert abs(Top - T0) < ΔTmax """ - Temperature is outside the valid range for linear resistivity model: - Top = $Top - T0 = $T0 - ΔTmax = $ΔTmax - |Top - T0| = $(abs(Top - T0))""" - return 1 + alpha * (Top - T0) -end - -function calc_temperature_correction(alpha, Top, T0 = T₀) - T = resolve_T(alpha, Top, T0) - return calc_temperature_correction( - coerce_to_T(alpha, T), - coerce_to_T(Top, T), - coerce_to_T(T0, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the DC resistance of a tubular conductor based on its geometric and material properties, using the resistivity and cross-sectional area of a hollow cylinder with radii ``r_{in}`` and ``r_{ext}``: - -```math -R = \\rho \\frac{\\ell}{\\pi (r_{ext}^2 - r_{in}^2)} -``` -where ``\\ell`` is the length of the conductor, ``r_{in}`` and ``r_{ext}`` are the inner and outer radii, respectively. The length is assumed to be infinite in the direction of current flow, so the resistance is calculated per unit length. - -# Arguments - -- `radius_in`: Internal radius of the tubular conductor \\[m\\]. -- `radius_ext`: External radius of the tubular conductor \\[m\\]. -- `rho`: Electrical resistivity of the conductor material \\[Ω·m\\]. -- `alpha`: Temperature coefficient of resistivity \\[1/°C\\]. -- `T0`: Reference temperature for the material properties \\[°C\\]. -- `Top`: Operating temperature of the conductor \\[°C\\]. - -# Returns - -- DC resistance of the tubular conductor \\[Ω\\]. - -# Examples - -```julia -radius_in = 0.01 -radius_ext = 0.02 -rho = 1.7241e-8 -alpha = 0.00393 -T0 = 20 -T = 25 -resistance = $(FUNCTIONNAME)(radius_in, radius_ext, rho, alpha, T0, T) -# Output: ~9.10e-8 Ω -``` - -# See also - -- [`calc_temperature_correction`](@ref) -""" -function calc_tubular_resistance( - radius_in::T, - radius_ext::T, - rho::T, - alpha::T, - T0::T, - Top::T, -) where {T <: REALSCALAR} - cross_section = π * (radius_ext^2 - radius_in^2) - return calc_temperature_correction(alpha, Top, T0) * rho / cross_section -end - -function calc_tubular_resistance(radius_in, radius_ext, rho, alpha, T0, Top) - T = resolve_T(radius_in, radius_ext, rho, alpha, T0, Top) - return calc_tubular_resistance( - coerce_to_T(radius_in, T), - coerce_to_T(radius_ext, T), - coerce_to_T(rho, T), - coerce_to_T(alpha, T), - coerce_to_T(T0, T), - coerce_to_T(Top, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the inductance of a tubular conductor per unit length, disregarding skin-effects (DC approximation) [916943](@cite) [cigre345](@cite) [1458878](@cite): - -```math -L = \\frac{\\mu_r \\mu_0}{2 \\pi} \\log \\left( \\frac{r_{ext}}{r_{in}} \\right) -``` -where ``\\mu_r`` is the relative permeability of the conductor material, ``\\mu_0`` is the vacuum permeability, and ``r_{in}`` and ``r_{ext}`` are the inner and outer radii of the conductor, respectively. - -# Arguments - -- `radius_in`: Internal radius of the tubular conductor \\[m\\]. -- `radius_ext`: External radius of the tubular conductor \\[m\\]. -- `mu_r`: Relative permeability of the conductor material \\[dimensionless\\]. - -# Returns - -- Internal inductance of the tubular conductor per unit length \\[H/m\\]. - -# Examples - -```julia -radius_in = 0.01 -radius_ext = 0.02 -mu_r = 1.0 -L = $(FUNCTIONNAME)(radius_in, radius_ext, mu_r) -# Output: ~2.31e-7 H/m -``` - -# See also - -- [`calc_tubular_resistance`](@ref) -""" -function calc_tubular_inductance( - radius_in::T, - radius_ext::T, - mu_r::T, -) where {T <: REALSCALAR} - return mu_r * μ₀ / (2 * π) * log(radius_ext / radius_in) -end - -function calc_tubular_inductance(radius_in, radius_ext, mu_r) - T = resolve_T(radius_in, radius_ext, mu_r) - return calc_tubular_inductance( - coerce_to_T(radius_in, T), - coerce_to_T(radius_ext, T), - coerce_to_T(mu_r, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the center coordinates of wires arranged in a circular pattern. - -# Arguments - -- `num_wires`: Number of wires in the circular arrangement \\[dimensionless\\]. -- `radius_wire`: Radius of each individual wire \\[m\\]. -- `radius_in`: Inner radius of the wire array (to wire centers) \\[m\\]. -- `C`: Optional tuple representing the center coordinates of the circular arrangement \\[m\\]. Default is (0.0, 0.0). - -# Returns - -- Vector of tuples, where each tuple contains the `(x, y)` coordinates \\[m\\] of the center of a wire. - -# Examples - -```julia -# Create a 7-wire array with 2mm wire radius and 1cm inner radius -wire_coords = $(FUNCTIONNAME)(7, 0.002, 0.01) -println(wire_coords[1]) # Output: First wire coordinates - -# Create a wire array with custom center position -wire_coords = $(FUNCTIONNAME)(7, 0.002, 0.01, C=(0.5, 0.3)) -``` - -# See also - -- [`LineCableModels.DataModel.WireArray`](@ref) -""" -function calc_wirearray_coords( - num_wires::U, - radius_wire::T, - radius_in::T, - C::Tuple{T, T}, -) where {T <: REALSCALAR, U <: Int} - wire_coords = Tuple{T, T}[] # Global coordinates of all wires - lay_radius = num_wires == 1 ? 0 : radius_in + radius_wire - - # Calculate the angle between each wire - angle_step = 2 * π / num_wires - for i in 0:(num_wires-1) - angle = i * angle_step - x = C[1] + lay_radius * cos(angle) - y = C[2] + lay_radius * sin(angle) - push!(wire_coords, (x, y)) # Add wire center - end - return wire_coords -end - -function calc_wirearray_coords(num_wires::Int, radius_wire, radius_in; C = nothing) - T = - C === nothing ? resolve_T(radius_wire, radius_in) : - resolve_T(radius_wire, radius_in, C...) - C_val = C === nothing ? coerce_to_T((0.0, 0.0), T) : coerce_to_T(C, T) - return calc_wirearray_coords( - num_wires, - coerce_to_T(radius_wire, T), - coerce_to_T(radius_in, T), - C_val, - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the positive-sequence inductance of a trifoil-configured cable system composed of core/screen assuming solid bonding, using the formula given under section 4.2.4.3 of CIGRE TB-531: - -```math -Z_d = \\left[Z_a - Z_x\\right] - \\frac{\\left( Z_m - Z_x \\right)^2}{Z_s - Z_x} -``` -```math -L = \\mathfrak{Im}\\left(\\frac{Z_d}{\\omega}\\right) -``` -where ``Z_a``, ``Z_s`` are the self impedances of the core conductor and the screen, and ``Z_m``, and ``Z_x`` are the mutual impedances between core/screen and between cables, respectively, as per sections 4.2.3.4, 4.2.3.5, 4.2.3.6 and 4.2.3.8 of the same document [cigre531](@cite). - -# Arguments - -- `r_in_co`: Internal radius of the phase conductor \\[m\\]. -- `r_ext_co`: External radius of the phase conductor \\[m\\]. -- `rho_co`: Electrical resistivity of the phase conductor material \\[Ω·m\\]. -- `mu_r_co`: Relative permeability of the phase conductor material \\[dimensionless\\]. -- `r_in_scr`: Internal radius of the metallic screen \\[m\\]. -- `r_ext_scr`: External radius of the metallic screen \\[m\\]. -- `rho_scr`: Electrical resistivity of the metallic screen material \\[Ω·m\\]. -- `mu_r_scr`: Relative permeability of the screen conductor material \\[dimensionless\\]. -- `S`: Spacing between conductors in trifoil configuration \\[m\\]. -- `rho_e`: Soil resistivity \\[Ω·m\\]. Default: 100 Ω·m. -- `f`: Frequency \\[Hz\\]. Default: [`f₀`](@ref). - -# Returns - -- Positive-sequence inductance per unit length of the cable system \\[H/m\\]. - -# Examples - -```julia -L = $(FUNCTIONNAME)(0.01, 0.015, 1.72e-8, 1.0, 0.02, 0.025, 2.83e-8, 1.0, S=0.1, rho_e=50, f=50) -println(L) # Output: Inductance value in H/m -``` - -# See also - -- [`calc_tubular_gmr`](@ref) -""" -function calc_inductance_trifoil( - r_in_co::T, - r_ext_co::T, - rho_co::T, - mu_r_co::T, - r_in_scr::T, - r_ext_scr::T, - rho_scr::T, - mu_r_scr::T, - S::T, - rho_e::T, - f::T, -) where {T <: REALSCALAR} - - ω = 2 * π * f - C = μ₀ / (2π) - - # Compute simplified earth return depth - DE = 659.0 * sqrt(rho_e / f) - - # Compute R'_E - RpE = (ω * μ₀) / 8.0 - - # Compute Xa - GMRa = calc_tubular_gmr(r_ext_co, r_in_co, mu_r_co) - Xa = (ω * C) * log(DE / GMRa) - - # Self impedance of a phase conductor with earth return - Ra = rho_co / (π * (r_ext_co^2 - r_in_co^2)) - Za = RpE + Ra + im * Xa - - # Compute rs - GMRscr = calc_tubular_gmr(r_ext_scr, r_in_scr, mu_r_scr) - # Compute Xs - Xs = (ω * C) * log(DE / GMRscr) - - # Self impedance of metal screen with earth return - Rs = rho_scr / (π * (r_ext_scr^2 - r_in_scr^2)) - Zs = RpE + Rs + im * Xs - - # Mutual impedance between phase conductor and screen - Zm = RpE + im * Xs - - # Compute GMD - GMD = S # trifoil, for flat use: 2^(1/3) * S - - # Compute Xap - Xap = (ω * C) * log(DE / GMD) - - # Equivalent mutual impedances between cables - Zx = RpE + im * Xap - - # Formula from CIGRE TB-531, 4.2.4.3, solid bonding - Z1_sb = (Za - Zx) - ((Zm - Zx)^2 / (Zs - Zx)) - - # Likewise, but for single point bonding - # Z1_sp = (Za - Zx) - return imag(Z1_sb) / ω -end - -function calc_inductance_trifoil( - r_in_co, - r_ext_co, - rho_co, - mu_r_co, - r_in_scr, - r_ext_scr, - rho_scr, - mu_r_scr, - S; - rho_e = 100.0, - f = f₀, -) - T = resolve_T( - r_in_co, - r_ext_co, - rho_co, - mu_r_co, - r_in_scr, - r_ext_scr, - rho_scr, - mu_r_scr, - S, - rho_e, - f, - ) - return calc_inductance_trifoil( - coerce_to_T(r_in_co, T), - coerce_to_T(r_ext_co, T), - coerce_to_T(rho_co, T), - coerce_to_T(mu_r_co, T), - coerce_to_T(r_in_scr, T), - coerce_to_T(r_ext_scr, T), - coerce_to_T(rho_scr, T), - coerce_to_T(mu_r_scr, T), - coerce_to_T(S, T), - coerce_to_T(rho_e, T), - coerce_to_T(f, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the geometric mean radius (GMR) of a circular wire array, using formula (62), page 335, of the book by Edward Rosa [rosa1908](@cite): - -```math -GMR = \\sqrt[a] {r n a^{n-1}} -``` - -where ``a`` is the layout radius, ``n`` is the number of wires, and ``r`` is the radius of each wire. - -# Arguments - -- `lay_rad`: Layout radius of the wire array \\[m\\]. -- `N`: Number of wires in the array \\[dimensionless\\]. -- `rad_wire`: Radius of an individual wire \\[m\\]. -- `mu_r`: Relative permeability of the wire material \\[dimensionless\\]. - -# Returns - -- Geometric mean radius (GMR) of the wire array \\[m\\]. - -# Examples - -```julia -lay_rad = 0.05 -N = 7 -rad_wire = 0.002 -mu_r = 1.0 -gmr = $(FUNCTIONNAME)(lay_rad, N, rad_wire, mu_r) -println(gmr) # Expected output: 0.01187... [m] -``` -""" -function calc_wirearray_gmr( - lay_rad::T, - N::Int, - rad_wire::T, - mu_r::T, -) where {T <: REALSCALAR} - gmr_wire = rad_wire * exp(-mu_r / 4) - log_gmr_array = log(gmr_wire * N * lay_rad^(N - 1)) / N - return exp(log_gmr_array) -end - -function calc_wirearray_gmr(lay_rad, N::Int, rad_wire, mu_r) - T = resolve_T(lay_rad, rad_wire, mu_r) - return calc_wirearray_gmr( - coerce_to_T(lay_rad, T), - N, - coerce_to_T(rad_wire, T), - coerce_to_T(mu_r, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the geometric mean radius (GMR) of a tubular conductor, using [6521501](@cite): - -```math -\\log GMR = \\log r_2 - \\mu_r \\left[ \\frac{r_1^4}{\\left(r_2^2 - r_1^2\\right)^2} \\log\\left(\\frac{r_2}{r_1}\\right) - \\frac{3r_1^2 - r_2^2}{4\\left(r_2^2 - r_1^2\\right)} \\right] -``` - -where ``\\mu_r`` is the material magnetic permeability (relative to free space), ``r_1`` and ``r_2`` are the inner and outer radii of the tubular conductor, respectively. If ``r_2`` is approximately equal to ``r_1`` , the tube collapses into a thin shell, and the GMR is equal to ``r_2``. If the tube becomes infinitely thick (e.g., ``r_2 \\gg r_1``), the GMR diverges to infinity. - -# Arguments - -- `radius_ext`: External radius of the tubular conductor \\[m\\]. -- `radius_in`: Internal radius of the tubular conductor \\[m\\]. -- `mu_r`: Relative permeability of the conductor material \\[dimensionless\\]. - -# Returns - -- Geometric mean radius (GMR) of the tubular conductor \\[m\\]. - -# Errors - -- Throws `ArgumentError` if `radius_ext` is less than `radius_in`. - -# Examples - -```julia -radius_ext = 0.02 -radius_in = 0.01 -mu_r = 1.0 -gmr = $(FUNCTIONNAME)(radius_ext, radius_in, mu_r) -println(gmr) # Expected output: ~0.0135 [m] -``` -""" -function calc_tubular_gmr(radius_ext::T, radius_in::T, mu_r::T) where {T <: REALSCALAR} - if (radius_ext < radius_in) || (radius_ext <= 0.0) - throw( - ArgumentError( - "Invalid parameters: radius_ext must be >= radius_in and positive.", - ), - ) - end - - # Constants - if isapprox(radius_in, radius_ext) - # Tube collapses into a thin shell with infinitesimal thickness and the GMR is simply the radius - gmr = radius_ext - elseif abs(radius_in / radius_ext) < eps() && abs(radius_in) > TOL - # Tube becomes infinitely thick up to floating point precision - gmr = Inf - else - is_solid = isapprox(radius_in, 0.0) - term1 = - is_solid ? 0.0 : - (radius_in^4 / (radius_ext^2 - radius_in^2)^2) * log(radius_ext / radius_in) - term2 = (3 * radius_in^2 - radius_ext^2) / (4 * (radius_ext^2 - radius_in^2)) - Lin = (μ₀ * mu_r / (2 * π)) * (term1 - term2) - - # Compute the GMR - gmr = exp(log(radius_ext) - (2 * π / μ₀) * Lin) - end - - return gmr -end - -function calc_tubular_gmr(radius_ext, radius_in, mu_r) - T = resolve_T(radius_ext, radius_in, mu_r) - return calc_tubular_gmr( - coerce_to_T(radius_ext, T), - coerce_to_T(radius_in, T), - coerce_to_T(mu_r, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the relative permeability (`mu_r`) based on the geometric mean radius (GMR) and conductor dimensions, by executing the inverse of [`calc_tubular_gmr`](@ref), and solving for `mu_r`: - -```math -\\log GMR = \\log r_2 - \\mu_r \\left[ \\frac{r_1^4}{\\left(r_2^2 - r_1^2\\right)^2} \\log\\left(\\frac{r_2}{r_1}\\right) - \\frac{3r_1^2 - r_2^2}{4\\left(r_2^2 - r_1^2\\right)} \\right] -``` - -```math -\\mu_r = -\\frac{\\left(\\log GMR - \\log r_2\\right)}{\\frac{r_1^4}{\\left(r_2^2 - r_1^2\\right)^2} \\log\\left(\\frac{r_2}{r_1}\\right) - \\frac{3r_1^2 - r_2^2}{4\\left(r_2^2 - r_1^2\\right)}} -``` - -where ``r_1`` is the inner radius and ``r_2`` is the outer radius. - -# Arguments - -- `gmr`: Geometric mean radius of the conductor \\[m\\]. -- `radius_ext`: External radius of the conductor \\[m\\]. -- `radius_in`: Internal radius of the conductor \\[m\\]. - -# Returns - -- Relative permeability (`mu_r`) of the conductor material \\[dimensionless\\]. - -# Errors - -- Throws `ArgumentError` if `radius_ext` is less than `radius_in`. - -# Notes - -Assumes a tubular geometry for the conductor, reducing to the solid case if `radius_in` is zero. - -# Examples - -```julia -gmr = 0.015 -radius_ext = 0.02 -radius_in = 0.01 -mu_r = $(FUNCTIONNAME)(gmr, radius_ext, radius_in) -println(mu_r) # Expected output: ~1.7 [dimensionless] -``` - -# See also -- [`calc_tubular_gmr`](@ref) -""" -function calc_equivalent_mu(gmr::T, radius_ext::T, radius_in::T) where {T <: REALSCALAR} - if (radius_ext < radius_in) || (radius_ext <= 0.0) - throw( - ArgumentError( - "Invalid parameters: radius_ext must be >= radius_in and positive.", - ), - ) - end - is_solid = isapprox(radius_in, 0.0) || isapprox(radius_in, radius_ext) - term1 = - is_solid ? 0.0 : - (radius_in^4 / (radius_ext^2 - radius_in^2)^2) * log(radius_ext / radius_in) - term2 = (3 * radius_in^2 - radius_ext^2) / (4 * (radius_ext^2 - radius_in^2)) - # Compute the log difference - log_diff = log(gmr) - log(radius_ext) - - # Compute mu_r - mu_r = -log_diff / (term1 - term2) - - return mu_r -end - -function calc_equivalent_mu(gmr, radius_ext, radius_in) - T = resolve_T(gmr, radius_ext, radius_in) - return calc_equivalent_mu( - coerce_to_T(gmr, T), - coerce_to_T(radius_ext, T), - coerce_to_T(radius_in, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the shunt capacitance per unit length of a coaxial structure, using the standard formula for the capacitance of a coaxial structure [cigre531](@cite) [916943](@cite) [1458878](@cite): - -```math -C = \\frac{2 \\pi \\varepsilon_0 \\varepsilon_r}{\\log \\left(\\frac{r_{ext}}{r_{in}}\\right)} -``` -where ``\\varepsilon_0`` is the vacuum permittivity, ``\\varepsilon_r`` is the relative permittivity of the dielectric material, and ``r_{in}`` and ``r_{ext}`` are the inner and outer radii of the coaxial structure, respectively. - -# Arguments - -- `radius_in`: Internal radius of the coaxial structure \\[m\\]. -- `radius_ext`: External radius of the coaxial structure \\[m\\]. -- `epsr`: Relative permittivity of the dielectric material \\[dimensionless\\]. - -# Returns - -- Shunt capacitance per unit length \\[F/m\\]. - -# Examples - -```julia -radius_in = 0.01 -radius_ext = 0.02 -epsr = 2.3 -capacitance = $(FUNCTIONNAME)(radius_in, radius_ext, epsr) -println(capacitance) # Expected output: ~1.24e-10 [F/m] -``` -""" -function calc_shunt_capacitance( - radius_in::T, - radius_ext::T, - epsr::T, -) where {T <: REALSCALAR} - return 2 * π * ε₀ * epsr / log(radius_ext / radius_in) -end - -function calc_shunt_capacitance(radius_in, radius_ext, epsr) - T = resolve_T(radius_in, radius_ext, epsr) - return calc_shunt_capacitance( - coerce_to_T(radius_in, T), - coerce_to_T(radius_ext, T), - coerce_to_T(epsr, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the shunt conductance per unit length of a coaxial structure, using the improved model reported in [916943](@cite) [Karmokar2025](@cite) [4389974](@cite): - -```math -G = \\frac{2\\pi\\sigma}{\\log(\\frac{r_{ext}}{r_{in}})} -``` -where ``\\sigma = \\frac{1}{\\rho}`` is the conductivity of the dielectric/semiconducting material, ``r_{in}`` is the internal radius, and ``r_{ext}`` is the external radius of the coaxial structure. - -# Arguments - -- `radius_in`: Internal radius of the coaxial structure \\[m\\]. -- `radius_ext`: External radius of the coaxial structure \\[m\\]. -- `rho`: Resistivity of the dielectric/semiconducting material \\[Ω·m\\]. - -# Returns - -- Shunt conductance per unit length \\[S·m\\]. - -# Examples - -```julia -radius_in = 0.01 -radius_ext = 0.02 -rho = 1e9 -g = $(FUNCTIONNAME)(radius_in, radius_ext, rho) -println(g) # Expected output: 2.7169e-9 [S·m] -``` -""" -function calc_shunt_conductance(radius_in::T, radius_ext::T, rho::T) where {T <: REALSCALAR} - return 2 * π * (1 / rho) / log(radius_ext / radius_in) -end - -function calc_shunt_conductance(radius_in, radius_ext, rho) - T = resolve_T(radius_in, radius_ext, rho) - return calc_shunt_conductance( - coerce_to_T(radius_in, T), - coerce_to_T(radius_ext, T), - coerce_to_T(rho, T), - ) -end - - -""" -$(TYPEDSIGNATURES) - -Calculates the equivalent geometric mean radius (GMR) of a conductor after adding a new layer, by recursive application of the multizone stranded conductor defined as [yang2008gmr](@cite): - -```math -GMR_{eq} = {GMR_{i-1}}^{\\beta^2} \\cdot {GMR_{i}}^{(1-\\beta)^2} \\cdot {GMD}^{2\\beta(1-\\beta)} -``` -```math -\\beta = \\frac{S_{i-1}}{S_{i-1} + S_{i}} -``` -where: -- ``S_{i-1}`` is the cumulative cross-sectional area of the existing cable part, ``S_{i}`` is the total cross-sectional area after inclusion of the conducting layer ``{i}``. -- ``GMR_{i-1}`` is the cumulative GMR of the existing cable part, ``GMR_{i}`` is the GMR of the conducting layer ``{i}``. -- ``GMD`` is the geometric mean distance between the existing cable part and the new layer, calculated using [`calc_gmd`](@ref). - -# Arguments - -- `existing`: The existing cable part ([`AbstractCablePart`](@ref)). -- `new_layer`: The new layer being added ([`AbstractCablePart`](@ref)). - -# Returns - -- Updated equivalent GMR of the combined conductor \\[m\\]. - -# Examples - -```julia -material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) -conductor = Conductor(Strip(0.01, 0.002, 0.05, 10, material_props)) -new_layer = WireArray(0.02, 0.002, 7, 15, material_props) -equivalent_gmr = $(FUNCTIONNAME)(conductor, new_layer) # Expected output: Updated GMR value [m] -``` - -# See also - -- [`calc_gmd`](@ref) -""" -function calc_equivalent_gmr( - existing::T, - new_layer::U, -) where {T <: AbstractCablePart, U <: AbstractCablePart} - beta = existing.cross_section / (existing.cross_section + new_layer.cross_section) - - DM = parentmodule(@__MODULE__) # DataModel - if isdefined(DM, :ConductorGroup) - CG = getfield(DM, :ConductorGroup) - if existing isa CG - current_conductor = existing.layers[end] - else - current_conductor = existing - end - end - - # current_conductor = existing isa ConductorGroup ? existing.layers[end] : existing - gmd = calc_gmd(current_conductor, new_layer) - return existing.gmr^(beta^2) * new_layer.gmr^((1 - beta)^2) * - gmd^(2 * beta * (1 - beta)) -end - -# evil hackery to detect WireArray types -@inline function _is_wirearray(x) - DM = parentmodule(@__MODULE__) # DataModel - return isdefined(DM, :WireArray) && - (x isa getfield(DM, :WireArray)) # no compile-time ref to WireArray -end - -""" -$(TYPEDSIGNATURES) - -Calculates the geometric mean distance (GMD) between two cable parts, by using the definition described in Grover [grover1981inductance](@cite): - -```math -\\log GMD = \\left(\\frac{\\sum_{i=1}^{n_1}\\sum_{j=1}^{n_2} (s_1 \\cdot s_2) \\cdot \\log(d_{ij})}{\\sum_{i=1}^{n_1}\\sum_{j=1}^{n_2} (s_1 \\cdot s_2)}\\right) -``` - -where: -- ``d_{ij}`` is the Euclidean distance between elements ``i`` and ``j``. -- ``s_1`` and ``s_2`` are the cross-sectional areas of the respective elements. -- ``n_1`` and ``n_2`` are the number of sub-elements in each cable part. - -# Arguments - -- `co1`: First cable part ([`AbstractCablePart`](@ref)). -- `co2`: Second cable part ([`AbstractCablePart`](@ref)). - -# Returns - -- Geometric mean distance between the cable parts \\[m\\]. - -# Notes - -For concentric structures, the GMD converges to the external radii of the outermost element. - -!!! info "Numerical stability" - This implementation uses a weighted sum of logarithms rather than the traditional product formula ``\\Pi(d_{ij})^{(1/n)}`` found in textbooks. The logarithmic approach prevents numerical underflow/overflow when dealing with many conductors or extreme distance ratios, making it significantly more stable for practical calculations. - -# Examples - -```julia -material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) -wire_array1 = WireArray(0.01, 0.002, 7, 10, material_props) -wire_array2 = WireArray(0.02, 0.002, 7, 15, material_props) -gmd = $(FUNCTIONNAME)(wire_array1, wire_array2) # Expected output: GMD value [m] - -strip = Strip(0.01, 0.002, 0.05, 10, material_props) -tubular = Tubular(0.01, 0.02, material_props) -gmd = $(FUNCTIONNAME)(strip, tubular) # Expected output: GMD value [m] -``` - -# See also - -- [`calc_wirearray_coords`](@ref) -- [`calc_equivalent_gmr`](@ref) -""" -function calc_gmd(co1::T, co2::U) where {T <: AbstractCablePart, U <: AbstractCablePart} - - if _is_wirearray(co1) #co1 isa WireArray - coords1 = calc_wirearray_coords(co1.num_wires, co1.radius_wire, co1.radius_in) - n1 = co1.num_wires - r1 = co1.radius_wire - s1 = pi * r1^2 - else - coords1 = [(0.0, 0.0)] - n1 = 1 - r1 = co1.radius_ext - s1 = co1.cross_section - end - - # if co2 isa WireArray - if _is_wirearray(co2) - coords2 = calc_wirearray_coords(co2.num_wires, co2.radius_wire, co2.radius_in) - n2 = co2.num_wires - r2 = co2.radius_wire - s2 = pi * r2^2 - else - coords2 = [(0.0, 0.0)] - n2 = 1 - r2 = co2.radius_ext - s2 = co2.cross_section - end - - log_sum = 0.0 - area_weights = 0.0 - - for i in 1:n1 - for j in 1:n2 - # Pair-wise distances - x1, y1 = coords1[i] - x2, y2 = coords2[j] - d_ij = sqrt((x1 - x2)^2 + (y1 - y2)^2) - if d_ij > eps() - # The GMD is computed as the Euclidean distance from center-to-center - log_dij = log(d_ij) - else - # This means two concentric structures (solid/strip or tubular, tubular/strip or tubular, strip/strip or tubular) - # In all cases the GMD is the outermost radius - # max(r1, r2) - log_dij = log(max(r1, r2)) - end - log_sum += (s1 * s2) * log_dij - area_weights += (s1 * s2) - end - end - return exp(log_sum / area_weights) -end -""" -$(TYPEDSIGNATURES) - -Calculates the solenoid correction factor for magnetic permeability in insulated cables with helical conductors ([`WireArray`](@ref)), using the formula from Gudmundsdottir et al. [5743045](@cite): - -```math -\\mu_{r, sol} = 1 + \\frac{2 \\pi^2 N^2 (r_{ins, ext}^2 - r_{con, ext}^2)}{\\log(r_{ins, ext}/r_{con, ext})} -``` - -where: -- ``N`` is the number of turns per unit length. -- ``r_{con, ext}`` is the conductor external radius. -- ``r_{ins, ext}`` is the insulator external radius. - -# Arguments - -- `num_turns`: Number of turns per unit length \\[1/m\\]. -- `radius_ext_con`: External radius of the conductor \\[m\\]. -- `radius_ext_ins`: External radius of the insulator \\[m\\]. - -# Returns - -- Correction factor for the insulator magnetic permeability \\[dimensionless\\]. - -# Examples - -```julia -# Cable with 10 turns per meter, conductor radius 5 mm, insulator radius 10 mm -correction = $(FUNCTIONNAME)(10, 0.005, 0.01) # Expected output: > 1.0 [dimensionless] - -# Non-helical cable (straight conductor) -correction = $(FUNCTIONNAME)(NaN, 0.005, 0.01) # Expected output: 1.0 [dimensionless] -``` -""" -function calc_solenoid_correction( - num_turns::T, - radius_ext_con::T, - radius_ext_ins::T, -) where {T <: REALSCALAR} - if isnan(num_turns) - return 1.0 - else - return 1.0 + - 2 * num_turns^2 * pi^2 * (radius_ext_ins^2 - radius_ext_con^2) / - log(radius_ext_ins / radius_ext_con) - end -end - -function calc_solenoid_correction( - num_turns, - radius_ext_con, - radius_ext_ins, -) - T = resolve_T(num_turns, radius_ext_con, radius_ext_ins) - return calc_solenoid_correction( - coerce_to_T(num_turns, T), - coerce_to_T(radius_ext_con, T), - coerce_to_T(radius_ext_ins, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the equivalent resistivity of a solid tubular conductor, using the formula [916943](@cite): - -```math -\\rho_{eq} = R_{eq} S_{eff} = R_{eq} \\pi (r_{ext}^2 - r_{in}^2) -``` - -where ``S_{eff}`` is the effective cross-sectional area of the tubular conductor. - -# Arguments - -- `R`: Resistance of the conductor \\[Ω\\]. -- `radius_ext_con`: External radius of the tubular conductor \\[m\\]. -- `radius_in_con`: Internal radius of the tubular conductor \\[m\\]. - -# Returns - -- Equivalent resistivity of the tubular conductor \\[Ω·m\\]. - -# Examples - -```julia -rho_eq = $(FUNCTIONNAME)(0.01, 0.02, 0.01) # Expected output: ~9.42e-4 [Ω·m] -``` -""" -function calc_equivalent_rho( - R::T, - radius_ext_con::T, - radius_in_con::T, -) where {T <: REALSCALAR} - eff_conductor_area = π * (radius_ext_con^2 - radius_in_con^2) - return R * eff_conductor_area -end - -function calc_equivalent_rho(R, radius_ext_con, radius_in_con) - T = resolve_T(R, radius_ext_con, radius_in_con) - return calc_equivalent_rho( - coerce_to_T(R, T), - coerce_to_T(radius_ext_con, T), - coerce_to_T(radius_in_con, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the equivalent permittivity for a coaxial cable insulation, using the formula [916943](@cite): - -```math -\\varepsilon_{eq} = \\frac{C_{eq} \\log(\\frac{r_{ext}}{r_{in}})}{2\\pi \\varepsilon_0} -``` - -where ``\\varepsilon_0`` is the permittivity of free space. - -# Arguments - -- `C_eq`: Equivalent capacitance of the insulation \\[F/m\\]. -- `radius_ext`: External radius of the insulation \\[m\\]. -- `radius_in`: Internal radius of the insulation \\[m\\]. - -# Returns - -- Equivalent relative permittivity of the insulation \\[dimensionless\\]. - -# Examples - -```julia -eps_eq = $(FUNCTIONNAME)(1e-10, 0.01, 0.005) # Expected output: ~2.26 [dimensionless] -``` - -# See also -- [`ε₀`](@ref) -""" -function calc_equivalent_eps(C_eq::T, radius_ext::T, radius_in::T) where {T <: REALSCALAR} - return (C_eq * log(radius_ext / radius_in)) / (2 * pi) / ε₀ -end - -function calc_equivalent_eps(C_eq, radius_ext, radius_in) - T = resolve_T(C_eq, radius_ext, radius_in) - return calc_equivalent_eps( - coerce_to_T(C_eq, T), - coerce_to_T(radius_ext, T), - coerce_to_T(radius_in, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the equivalent loss factor (tangent) of a dielectric material: - -```math -\\tan \\delta = \\frac{G_{eq}}{\\omega \\cdot C_{eq}} -``` - -where ``\\tan \\delta`` is the loss factor (tangent). - -# Arguments - -- `G_eq`: Equivalent conductance of the material \\[S·m\\]. -- `C_eq`: Equivalent capacitance of the material \\[F/m\\]. -- `ω`: Angular frequency \\[rad/s\\]. - -# Returns - -- Equivalent loss factor of the dielectric material \\[dimensionless\\]. - -# Examples - -```julia -loss_factor = $(FUNCTIONNAME)(1e-8, 1e-10, 2π*50) # Expected output: ~0.0318 [dimensionless] -``` -""" -function calc_equivalent_lossfact(G_eq::T, C_eq::T, ω::T) where {T <: REALSCALAR} - return G_eq / (ω * C_eq) -end - -function calc_equivalent_lossfact(G_eq, C_eq, ω) - T = resolve_T(G_eq, C_eq, ω) - return calc_equivalent_lossfact( - coerce_to_T(G_eq, T), - coerce_to_T(C_eq, T), - coerce_to_T(ω, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Calculates the effective conductivity of a dielectric material from the known conductance (related to the loss factor ``\\tan \\delta``) via [916943](@cite) [Karmokar2025](@cite) [4389974](@cite): - -```math -\\sigma_{eq} = \\frac{G_{eq}}{2\\pi} \\log(\\frac{r_{ext}}{r_{in}}) -``` -where ``\\sigma_{eq} = \\frac{1}{\\rho_{eq}}`` is the conductivity of the dielectric/semiconducting material, ``G_{eq}`` is the shunt conductance per unit length, ``r_{in}`` is the internal radius, and ``r_{ext}`` is the external radius of the coaxial structure. - -# Arguments - -- `G_eq`: Equivalent conductance of the material \\[S·m\\]. -- `radius_in`: Internal radius of the coaxial structure \\[m\\]. -- `radius_ext`: External radius of the coaxial structure \\[m\\]. - -# Returns - -- Effective material conductivity per unit length \\[S·m\\]. - -# Examples - -```julia -Geq = 2.7169e-9 -sigma_eq = $(FUNCTIONNAME)(G_eq, radius_in, radius_ext) -``` -""" -function calc_sigma_lossfact(G_eq::T, radius_in::T, radius_ext::T) where {T <: REALSCALAR} - return G_eq * log(radius_ext / radius_in) / (2 * pi) -end - -function calc_sigma_lossfact(G_eq, radius_in, radius_ext) - T = resolve_T(G_eq, radius_in, radius_ext) - return calc_sigma_lossfact( - coerce_to_T(G_eq, T), - coerce_to_T(radius_in, T), - coerce_to_T(radius_ext, T), - ) -end - -end # module BaseParams +""" + LineCableModels.DataModel.BaseParams + +The [`BaseParams`](@ref) submodule provides fundamental functions for determining the base electrical parameters (R, L, C, G) of cable components within the [`LineCableModels.DataModel`](@ref) module. This includes implementations of standard engineering formulas for resistance, inductance, and geometric parameters of various conductor configurations. + +# Overview + +- Implements basic electrical engineering formulas for calculating DC resistance and inductance of different conductor geometries (tubular, strip, wire arrays). +- Implements basic formulas for capacitance and dielectric losses in insulators and semiconductors. +- Provides functions for temperature correction of material properties. +- Calculates geometric mean radii for different conductor configurations. +- Includes functions for determining the effective length for helical wire arrangements. +- Calculates equivalent electrical parameters and correction factors for different geometries and configurations. + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module BaseParams + +# Export public API +export calc_equivalent_alpha +export calc_parallel_equivalent +export calc_helical_params +export calc_strip_resistance +export calc_temperature_correction +export calc_tubular_resistance +export calc_tubular_inductance +export calc_wirearray_coords +export calc_inductance_trifoil +export calc_wirearray_gmr +export calc_tubular_gmr +export calc_equivalent_mu +export calc_equivalent_kappa +export calc_shunt_capacitance +export calc_shunt_conductance +export calc_equivalent_gmr +export calc_gmd +export calc_solenoid_correction +export calc_equivalent_rho +export calc_equivalent_eps +export calc_equivalent_lossfact +export calc_sigma_lossfact + +# Module-specific dependencies +using Measurements +using ...Commons +import ..DataModel: AbstractCablePart, AbstractInsulatorPart +using ...Utils: resolve_T, coerce_to_T + + +""" +$(TYPEDSIGNATURES) + +Calculates the equivalent temperature coefficient of resistance (`alpha`) when two conductors are connected in parallel, by cross-weighted-resistance averaging: + +```math +\\alpha_{eq} = \\frac{\\alpha_1 R_2 + \\alpha_2 R1}{R_1 + R_2} +``` +where ``\\alpha_1``, ``\\alpha_2`` are the temperature coefficients of the conductors, and ``R_1``, ``R_2`` are the respective resistances. + +# Arguments + +- `alpha1`: Temperature coefficient of resistance of the first conductor \\[1/°C\\]. +- `R1`: Resistance of the first conductor \\[Ω\\]. +- `alpha2`: Temperature coefficient of resistance of the second conductor \\[1/°C\\]. +- `R2`: Resistance of the second conductor \\[Ω\\]. + +# Returns + +- The equivalent temperature coefficient \\[1/°C\\] for the parallel combination. + +# Examples + +```julia +alpha_conductor = 0.00393 # Copper +alpha_new_part = 0.00403 # Aluminum +R_conductor = 0.5 +R_new_part = 1.0 +alpha_eq = $(FUNCTIONNAME)(alpha_conductor, R_conductor, alpha_new_part, R_new_part) +println(alpha_eq) # Output: 0.00396 (approximately) +``` +""" +function calc_equivalent_alpha(alpha1::T, R1::T, alpha2::T, R2::T) where {T <: REALSCALAR} + return (alpha1 * R2 + alpha2 * R1) / (R1 + R2) +end + +function calc_equivalent_alpha(alpha1, R1, alpha2, R2) + T = resolve_T(alpha1, R1, alpha2, R2) + return calc_equivalent_alpha( + coerce_to_T(alpha1, T), + coerce_to_T(R1, T), + coerce_to_T(alpha2, T), + coerce_to_T(R2, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the parallel equivalent of two impedances (or series equivalent of two admittances): + +```math +Z_{eq} = \\frac{Z_1 Z_2}{Z_1 + Z_2} +``` + +This expression, when applied recursively to [`LineCableModels.DataModel.WireArray`](@ref) objects, implements the formula for the hexagonal wiring pattern described in CIGRE TB-345 [app14198982](@cite) [cigre345](@cite): + +```math +\\frac{1}{R_{\\text{dc}}} = \\frac{\\pi d^2}{4 \\rho} \\left( 1 + \\sum_{1}^{n} \\frac{6n}{k_n} \\right) +``` + +```math +k_n = \\left[ 1 + \\left( \\pi \\frac{D_n}{\\lambda_n} \\right)^2 \\right]^{1/2} +``` + +where ``R_{\\text{dc}}`` is the DC resistance, ``d`` is the diameter of each wire, ``\rho`` is the resistivity, ``n`` is the number of layers following the hexagonal pattern, ``D_n`` is the diameter of the ``n``-th layer, and ``\\lambda_n `` is the pitch length of the ``n``-th layer, obtained using [`calc_helical_params`](@ref). + +# Arguments + +- `Z1`: The total impedance of the existing system \\[Ω\\]. +- `Z2`: The impedance of the new layer being added \\[Ω\\]. + +# Returns + +- The parallel equivalent impedance \\[Ω\\]. + +# Examples + +```julia +Z1 = 5.0 +Z2 = 10.0 +Req = $(FUNCTIONNAME)(Z1, Z2) +println(Req) # Outputs: 3.3333333333333335 +``` + +# See also + +- [`calc_helical_params`](@ref) +""" +function calc_parallel_equivalent( + Z1::T, + Z2::T, +) where {T <: Union{REALSCALAR, COMPLEXSCALAR}} + + # Case 1: Inf / Inf -> NaN + # The parallel combination of an open circuit (Inf) and any finite impedance is the finite impedance. + if isinf(Z1) + return Z2 + elseif isinf(Z2) + return Z1 + end + + # Case 2: 0 / 0 -> NaN + # The parallel combination of two short circuits (0) is a short circuit. + # The standard formula works fine if only one is zero, but not if both are. + if iszero(Z1) && iszero(Z2) + return zero(T) + end + return (Z1 * Z2) / (Z1 + Z2) +end + +function calc_parallel_equivalent(Z1, Z2) + T = resolve_T(Z1, Z2) + return calc_parallel_equivalent( + coerce_to_T(Z1, T), + coerce_to_T(Z2, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the mean diameter, pitch length, and overlength based on cable geometry parameters. The lay ratio is defined as the ratio of the pitch length ``L_p`` to the external diameter ``D_e``: + +```math +\\lambda = \\frac{L_p}{D_e} +``` +where ``D_e`` and ``L_p`` are the dimensions represented in the figure. + +![](./assets/lay_ratio.svg) + +# Arguments + +- `radius_in`: Inner radius of the cable layer \\[m\\]. +- `radius_ext`: Outer radius of the cable layer \\[m\\]. +- `lay_ratio`: Ratio of the pitch (lay) length to the external diameter of the corresponding layer of wires \\[dimensionless\\]. + +# Returns + +- `mean_diameter`: Mean diameter of the cable layer \\[m\\]. +- `pitch_length`: The length over which the strands complete one full twist \\[m\\]. +- `overlength`: Effective length increase resulting from the helical path \\[1/m\\]. + +# Notes + +Reference values for `lay_ratio` are given under standard EN 50182 [CENELEC50182](@cite): + +| Conductor type | Steel wires | Aluminum wires | Lay ratio - Steel | Lay ratio - Aluminum | +|---------------|----------------------|---------------------|----------------------|-------------------| +| AAAC 4 layers | - | 61 (1/6/12/18/24) | - | 15/13.5/12.5/11 | +| ACSR 3 layers | 7 (1/6) | 54 (12/18/24) | 19 | 15/13/11.5 | +| ACSR 2 layers | 7 (1/6) | 26 (10/16) | 19 | 14/11.5 | +| ACSR 1 layer | 7 (1/6) | 10 | 19 | 14 | +| ACCC/TW | - | 36 (8/12/16) | - | 15/13.5/11.5 | + +# Examples + +```julia +radius_in = 0.01 +radius_ext = 0.015 +lay_ratio = 12 + +mean_diam, pitch, overlength = $(FUNCTIONNAME)(radius_in, radius_ext, lay_ratio) +# mean_diam ≈ 0.025 [m] +# pitch ≈ 0.3 [m] +# overlength > 1.0 [1/m] +``` +""" +function calc_helical_params( + radius_in::T, + radius_ext::T, + lay_ratio::T, +) where {T <: REALSCALAR} + mean_diameter = 2 * (radius_in + (radius_ext - radius_in) / 2) + pitch_length = lay_ratio * mean_diameter + overlength = + !isapprox(pitch_length, 0.0) ? sqrt(1 + (π * mean_diameter / pitch_length)^2) : 1 + + return mean_diameter, pitch_length, overlength +end + +function calc_helical_params(radius_in, radius_ext, lay_ratio) + T = resolve_T(radius_in, radius_ext, lay_ratio) + return calc_helical_params( + coerce_to_T(radius_in, T), + coerce_to_T(radius_ext, T), + coerce_to_T(lay_ratio, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the DC resistance of a strip conductor based on its geometric and material properties, using the basic resistance formula in terms of the resistivity and cross-sectional area: + +```math +R = \\rho \\frac{\\ell}{W T} +``` +where ``\\ell`` is the length of the strip, ``W`` is the width, and ``T`` is the thickness. The length is assumed to be infinite in the direction of current flow, so the resistance is calculated per unit length. + +# Arguments + +- `thickness`: Thickness of the strip \\[m\\]. +- `width`: Width of the strip \\[m\\]. +- `rho`: Electrical resistivity of the conductor material \\[Ω·m\\]. +- `alpha`: Temperature coefficient of resistivity \\[1/°C\\]. +- `T0`: Reference temperature for the material properties \\[°C\\]. +- `Top`: Operating temperature of the conductor \\[°C\\]. + +# Returns + +- DC resistance of the strip conductor \\[Ω\\]. + +# Examples + +```julia +thickness = 0.002 +width = 0.05 +rho = 1.7241e-8 +alpha = 0.00393 +T0 = 20 +T = 25 +resistance = $(FUNCTIONNAME)(thickness, width, rho, alpha, T0, T) +# Output: ~0.0001758 Ω +``` + +# See also + +- [`calc_temperature_correction`](@ref) +""" +function calc_strip_resistance( + thickness::T, + width::T, + rho::T, + alpha::T, + T0::T, + Top::T, +) where {T <: REALSCALAR} + + cross_section = thickness * width + return calc_temperature_correction(alpha, Top, T0) * rho / cross_section +end + +function calc_strip_resistance(thickness, width, rho, alpha, T0, Top) + T = resolve_T(thickness, width, rho, alpha, T0, Top) + return calc_strip_resistance( + coerce_to_T(thickness, T), + coerce_to_T(width, T), + coerce_to_T(rho, T), + coerce_to_T(alpha, T), + coerce_to_T(T0, T), + coerce_to_T(Top, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the temperature correction factor for material properties based on the standard linear temperature model [cigre345](@cite): + +```math +k(T) = 1 + \\alpha (T - T_0) +``` +where ``\\alpha`` is the temperature coefficient of the material resistivity, ``T`` is the operating temperature, and ``T_0`` is the reference temperature. + +# Arguments + +- `alpha`: Temperature coefficient of the material property \\[1/°C\\]. +- `T`: Current temperature \\[°C\\]. +- `T0`: Reference temperature at which the base material property was measured \\[°C\\]. Defaults to T₀. + +# Returns + +- Temperature correction factor to be applied to the material property \\[dimensionless\\]. + +# Examples + +```julia + # Copper resistivity correction (alpha = 0.00393 [1/°C]) + k = $(FUNCTIONNAME)(0.00393, 75.0, 20.0) # Expected output: 1.2161 +``` +""" +function calc_temperature_correction(alpha::T, Top::T, T0::T = T₀) where {T <: REALSCALAR} + @assert abs(Top - T0) < ΔTmax """ + Temperature is outside the valid range for linear resistivity model: + Top = $Top + T0 = $T0 + ΔTmax = $ΔTmax + |Top - T0| = $(abs(Top - T0))""" + return 1 + alpha * (Top - T0) +end + +function calc_temperature_correction(alpha, Top, T0 = T₀) + T = resolve_T(alpha, Top, T0) + return calc_temperature_correction( + coerce_to_T(alpha, T), + coerce_to_T(Top, T), + coerce_to_T(T0, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the DC resistance of a tubular conductor based on its geometric and material properties, using the resistivity and cross-sectional area of a hollow cylinder with radii ``r_{in}`` and ``r_{ext}``: + +```math +R = \\rho \\frac{\\ell}{\\pi (r_{ext}^2 - r_{in}^2)} +``` +where ``\\ell`` is the length of the conductor, ``r_{in}`` and ``r_{ext}`` are the inner and outer radii, respectively. The length is assumed to be infinite in the direction of current flow, so the resistance is calculated per unit length. + +# Arguments + +- `radius_in`: Internal radius of the tubular conductor \\[m\\]. +- `radius_ext`: External radius of the tubular conductor \\[m\\]. +- `rho`: Electrical resistivity of the conductor material \\[Ω·m\\]. +- `alpha`: Temperature coefficient of resistivity \\[1/°C\\]. +- `T0`: Reference temperature for the material properties \\[°C\\]. +- `Top`: Operating temperature of the conductor \\[°C\\]. + +# Returns + +- DC resistance of the tubular conductor \\[Ω\\]. + +# Examples + +```julia +radius_in = 0.01 +radius_ext = 0.02 +rho = 1.7241e-8 +alpha = 0.00393 +T0 = 20 +T = 25 +resistance = $(FUNCTIONNAME)(radius_in, radius_ext, rho, alpha, T0, T) +# Output: ~9.10e-8 Ω +``` + +# See also + +- [`calc_temperature_correction`](@ref) +""" +function calc_tubular_resistance( + radius_in::T, + radius_ext::T, + rho::T, + alpha::T, + T0::T, + Top::T, +) where {T <: REALSCALAR} + cross_section = π * (radius_ext^2 - radius_in^2) + return calc_temperature_correction(alpha, Top, T0) * rho / cross_section +end + +function calc_tubular_resistance(radius_in, radius_ext, rho, alpha, T0, Top) + T = resolve_T(radius_in, radius_ext, rho, alpha, T0, Top) + return calc_tubular_resistance( + coerce_to_T(radius_in, T), + coerce_to_T(radius_ext, T), + coerce_to_T(rho, T), + coerce_to_T(alpha, T), + coerce_to_T(T0, T), + coerce_to_T(Top, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the inductance of a tubular conductor per unit length, disregarding skin-effects (DC approximation) [916943](@cite) [cigre345](@cite) [1458878](@cite): + +```math +L = \\frac{\\mu_r \\mu_0}{2 \\pi} \\log \\left( \\frac{r_{ext}}{r_{in}} \\right) +``` +where ``\\mu_r`` is the relative permeability of the conductor material, ``\\mu_0`` is the vacuum permeability, and ``r_{in}`` and ``r_{ext}`` are the inner and outer radii of the conductor, respectively. + +# Arguments + +- `radius_in`: Internal radius of the tubular conductor \\[m\\]. +- `radius_ext`: External radius of the tubular conductor \\[m\\]. +- `mu_r`: Relative permeability of the conductor material \\[dimensionless\\]. + +# Returns + +- Internal inductance of the tubular conductor per unit length \\[H/m\\]. + +# Examples + +```julia +radius_in = 0.01 +radius_ext = 0.02 +mu_r = 1.0 +L = $(FUNCTIONNAME)(radius_in, radius_ext, mu_r) +# Output: ~2.31e-7 H/m +``` + +# See also + +- [`calc_tubular_resistance`](@ref) +""" +function calc_tubular_inductance( + radius_in::T, + radius_ext::T, + mu_r::T, +) where {T <: REALSCALAR} + return mu_r * μ₀ / (2 * π) * log(radius_ext / radius_in) +end + +function calc_tubular_inductance(radius_in, radius_ext, mu_r) + T = resolve_T(radius_in, radius_ext, mu_r) + return calc_tubular_inductance( + coerce_to_T(radius_in, T), + coerce_to_T(radius_ext, T), + coerce_to_T(mu_r, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the center coordinates of wires arranged in a circular pattern. + +# Arguments + +- `num_wires`: Number of wires in the circular arrangement \\[dimensionless\\]. +- `radius_wire`: Radius of each individual wire \\[m\\]. +- `radius_in`: Inner radius of the wire array (to wire centers) \\[m\\]. +- `C`: Optional tuple representing the center coordinates of the circular arrangement \\[m\\]. Default is (0.0, 0.0). + +# Returns + +- Vector of tuples, where each tuple contains the `(x, y)` coordinates \\[m\\] of the center of a wire. + +# Examples + +```julia +# Create a 7-wire array with 2mm wire radius and 1cm inner radius +wire_coords = $(FUNCTIONNAME)(7, 0.002, 0.01) +println(wire_coords[1]) # Output: First wire coordinates + +# Create a wire array with custom center position +wire_coords = $(FUNCTIONNAME)(7, 0.002, 0.01, C=(0.5, 0.3)) +``` + +# See also + +- [`LineCableModels.DataModel.WireArray`](@ref) +""" +function calc_wirearray_coords( + num_wires::U, + radius_wire::T, + radius_in::T, + C::Tuple{T, T}, +) where {T <: REALSCALAR, U <: Int} + wire_coords = Tuple{T, T}[] # Global coordinates of all wires + lay_radius = num_wires == 1 ? 0 : radius_in + radius_wire + + # Calculate the angle between each wire + angle_step = 2 * π / num_wires + for i in 0:(num_wires-1) + angle = i * angle_step + x = C[1] + lay_radius * cos(angle) + y = C[2] + lay_radius * sin(angle) + push!(wire_coords, (x, y)) # Add wire center + end + return wire_coords +end + +function calc_wirearray_coords(num_wires::Int, radius_wire, radius_in; C = nothing) + T = + C === nothing ? resolve_T(radius_wire, radius_in) : + resolve_T(radius_wire, radius_in, C...) + C_val = C === nothing ? coerce_to_T((0.0, 0.0), T) : coerce_to_T(C, T) + return calc_wirearray_coords( + num_wires, + coerce_to_T(radius_wire, T), + coerce_to_T(radius_in, T), + C_val, + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the positive-sequence inductance of a trifoil-configured cable system composed of core/screen assuming solid bonding, using the formula given under section 4.2.4.3 of CIGRE TB-531: + +```math +Z_d = \\left[Z_a - Z_x\\right] - \\frac{\\left( Z_m - Z_x \\right)^2}{Z_s - Z_x} +``` +```math +L = \\mathfrak{Im}\\left(\\frac{Z_d}{\\omega}\\right) +``` +where ``Z_a``, ``Z_s`` are the self impedances of the core conductor and the screen, and ``Z_m``, and ``Z_x`` are the mutual impedances between core/screen and between cables, respectively, as per sections 4.2.3.4, 4.2.3.5, 4.2.3.6 and 4.2.3.8 of the same document [cigre531](@cite). + +# Arguments + +- `r_in_co`: Internal radius of the phase conductor \\[m\\]. +- `r_ext_co`: External radius of the phase conductor \\[m\\]. +- `rho_co`: Electrical resistivity of the phase conductor material \\[Ω·m\\]. +- `mu_r_co`: Relative permeability of the phase conductor material \\[dimensionless\\]. +- `r_in_scr`: Internal radius of the metallic screen \\[m\\]. +- `r_ext_scr`: External radius of the metallic screen \\[m\\]. +- `rho_scr`: Electrical resistivity of the metallic screen material \\[Ω·m\\]. +- `mu_r_scr`: Relative permeability of the screen conductor material \\[dimensionless\\]. +- `S`: Spacing between conductors in trifoil configuration \\[m\\]. +- `rho_e`: Soil resistivity \\[Ω·m\\]. Default: 100 Ω·m. +- `f`: Frequency \\[Hz\\]. Default: [`f₀`](@ref). + +# Returns + +- Positive-sequence inductance per unit length of the cable system \\[H/m\\]. + +# Examples + +```julia +L = $(FUNCTIONNAME)(0.01, 0.015, 1.72e-8, 1.0, 0.02, 0.025, 2.83e-8, 1.0, S=0.1, rho_e=50, f=50) +println(L) # Output: Inductance value in H/m +``` + +# See also + +- [`calc_tubular_gmr`](@ref) +""" +function calc_inductance_trifoil( + r_in_co::T, + r_ext_co::T, + rho_co::T, + mu_r_co::T, + r_in_scr::T, + r_ext_scr::T, + rho_scr::T, + mu_r_scr::T, + S::T, + rho_e::T, + f::T, +) where {T <: REALSCALAR} + + ω = 2 * π * f + C = μ₀ / (2π) + + # Compute simplified earth return depth + DE = 659.0 * sqrt(rho_e / f) + + # Compute R'_E + RpE = (ω * μ₀) / 8.0 + + # Compute Xa + GMRa = calc_tubular_gmr(r_ext_co, r_in_co, mu_r_co) + Xa = (ω * C) * log(DE / GMRa) + + # Self impedance of a phase conductor with earth return + Ra = rho_co / (π * (r_ext_co^2 - r_in_co^2)) + Za = RpE + Ra + im * Xa + + # Compute rs + GMRscr = calc_tubular_gmr(r_ext_scr, r_in_scr, mu_r_scr) + # Compute Xs + Xs = (ω * C) * log(DE / GMRscr) + + # Self impedance of metal screen with earth return + Rs = rho_scr / (π * (r_ext_scr^2 - r_in_scr^2)) + Zs = RpE + Rs + im * Xs + + # Mutual impedance between phase conductor and screen + Zm = RpE + im * Xs + + # Compute GMD + GMD = S # trifoil, for flat use: 2^(1/3) * S + + # Compute Xap + Xap = (ω * C) * log(DE / GMD) + + # Equivalent mutual impedances between cables + Zx = RpE + im * Xap + + # Formula from CIGRE TB-531, 4.2.4.3, solid bonding + Z1_sb = (Za - Zx) - ((Zm - Zx)^2 / (Zs - Zx)) + + # Likewise, but for single point bonding + # Z1_sp = (Za - Zx) + return imag(Z1_sb) / ω +end + +function calc_inductance_trifoil( + r_in_co, + r_ext_co, + rho_co, + mu_r_co, + r_in_scr, + r_ext_scr, + rho_scr, + mu_r_scr, + S; + rho_e = 100.0, + f = f₀, +) + T = resolve_T( + r_in_co, + r_ext_co, + rho_co, + mu_r_co, + r_in_scr, + r_ext_scr, + rho_scr, + mu_r_scr, + S, + rho_e, + f, + ) + return calc_inductance_trifoil( + coerce_to_T(r_in_co, T), + coerce_to_T(r_ext_co, T), + coerce_to_T(rho_co, T), + coerce_to_T(mu_r_co, T), + coerce_to_T(r_in_scr, T), + coerce_to_T(r_ext_scr, T), + coerce_to_T(rho_scr, T), + coerce_to_T(mu_r_scr, T), + coerce_to_T(S, T), + coerce_to_T(rho_e, T), + coerce_to_T(f, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the geometric mean radius (GMR) of a circular wire array, using formula (62), page 335, of the book by Edward Rosa [rosa1908](@cite): + +```math +GMR = \\sqrt[a] {r n a^{n-1}} +``` + +where ``a`` is the layout radius, ``n`` is the number of wires, and ``r`` is the radius of each wire. + +# Arguments + +- `lay_rad`: Layout radius of the wire array \\[m\\]. +- `N`: Number of wires in the array \\[dimensionless\\]. +- `rad_wire`: Radius of an individual wire \\[m\\]. +- `mu_r`: Relative permeability of the wire material \\[dimensionless\\]. + +# Returns + +- Geometric mean radius (GMR) of the wire array \\[m\\]. + +# Examples + +```julia +lay_rad = 0.05 +N = 7 +rad_wire = 0.002 +mu_r = 1.0 +gmr = $(FUNCTIONNAME)(lay_rad, N, rad_wire, mu_r) +println(gmr) # Expected output: 0.01187... [m] +``` +""" +function calc_wirearray_gmr( + lay_rad::T, + N::Int, + rad_wire::T, + mu_r::T, +) where {T <: REALSCALAR} + gmr_wire = rad_wire * exp(-mu_r / 4) + log_gmr_array = log(gmr_wire * N * lay_rad^(N - 1)) / N + return exp(log_gmr_array) +end + +function calc_wirearray_gmr(lay_rad, N::Int, rad_wire, mu_r) + T = resolve_T(lay_rad, rad_wire, mu_r) + return calc_wirearray_gmr( + coerce_to_T(lay_rad, T), + N, + coerce_to_T(rad_wire, T), + coerce_to_T(mu_r, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the geometric mean radius (GMR) of a tubular conductor, using [6521501](@cite): + +```math +\\log GMR = \\log r_2 - \\mu_r \\left[ \\frac{r_1^4}{\\left(r_2^2 - r_1^2\\right)^2} \\log\\left(\\frac{r_2}{r_1}\\right) - \\frac{3r_1^2 - r_2^2}{4\\left(r_2^2 - r_1^2\\right)} \\right] +``` + +where ``\\mu_r`` is the material magnetic permeability (relative to free space), ``r_1`` and ``r_2`` are the inner and outer radii of the tubular conductor, respectively. If ``r_2`` is approximately equal to ``r_1`` , the tube collapses into a thin shell, and the GMR is equal to ``r_2``. If the tube becomes infinitely thick (e.g., ``r_2 \\gg r_1``), the GMR diverges to infinity. + +# Arguments + +- `radius_ext`: External radius of the tubular conductor \\[m\\]. +- `radius_in`: Internal radius of the tubular conductor \\[m\\]. +- `mu_r`: Relative permeability of the conductor material \\[dimensionless\\]. + +# Returns + +- Geometric mean radius (GMR) of the tubular conductor \\[m\\]. + +# Errors + +- Throws `ArgumentError` if `radius_ext` is less than `radius_in`. + +# Examples + +```julia +radius_ext = 0.02 +radius_in = 0.01 +mu_r = 1.0 +gmr = $(FUNCTIONNAME)(radius_ext, radius_in, mu_r) +println(gmr) # Expected output: ~0.0135 [m] +``` +""" +function calc_tubular_gmr(radius_ext::T, radius_in::T, mu_r::T) where {T <: REALSCALAR} + if (radius_ext < radius_in) || (radius_ext <= 0.0) + throw( + ArgumentError( + "Invalid parameters: radius_ext must be >= radius_in and positive.", + ), + ) + end + + # Constants + if isapprox(radius_in, radius_ext) + # Tube collapses into a thin shell with infinitesimal thickness and the GMR is simply the radius + gmr = radius_ext + elseif abs(radius_in / radius_ext) < eps() && abs(radius_in) > TOL + # Tube becomes infinitely thick up to floating point precision + gmr = Inf + else + is_solid = isapprox(radius_in, 0.0) + term1 = + is_solid ? 0.0 : + (radius_in^4 / (radius_ext^2 - radius_in^2)^2) * log(radius_ext / radius_in) + term2 = (3 * radius_in^2 - radius_ext^2) / (4 * (radius_ext^2 - radius_in^2)) + Lin = (μ₀ * mu_r / (2 * π)) * (term1 - term2) + + # Compute the GMR + gmr = exp(log(radius_ext) - (2 * π / μ₀) * Lin) + end + + return gmr +end + +function calc_tubular_gmr(radius_ext, radius_in, mu_r) + T = resolve_T(radius_ext, radius_in, mu_r) + return calc_tubular_gmr( + coerce_to_T(radius_ext, T), + coerce_to_T(radius_in, T), + coerce_to_T(mu_r, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the relative permeability (`mu_r`) based on the geometric mean radius (GMR) and conductor dimensions, by executing the inverse of [`calc_tubular_gmr`](@ref), and solving for `mu_r`: + +```math +\\log GMR = \\log r_2 - \\mu_r \\left[ \\frac{r_1^4}{\\left(r_2^2 - r_1^2\\right)^2} \\log\\left(\\frac{r_2}{r_1}\\right) - \\frac{3r_1^2 - r_2^2}{4\\left(r_2^2 - r_1^2\\right)} \\right] +``` + +```math +\\mu_r = -\\frac{\\left(\\log GMR - \\log r_2\\right)}{\\frac{r_1^4}{\\left(r_2^2 - r_1^2\\right)^2} \\log\\left(\\frac{r_2}{r_1}\\right) - \\frac{3r_1^2 - r_2^2}{4\\left(r_2^2 - r_1^2\\right)}} +``` + +where ``r_1`` is the inner radius and ``r_2`` is the outer radius. + +# Arguments + +- `gmr`: Geometric mean radius of the conductor \\[m\\]. +- `radius_ext`: External radius of the conductor \\[m\\]. +- `radius_in`: Internal radius of the conductor \\[m\\]. + +# Returns + +- Relative permeability (`mu_r`) of the conductor material \\[dimensionless\\]. + +# Errors + +- Throws `ArgumentError` if `radius_ext` is less than `radius_in`. + +# Notes + +Assumes a tubular geometry for the conductor, reducing to the solid case if `radius_in` is zero. + +# Examples + +```julia +gmr = 0.015 +radius_ext = 0.02 +radius_in = 0.01 +mu_r = $(FUNCTIONNAME)(gmr, radius_ext, radius_in) +println(mu_r) # Expected output: ~1.7 [dimensionless] +``` + +# See also +- [`calc_tubular_gmr`](@ref) +""" +function calc_equivalent_mu(gmr::T, radius_ext::T, radius_in::T) where {T <: REALSCALAR} + if (radius_ext < radius_in) || (radius_ext <= 0.0) + throw( + ArgumentError( + "Invalid parameters: radius_ext must be >= radius_in and positive.", + ), + ) + end + is_solid = isapprox(radius_in, 0.0) || isapprox(radius_in, radius_ext) + term1 = + is_solid ? 0.0 : + (radius_in^4 / (radius_ext^2 - radius_in^2)^2) * log(radius_ext / radius_in) + term2 = (3 * radius_in^2 - radius_ext^2) / (4 * (radius_ext^2 - radius_in^2)) + # Compute the log difference + log_diff = log(gmr) - log(radius_ext) + + # Compute mu_r + mu_r = -log_diff / (term1 - term2) + + return mu_r +end + +function calc_equivalent_mu(gmr, radius_ext, radius_in) + T = resolve_T(gmr, radius_ext, radius_in) + return calc_equivalent_mu( + coerce_to_T(gmr, T), + coerce_to_T(radius_ext, T), + coerce_to_T(radius_in, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the shunt capacitance per unit length of a coaxial structure, using the standard formula for the capacitance of a coaxial structure [cigre531](@cite) [916943](@cite) [1458878](@cite): + +```math +C = \\frac{2 \\pi \\varepsilon_0 \\varepsilon_r}{\\log \\left(\\frac{r_{ext}}{r_{in}}\\right)} +``` +where ``\\varepsilon_0`` is the vacuum permittivity, ``\\varepsilon_r`` is the relative permittivity of the dielectric material, and ``r_{in}`` and ``r_{ext}`` are the inner and outer radii of the coaxial structure, respectively. + +# Arguments + +- `radius_in`: Internal radius of the coaxial structure \\[m\\]. +- `radius_ext`: External radius of the coaxial structure \\[m\\]. +- `epsr`: Relative permittivity of the dielectric material \\[dimensionless\\]. + +# Returns + +- Shunt capacitance per unit length \\[F/m\\]. + +# Examples + +```julia +radius_in = 0.01 +radius_ext = 0.02 +epsr = 2.3 +capacitance = $(FUNCTIONNAME)(radius_in, radius_ext, epsr) +println(capacitance) # Expected output: ~1.24e-10 [F/m] +``` +""" +function calc_shunt_capacitance( + radius_in::T, + radius_ext::T, + epsr::T, +) where {T <: REALSCALAR} + return 2 * π * ε₀ * epsr / log(radius_ext / radius_in) +end + +function calc_shunt_capacitance(radius_in, radius_ext, epsr) + T = resolve_T(radius_in, radius_ext, epsr) + return calc_shunt_capacitance( + coerce_to_T(radius_in, T), + coerce_to_T(radius_ext, T), + coerce_to_T(epsr, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the shunt conductance per unit length of a coaxial structure, using the improved model reported in [916943](@cite) [Karmokar2025](@cite) [4389974](@cite): + +```math +G = \\frac{2\\pi\\sigma}{\\log(\\frac{r_{ext}}{r_{in}})} +``` +where ``\\sigma = \\frac{1}{\\rho}`` is the conductivity of the dielectric/semiconducting material, ``r_{in}`` is the internal radius, and ``r_{ext}`` is the external radius of the coaxial structure. + +# Arguments + +- `radius_in`: Internal radius of the coaxial structure \\[m\\]. +- `radius_ext`: External radius of the coaxial structure \\[m\\]. +- `rho`: Resistivity of the dielectric/semiconducting material \\[Ω·m\\]. + +# Returns + +- Shunt conductance per unit length \\[S·m\\]. + +# Examples + +```julia +radius_in = 0.01 +radius_ext = 0.02 +rho = 1e9 +g = $(FUNCTIONNAME)(radius_in, radius_ext, rho) +println(g) # Expected output: 2.7169e-9 [S·m] +``` +""" +function calc_shunt_conductance(radius_in::T, radius_ext::T, rho::T) where {T <: REALSCALAR} + return 2 * π * (1 / rho) / log(radius_ext / radius_in) +end + +function calc_shunt_conductance(radius_in, radius_ext, rho) + T = resolve_T(radius_in, radius_ext, rho) + return calc_shunt_conductance( + coerce_to_T(radius_in, T), + coerce_to_T(radius_ext, T), + coerce_to_T(rho, T), + ) +end + + +""" +$(TYPEDSIGNATURES) + +Calculates the equivalent geometric mean radius (GMR) of a conductor after adding a new layer, by recursive application of the multizone stranded conductor defined as [yang2008gmr](@cite): + +```math +GMR_{eq} = {GMR_{i-1}}^{\\beta^2} \\cdot {GMR_{i}}^{(1-\\beta)^2} \\cdot {GMD}^{2\\beta(1-\\beta)} +``` +```math +\\beta = \\frac{S_{i-1}}{S_{i-1} + S_{i}} +``` +where: +- ``S_{i-1}`` is the cumulative cross-sectional area of the existing cable part, ``S_{i}`` is the total cross-sectional area after inclusion of the conducting layer ``{i}``. +- ``GMR_{i-1}`` is the cumulative GMR of the existing cable part, ``GMR_{i}`` is the GMR of the conducting layer ``{i}``. +- ``GMD`` is the geometric mean distance between the existing cable part and the new layer, calculated using [`calc_gmd`](@ref). + +# Arguments + +- `existing`: The existing cable part ([`AbstractCablePart`](@ref)). +- `new_layer`: The new layer being added ([`AbstractCablePart`](@ref)). + +# Returns + +- Updated equivalent GMR of the combined conductor \\[m\\]. + +# Examples + +```julia +material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) +conductor = Conductor(Strip(0.01, 0.002, 0.05, 10, material_props)) +new_layer = WireArray(0.02, 0.002, 7, 15, material_props) +equivalent_gmr = $(FUNCTIONNAME)(conductor, new_layer) # Expected output: Updated GMR value [m] +``` + +# See also + +- [`calc_gmd`](@ref) +""" +function calc_equivalent_gmr( + existing::T, + new_layer::U, +) where {T <: AbstractCablePart, U <: AbstractCablePart} + beta = existing.cross_section / (existing.cross_section + new_layer.cross_section) + + DM = parentmodule(@__MODULE__) # DataModel + if isdefined(DM, :ConductorGroup) + CG = getfield(DM, :ConductorGroup) + if existing isa CG + current_conductor = existing.layers[end] + else + current_conductor = existing + end + end + + # current_conductor = existing isa ConductorGroup ? existing.layers[end] : existing + gmd = calc_gmd(current_conductor, new_layer) + return existing.gmr^(beta^2) * new_layer.gmr^((1 - beta)^2) * + gmd^(2 * beta * (1 - beta)) +end + +# evil hackery to detect WireArray types +@inline function _is_wirearray(x) + DM = parentmodule(@__MODULE__) # DataModel + return isdefined(DM, :WireArray) && + (x isa getfield(DM, :WireArray)) # no compile-time ref to WireArray +end + +""" +$(TYPEDSIGNATURES) + +Calculates the geometric mean distance (GMD) between two cable parts, by using the definition described in Grover [grover1981inductance](@cite): + +```math +\\log GMD = \\left(\\frac{\\sum_{i=1}^{n_1}\\sum_{j=1}^{n_2} (s_1 \\cdot s_2) \\cdot \\log(d_{ij})}{\\sum_{i=1}^{n_1}\\sum_{j=1}^{n_2} (s_1 \\cdot s_2)}\\right) +``` + +where: +- ``d_{ij}`` is the Euclidean distance between elements ``i`` and ``j``. +- ``s_1`` and ``s_2`` are the cross-sectional areas of the respective elements. +- ``n_1`` and ``n_2`` are the number of sub-elements in each cable part. + +# Arguments + +- `co1`: First cable part ([`AbstractCablePart`](@ref)). +- `co2`: Second cable part ([`AbstractCablePart`](@ref)). + +# Returns + +- Geometric mean distance between the cable parts \\[m\\]. + +# Notes + +For concentric structures, the GMD converges to the external radii of the outermost element. + +!!! info "Numerical stability" + This implementation uses a weighted sum of logarithms rather than the traditional product formula ``\\Pi(d_{ij})^{(1/n)}`` found in textbooks. The logarithmic approach prevents numerical underflow/overflow when dealing with many conductors or extreme distance ratios, making it significantly more stable for practical calculations. + +# Examples + +```julia +material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) +wire_array1 = WireArray(0.01, 0.002, 7, 10, material_props) +wire_array2 = WireArray(0.02, 0.002, 7, 15, material_props) +gmd = $(FUNCTIONNAME)(wire_array1, wire_array2) # Expected output: GMD value [m] + +strip = Strip(0.01, 0.002, 0.05, 10, material_props) +tubular = Tubular(0.01, 0.02, material_props) +gmd = $(FUNCTIONNAME)(strip, tubular) # Expected output: GMD value [m] +``` + +# See also + +- [`calc_wirearray_coords`](@ref) +- [`calc_equivalent_gmr`](@ref) +""" +function calc_gmd(co1::T, co2::U) where {T <: AbstractCablePart, U <: AbstractCablePart} + + if _is_wirearray(co1) #co1 isa WireArray + coords1 = calc_wirearray_coords(co1.num_wires, co1.radius_wire, co1.radius_in) + n1 = co1.num_wires + r1 = co1.radius_wire + s1 = pi * r1^2 + else + coords1 = [(0.0, 0.0)] + n1 = 1 + r1 = co1.radius_ext + s1 = co1.cross_section + end + + # if co2 isa WireArray + if _is_wirearray(co2) + coords2 = calc_wirearray_coords(co2.num_wires, co2.radius_wire, co2.radius_in) + n2 = co2.num_wires + r2 = co2.radius_wire + s2 = pi * r2^2 + else + coords2 = [(0.0, 0.0)] + n2 = 1 + r2 = co2.radius_ext + s2 = co2.cross_section + end + + log_sum = 0.0 + area_weights = 0.0 + + for i in 1:n1 + for j in 1:n2 + # Pair-wise distances + x1, y1 = coords1[i] + x2, y2 = coords2[j] + d_ij = sqrt((x1 - x2)^2 + (y1 - y2)^2) + if d_ij > eps() + # The GMD is computed as the Euclidean distance from center-to-center + log_dij = log(d_ij) + else + # This means two concentric structures (solid/strip or tubular, tubular/strip or tubular, strip/strip or tubular) + # In all cases the GMD is the outermost radius + # max(r1, r2) + log_dij = log(max(r1, r2)) + end + log_sum += (s1 * s2) * log_dij + area_weights += (s1 * s2) + end + end + return exp(log_sum / area_weights) +end +""" +$(TYPEDSIGNATURES) + +Calculates the solenoid correction factor for magnetic permeability in insulated cables with helical conductors ([`WireArray`](@ref)), using the formula from Gudmundsdottir et al. [5743045](@cite): + +```math +\\mu_{r, sol} = 1 + \\frac{2 \\pi^2 N^2 (r_{ins, ext}^2 - r_{con, ext}^2)}{\\log(r_{ins, ext}/r_{con, ext})} +``` + +where: +- ``N`` is the number of turns per unit length. +- ``r_{con, ext}`` is the conductor external radius. +- ``r_{ins, ext}`` is the insulator external radius. + +# Arguments + +- `num_turns`: Number of turns per unit length \\[1/m\\]. +- `radius_ext_con`: External radius of the conductor \\[m\\]. +- `radius_ext_ins`: External radius of the insulator \\[m\\]. + +# Returns + +- Correction factor for the insulator magnetic permeability \\[dimensionless\\]. + +# Examples + +```julia +# Cable with 10 turns per meter, conductor radius 5 mm, insulator radius 10 mm +correction = $(FUNCTIONNAME)(10, 0.005, 0.01) # Expected output: > 1.0 [dimensionless] + +# Non-helical cable (straight conductor) +correction = $(FUNCTIONNAME)(NaN, 0.005, 0.01) # Expected output: 1.0 [dimensionless] +``` +""" +function calc_solenoid_correction( + num_turns::T, + radius_ext_con::T, + radius_ext_ins::T, +) where {T <: REALSCALAR} + if isnan(num_turns) + return 1.0 + else + return 1.0 + + 2 * num_turns^2 * pi^2 * (radius_ext_ins^2 - radius_ext_con^2) / + log(radius_ext_ins / radius_ext_con) + end +end + +function calc_solenoid_correction( + num_turns, + radius_ext_con, + radius_ext_ins, +) + T = resolve_T(num_turns, radius_ext_con, radius_ext_ins) + return calc_solenoid_correction( + coerce_to_T(num_turns, T), + coerce_to_T(radius_ext_con, T), + coerce_to_T(radius_ext_ins, T), + ) +end +""" +$(TYPEDSIGNATURES) + +Calculates the equivalent thermal conductivity (κ) a solid tubular conductor, using the Law of Wiedemann–Franz: + +```math +\\kappa_{eq} = L \\sigma T = L \\frac{T}{\\rho} +``` + +where ``L`` is the Lorenz number. + +# Arguments + +- `T`: Temperature of the conductor \\[°C\\]. +- `rho`: Resistivity of the conductor \\[Ω·m\\]. + +# Returns + +- Equivalent thermal conductivity of the tubular conductor \\[W/(m·K)\\]. + +# Examples + +```julia +kappa_eq = $(FUNCTIONNAME)(2.38-8, 20.0) # Expected output: ~299.8 [W/(m·K)] +``` +""" +function calc_equivalent_kappa( + rho::T, + T0::T, +) where {T <: REALSCALAR} + T0_kelvin = T0 + 273.15 # Convert to Kelvin + L = 2.44e-8 # Lorenz number in W·Ω/K² + return L * inv(rho) * T0_kelvin +end + +function calc_equivalent_kappa(rho, T0) + T = resolve_T(rho, T0) + return calc_equivalent_kappa( + coerce_to_T(rho, T), + coerce_to_T(T0, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the equivalent thermal conductivity of a multilayer tubular insulator. + +The function computes the total thermal resistance of the insulator group by +summing the resistances of each concentric layer in series. + +```math +R_{th, layer} = \\frac{\\log(r_{ext} / r_{in})}{2 \\pi \\kappa_{layer}} + +R_{th, total} = \\sum R_{th, layer} +``` + +The equivalent thermal conductivity (κ_eq) is then found by treating the +entire group as a single layer with the total thermal resistance and the +group's overall geometry: + +```math +\\kappa_{eq} = \\frac{\\log(r_{group, ext} / r_{group, in})}{2 \\pi R_{th, total}} +``` + +# Arguments + +- `insulator_group::InsulatorGroup{T}`: A struct containing a vector of + `InsulatorLayer` objects and the total inner and outer radii. + +# Returns + +- Equivalent thermal conductivity of the insulator group [W/(m·K)]. + + +""" +function calc_equivalent_kappa(insulator_group::AbstractInsulatorPart) + R_thermal_total = 0.0 + + # Calculate total thermal resistance by summing layers in series + for layer in insulator_group.layers + # Ensure radii are valid to prevent log(<=0) + if layer.radius_ext <= layer.radius_in || layer.material_props.kappa <= 0.0 + # Or throw an error + continue + end + + R_thermal_layer = + log(layer.radius_ext / layer.radius_in) / (2 * π * layer.material_props.kappa) + + R_thermal_total += R_thermal_layer + end + + r_group_ext = insulator_group.radius_ext + r_group_in = insulator_group.radius_in + + # Check for invalid total geometry or zero total resistance + if R_thermal_total <= 0.0 || r_group_ext <= r_group_in + # Fallback: return kappa of the first layer or 0.0 + return isempty(insulator_group.layers) ? 0.0 : insulator_group.layers[1].material_props.kappa + end + + # Calculate equivalent kappa using the total resistance and total geometry + kappa_eq = log(r_group_ext / r_group_in) / (2 * π * R_thermal_total) + + return kappa_eq +end +""" +$(TYPEDSIGNATURES) + +Calculates the equivalent resistivity of a solid tubular conductor, using the formula [916943](@cite): + +```math +\\rho_{eq} = R_{eq} S_{eff} = R_{eq} \\pi (r_{ext}^2 - r_{in}^2) +``` + +where ``S_{eff}`` is the effective cross-sectional area of the tubular conductor. + +# Arguments + +- `R`: Resistance of the conductor \\[Ω\\]. +- `radius_ext_con`: External radius of the tubular conductor \\[m\\]. +- `radius_in_con`: Internal radius of the tubular conductor \\[m\\]. + +# Returns + +- Equivalent resistivity of the tubular conductor \\[Ω·m\\]. + +# Examples + +```julia +rho_eq = $(FUNCTIONNAME)(0.01, 0.02, 0.01) # Expected output: ~9.42e-4 [Ω·m] +``` +""" +function calc_equivalent_rho( + R::T, + radius_ext_con::T, + radius_in_con::T, +) where {T <: REALSCALAR} + eff_conductor_area = π * (radius_ext_con^2 - radius_in_con^2) + return R * eff_conductor_area +end + +function calc_equivalent_rho(R, radius_ext_con, radius_in_con) + T = resolve_T(R, radius_ext_con, radius_in_con) + return calc_equivalent_rho( + coerce_to_T(R, T), + coerce_to_T(radius_ext_con, T), + coerce_to_T(radius_in_con, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the equivalent permittivity for a coaxial cable insulation, using the formula [916943](@cite): + +```math +\\varepsilon_{eq} = \\frac{C_{eq} \\log(\\frac{r_{ext}}{r_{in}})}{2\\pi \\varepsilon_0} +``` + +where ``\\varepsilon_0`` is the permittivity of free space. + +# Arguments + +- `C_eq`: Equivalent capacitance of the insulation \\[F/m\\]. +- `radius_ext`: External radius of the insulation \\[m\\]. +- `radius_in`: Internal radius of the insulation \\[m\\]. + +# Returns + +- Equivalent relative permittivity of the insulation \\[dimensionless\\]. + +# Examples + +```julia +eps_eq = $(FUNCTIONNAME)(1e-10, 0.01, 0.005) # Expected output: ~2.26 [dimensionless] +``` + +# See also +- [`ε₀`](@ref) +""" +function calc_equivalent_eps(C_eq::T, radius_ext::T, radius_in::T) where {T <: REALSCALAR} + return (C_eq * log(radius_ext / radius_in)) / (2 * pi) / ε₀ +end + +function calc_equivalent_eps(C_eq, radius_ext, radius_in) + T = resolve_T(C_eq, radius_ext, radius_in) + return calc_equivalent_eps( + coerce_to_T(C_eq, T), + coerce_to_T(radius_ext, T), + coerce_to_T(radius_in, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the equivalent loss factor (tangent) of a dielectric material: + +```math +\\tan \\delta = \\frac{G_{eq}}{\\omega \\cdot C_{eq}} +``` + +where ``\\tan \\delta`` is the loss factor (tangent). + +# Arguments + +- `G_eq`: Equivalent conductance of the material \\[S·m\\]. +- `C_eq`: Equivalent capacitance of the material \\[F/m\\]. +- `ω`: Angular frequency \\[rad/s\\]. + +# Returns + +- Equivalent loss factor of the dielectric material \\[dimensionless\\]. + +# Examples + +```julia +loss_factor = $(FUNCTIONNAME)(1e-8, 1e-10, 2π*50) # Expected output: ~0.0318 [dimensionless] +``` +""" +function calc_equivalent_lossfact(G_eq::T, C_eq::T, ω::T) where {T <: REALSCALAR} + return G_eq / (ω * C_eq) +end + +function calc_equivalent_lossfact(G_eq, C_eq, ω) + T = resolve_T(G_eq, C_eq, ω) + return calc_equivalent_lossfact( + coerce_to_T(G_eq, T), + coerce_to_T(C_eq, T), + coerce_to_T(ω, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Calculates the effective conductivity of a dielectric material from the known conductance (related to the loss factor ``\\tan \\delta``) via [916943](@cite) [Karmokar2025](@cite) [4389974](@cite): + +```math +\\sigma_{eq} = \\frac{G_{eq}}{2\\pi} \\log(\\frac{r_{ext}}{r_{in}}) +``` +where ``\\sigma_{eq} = \\frac{1}{\\rho_{eq}}`` is the conductivity of the dielectric/semiconducting material, ``G_{eq}`` is the shunt conductance per unit length, ``r_{in}`` is the internal radius, and ``r_{ext}`` is the external radius of the coaxial structure. + +# Arguments + +- `G_eq`: Equivalent conductance of the material \\[S·m\\]. +- `radius_in`: Internal radius of the coaxial structure \\[m\\]. +- `radius_ext`: External radius of the coaxial structure \\[m\\]. + +# Returns + +- Effective material conductivity per unit length \\[S·m\\]. + +# Examples + +```julia +Geq = 2.7169e-9 +sigma_eq = $(FUNCTIONNAME)(G_eq, radius_in, radius_ext) +``` +""" +function calc_sigma_lossfact(G_eq::T, radius_in::T, radius_ext::T) where {T <: REALSCALAR} + return G_eq * log(radius_ext / radius_in) / (2 * pi) +end + +function calc_sigma_lossfact(G_eq, radius_in, radius_ext) + T = resolve_T(G_eq, radius_in, radius_ext) + return calc_sigma_lossfact( + coerce_to_T(G_eq, T), + coerce_to_T(radius_in, T), + coerce_to_T(radius_ext, T), + ) +end + +end # module BaseParams diff --git a/src/datamodel/cablecomponent.jl b/src/datamodel/cablecomponent.jl index d0a38e83..3d7e1114 100644 --- a/src/datamodel/cablecomponent.jl +++ b/src/datamodel/cablecomponent.jl @@ -1,241 +1,243 @@ - -""" -$(TYPEDEF) - -Represents a [`CableComponent`](@ref), i.e. a group of [`AbstractCablePart`](@ref) objects, with the equivalent geometric and material properties: - -$(TYPEDFIELDS) - -!!! info "Definition & application" - Cable components operate as containers for multiple cable parts, allowing the calculation of effective electromagnetic (EM) properties (``\\sigma, \\varepsilon, \\mu``). This is performed by transforming the physical objects within the [`CableComponent`](@ref) into one equivalent coaxial homogeneous structure comprised of one conductor and one insulator, each one represented by effective [`Material`](@ref) types stored in `conductor_props` and `insulator_props` fields. - - The effective properties approach is widely adopted in EMT-type simulations, and involves locking the internal and external radii of the conductor and insulator parts, respectively, and calculating the equivalent EM properties in order to match the previously determined values of R, L, C and G [916943](@cite) [1458878](@cite). - - In applications, the [`CableComponent`](@ref) type is mapped to the main cable structures described in manufacturer datasheets, e.g., core, sheath, armor and jacket. -""" -mutable struct CableComponent{T<:REALSCALAR} - "Cable component identification (e.g. core/sheath/armor)." - id::String - "The conductor group containing all conductive parts." - conductor_group::ConductorGroup{T} - "Effective properties of the equivalent coaxial conductor." - conductor_props::Material{T} - "The insulator group containing all insulating parts." - insulator_group::InsulatorGroup{T} - "Effective properties of the equivalent coaxial insulator." - insulator_props::Material{T} - - @doc """ - $(TYPEDSIGNATURES) - - Initializes a [`CableComponent`](@ref) object based on its constituent conductor and insulator groups. The constructor performs the following sequence of steps: - - 1. Validate that the conductor and insulator groups have matching radii at their interface. - 2. Obtain the lumped-parameter values (R, L, C, G) from the conductor and insulator groups, which are computed within their respective constructors. - 3. Calculate the correction factors and equivalent electromagnetic properties of the conductor and insulator groups: - - - | Quantity | Symbol | Function | - |----------|--------|----------| - | Resistivity (conductor) | ``\\rho_{con}`` | [`calc_equivalent_rho`](@ref) | - | Permeability (conductor) | ``\\mu_{con}`` | [`calc_equivalent_mu`](@ref) | - | Resistivity (insulator) | ``\\rho_{ins}`` | [`calc_sigma_lossfact`](@ref) | - | Permittivity (insulation) | ``\\varepsilon_{ins}`` | [`calc_equivalent_eps`](@ref) | - | Permeability (insulation) | ``\\mu_{ins}`` | [`calc_solenoid_correction`](@ref) | - - # Arguments - - - `id`: Cable component identification (e.g. core/sheath/armor). - - `conductor_group`: The conductor group containing all conductive parts. - - `insulator_group`: The insulator group containing all insulating parts. - - # Returns - - A [`CableComponent`](@ref) instance with calculated equivalent properties: - - - `id::String`: Cable component identification. - - `conductor_group::ConductorGroup{T}`: The conductor group containing all conductive parts. - - `conductor_props::Material{T}`: Effective properties of the equivalent coaxial conductor. - * `rho`: Resistivity \\[Ω·m\\]. - * `eps_r`: Relative permittivity \\[dimensionless\\]. - * `mu_r`: Relative permeability \\[dimensionless\\]. - * `T0`: Reference temperature \\[°C\\]. - * `alpha`: Temperature coefficient of resistivity \\[1/°C\\]. - - `insulator_group::InsulatorGroup{T}`: The insulator group containing all insulating parts. - - `insulator_props::Material{T}`: Effective properties of the equivalent coaxial insulator. - * `rho`: Resistivity \\[Ω·m\\]. - * `eps_r`: Relative permittivity \\[dimensionless\\]. - * `mu_r`: Relative permeability \\[dimensionless\\]. - * `T0`: Reference temperature \\[°C\\]. - * `alpha`: Temperature coefficient of resistivity \\[1/°C\\]. - - # Examples - - ```julia - conductor_group = ConductorGroup(...) - insulator_group = InsulatorGroup(...) - cable = $(FUNCTIONNAME)("component_id", conductor_group, insulator_group) # Create cable component with base parameters @ 50 Hz - ``` - - # See also - - - [`calc_equivalent_rho`](@ref) - - [`calc_equivalent_mu`](@ref) - - [`calc_equivalent_eps`](@ref) - - [`calc_sigma_lossfact`](@ref) - - [`calc_solenoid_correction`](@ref) - """ - function CableComponent{T}( - id::String, - conductor_group::ConductorGroup{T}, - insulator_group::InsulatorGroup{T}, - ) where {T<:REALSCALAR} - - # Geometry interface check (exact or approximately equal) - if !(conductor_group.radius_ext == insulator_group.radius_in || - isapprox(conductor_group.radius_ext, insulator_group.radius_in)) - throw(ArgumentError("Conductor outer radius must match insulator inner radius.")) - end - - # Radii - r1 = conductor_group.radius_in - r2 = conductor_group.radius_ext - r3 = insulator_group.radius_ext - - # 2) Conductor equivalents - ρ_con = calc_equivalent_rho(conductor_group.resistance, r2, r1) - μ_con = calc_equivalent_mu(conductor_group.gmr, r2, r1) - α_con = conductor_group.alpha - θ_con = conductor_group.layers[1].temperature - conductor_props = Material{T}(ρ_con, T(0), μ_con, θ_con, α_con) - - # 3) Insulator equivalents (use already-aggregated C and G) - C_eq = insulator_group.shunt_capacitance - G_eq = insulator_group.shunt_conductance - ε_ins = calc_equivalent_eps(C_eq, r3, r2) - σ_ins = calc_sigma_lossfact(G_eq, r2, r3) - ρ_ins = inv(σ_ins) # safe if σ_ins ≠ 0 - μ_ins_corr = calc_solenoid_correction(conductor_group.num_turns, r2, r3) - θ_ins = insulator_group.layers[1].temperature - insulator_props = Material{T}(ρ_ins, ε_ins, μ_ins_corr, θ_ins, T(0)) - - return new{T}( - id, - conductor_group, - conductor_props, - insulator_group, - insulator_props, - ) - end -end - -""" -$(TYPEDSIGNATURES) - -Weakly-typed constructor that infers the scalar type `T` from the two groups, coerces them if necessary, and calls the strict kernel. - -# Arguments -- `id`: Cable component identification. -- `conductor_group`: The conductor group (any `ConductorGroup{S}`). -- `insulator_group`: The insulator group (any `InsulatorGroup{R}`). - -# Returns -- A `CableComponent{T}` where `T` is the resolved scalar type. -""" -function CableComponent( - id::String, - conductor_group::ConductorGroup, - insulator_group::InsulatorGroup, -) - # Resolve target T from the two groups (honors Measurements, etc.) - T = resolve_T(conductor_group, insulator_group) - - # Coerce groups to T (identity if already T) - cgT = coerce_to_T(conductor_group, T) - igT = coerce_to_T(insulator_group, T) - - return CableComponent{T}(id, cgT, igT) -end - - -""" -$(TYPEDSIGNATURES) - -Constructs the equivalent coaxial conductor as a `Tubular` directly from a -`CableComponent`, reusing the rigorously tested positional constructor. - -# Arguments - -- `component`: The `CableComponent` providing geometry and material. - -# Returns - -- `Tubular{T}` with radii from `component.conductor_group` and material from - `component.conductor_props` at the group temperature (fallback to `T0`). -""" -function Tubular(component::CableComponent{T}) where {T<:REALSCALAR} - cg = component.conductor_group - temp = component.conductor_props.T0 - return Tubular(cg.radius_in, cg.radius_ext, component.conductor_props, temp) -end - -""" -$(TYPEDSIGNATURES) - -Constructs the equivalent coaxial insulation as an `Insulator` directly from a -`CableComponent`, calling the strict positional constructor. - -# Arguments - -- `component`: The `CableComponent` providing geometry and material. - -# Returns - -- `Insulator{T}` with radii from `component.insulator_group` and material from - `component.insulator_props` at the group temperature (fallback to `T0`). -""" -function Insulator(component::CableComponent{T}) where {T<:REALSCALAR} - ig = component.insulator_group - temp = component.insulator_props.T0 - return Insulator(ig.radius_in, ig.radius_ext, component.insulator_props, temp) -end - -""" -$(TYPEDSIGNATURES) - -Build a `ConductorGroup` equivalent for a `CableComponent`, preserving -`num_turns` and `num_wires` from the original group. - -Constructs a single-layer `ConductorGroup{T}` from the computed equivalent -`Tubular(component)`, but carries over bookkeeping fields needed by downstream -corrections (e.g., solenoid correction using `num_turns`). -""" -function ConductorGroup(component::CableComponent{T}) where {T<:REALSCALAR} - orig = component.conductor_group - t = Tubular(component) - return ConductorGroup{T}( - t.radius_in, - t.radius_ext, - t.cross_section, - orig.num_wires, - orig.num_turns, - t.resistance, - t.material_props.alpha, - t.gmr, - AbstractConductorPart{T}[t], - ) -end - -""" -$(TYPEDSIGNATURES) - -Build an `InsulatorGroup` equivalent for a `CableComponent` while maintaining -geometric coupling to the equivalent conductor group. - -Stacks a single insulating layer of equivalent material and thickness over the -new conductor group created from the same component. -""" -InsulatorGroup(component::CableComponent{T}) where {T<:REALSCALAR} = InsulatorGroup{T}(Insulator(component)) - - -include("cablecomponent/base.jl") + +""" +$(TYPEDEF) + +Represents a [`CableComponent`](@ref), i.e. a group of [`AbstractCablePart`](@ref) objects, with the equivalent geometric and material properties: + +$(TYPEDFIELDS) + +!!! info "Definition & application" + Cable components operate as containers for multiple cable parts, allowing the calculation of effective electromagnetic (EM) properties (``\\sigma, \\varepsilon, \\mu``). This is performed by transforming the physical objects within the [`CableComponent`](@ref) into one equivalent coaxial homogeneous structure comprised of one conductor and one insulator, each one represented by effective [`Material`](@ref) types stored in `conductor_props` and `insulator_props` fields. + + The effective properties approach is widely adopted in EMT-type simulations, and involves locking the internal and external radii of the conductor and insulator parts, respectively, and calculating the equivalent EM properties in order to match the previously determined values of R, L, C and G [916943](@cite) [1458878](@cite). + + In applications, the [`CableComponent`](@ref) type is mapped to the main cable structures described in manufacturer datasheets, e.g., core, sheath, armor and jacket. +""" +mutable struct CableComponent{T<:REALSCALAR} + "Cable component identification (e.g. core/sheath/armor)." + id::String + "The conductor group containing all conductive parts." + conductor_group::ConductorGroup{T} + "Effective properties of the equivalent coaxial conductor." + conductor_props::Material{T} + "The insulator group containing all insulating parts." + insulator_group::InsulatorGroup{T} + "Effective properties of the equivalent coaxial insulator." + insulator_props::Material{T} + + @doc """ + $(TYPEDSIGNATURES) + + Initializes a [`CableComponent`](@ref) object based on its constituent conductor and insulator groups. The constructor performs the following sequence of steps: + + 1. Validate that the conductor and insulator groups have matching radii at their interface. + 2. Obtain the lumped-parameter values (R, L, C, G) from the conductor and insulator groups, which are computed within their respective constructors. + 3. Calculate the correction factors and equivalent electromagnetic properties of the conductor and insulator groups: + + + | Quantity | Symbol | Function | + |----------|--------|----------| + | Resistivity (conductor) | ``\\rho_{con}`` | [`calc_equivalent_rho`](@ref) | + | Permeability (conductor) | ``\\mu_{con}`` | [`calc_equivalent_mu`](@ref) | + | Resistivity (insulator) | ``\\rho_{ins}`` | [`calc_sigma_lossfact`](@ref) | + | Permittivity (insulation) | ``\\varepsilon_{ins}`` | [`calc_equivalent_eps`](@ref) | + | Permeability (insulation) | ``\\mu_{ins}`` | [`calc_solenoid_correction`](@ref) | + + # Arguments + + - `id`: Cable component identification (e.g. core/sheath/armor). + - `conductor_group`: The conductor group containing all conductive parts. + - `insulator_group`: The insulator group containing all insulating parts. + + # Returns + + A [`CableComponent`](@ref) instance with calculated equivalent properties: + + - `id::String`: Cable component identification. + - `conductor_group::ConductorGroup{T}`: The conductor group containing all conductive parts. + - `conductor_props::Material{T}`: Effective properties of the equivalent coaxial conductor. + * `rho`: Resistivity \\[Ω·m\\]. + * `eps_r`: Relative permittivity \\[dimensionless\\]. + * `mu_r`: Relative permeability \\[dimensionless\\]. + * `T0`: Reference temperature \\[°C\\]. + * `alpha`: Temperature coefficient of resistivity \\[1/°C\\]. + - `insulator_group::InsulatorGroup{T}`: The insulator group containing all insulating parts. + - `insulator_props::Material{T}`: Effective properties of the equivalent coaxial insulator. + * `rho`: Resistivity \\[Ω·m\\]. + * `eps_r`: Relative permittivity \\[dimensionless\\]. + * `mu_r`: Relative permeability \\[dimensionless\\]. + * `T0`: Reference temperature \\[°C\\]. + * `alpha`: Temperature coefficient of resistivity \\[1/°C\\]. + + # Examples + + ```julia + conductor_group = ConductorGroup(...) + insulator_group = InsulatorGroup(...) + cable = $(FUNCTIONNAME)("component_id", conductor_group, insulator_group) # Create cable component with base parameters @ 50 Hz + ``` + + # See also + + - [`calc_equivalent_rho`](@ref) + - [`calc_equivalent_mu`](@ref) + - [`calc_equivalent_eps`](@ref) + - [`calc_sigma_lossfact`](@ref) + - [`calc_solenoid_correction`](@ref) + """ + function CableComponent{T}( + id::String, + conductor_group::ConductorGroup{T}, + insulator_group::InsulatorGroup{T}, + ) where {T<:REALSCALAR} + + # Geometry interface check (exact or approximately equal) + if !(conductor_group.radius_ext == insulator_group.radius_in || + isapprox(conductor_group.radius_ext, insulator_group.radius_in)) + throw(ArgumentError("Conductor outer radius must match insulator inner radius.")) + end + + # Radii + r1 = conductor_group.radius_in + r2 = conductor_group.radius_ext + r3 = insulator_group.radius_ext + + # 2) Conductor equivalents + ρ_con = calc_equivalent_rho(conductor_group.resistance, r2, r1) + μ_con = calc_equivalent_mu(conductor_group.gmr, r2, r1) + α_con = conductor_group.alpha + θ_con = conductor_group.layers[1].temperature + κ_con = calc_equivalent_kappa(ρ_con, conductor_group.layers[1].temperature) + conductor_props = Material{T}(ρ_con, T(0), μ_con, θ_con, α_con, κ_con) + + # 3) Insulator equivalents (use already-aggregated C and G) + C_eq = insulator_group.shunt_capacitance + G_eq = insulator_group.shunt_conductance + ε_ins = calc_equivalent_eps(C_eq, r3, r2) + σ_ins = calc_sigma_lossfact(G_eq, r2, r3) + ρ_ins = inv(σ_ins) # safe if σ_ins ≠ 0 + μ_ins_corr = calc_solenoid_correction(conductor_group.num_turns, r2, r3) + θ_ins = insulator_group.layers[1].temperature + κ_ins = calc_equivalent_kappa(insulator_group) + insulator_props = Material{T}(ρ_ins, ε_ins, μ_ins_corr, θ_ins, T(0), κ_ins) + + return new{T}( + id, + conductor_group, + conductor_props, + insulator_group, + insulator_props, + ) + end +end + +""" +$(TYPEDSIGNATURES) + +Weakly-typed constructor that infers the scalar type `T` from the two groups, coerces them if necessary, and calls the strict kernel. + +# Arguments +- `id`: Cable component identification. +- `conductor_group`: The conductor group (any `ConductorGroup{S}`). +- `insulator_group`: The insulator group (any `InsulatorGroup{R}`). + +# Returns +- A `CableComponent{T}` where `T` is the resolved scalar type. +""" +function CableComponent( + id::String, + conductor_group::ConductorGroup, + insulator_group::InsulatorGroup, +) + # Resolve target T from the two groups (honors Measurements, etc.) + T = resolve_T(conductor_group, insulator_group) + + # Coerce groups to T (identity if already T) + cgT = coerce_to_T(conductor_group, T) + igT = coerce_to_T(insulator_group, T) + + return CableComponent{T}(id, cgT, igT) +end + + +""" +$(TYPEDSIGNATURES) + +Constructs the equivalent coaxial conductor as a `Tubular` directly from a +`CableComponent`, reusing the rigorously tested positional constructor. + +# Arguments + +- `component`: The `CableComponent` providing geometry and material. + +# Returns + +- `Tubular{T}` with radii from `component.conductor_group` and material from + `component.conductor_props` at the group temperature (fallback to `T0`). +""" +function Tubular(component::CableComponent{T}) where {T<:REALSCALAR} + cg = component.conductor_group + temp = component.conductor_props.T0 + return Tubular(cg.radius_in, cg.radius_ext, component.conductor_props, temp) +end + +""" +$(TYPEDSIGNATURES) + +Constructs the equivalent coaxial insulation as an `Insulator` directly from a +`CableComponent`, calling the strict positional constructor. + +# Arguments + +- `component`: The `CableComponent` providing geometry and material. + +# Returns + +- `Insulator{T}` with radii from `component.insulator_group` and material from + `component.insulator_props` at the group temperature (fallback to `T0`). +""" +function Insulator(component::CableComponent{T}) where {T<:REALSCALAR} + ig = component.insulator_group + temp = component.insulator_props.T0 + return Insulator(ig.radius_in, ig.radius_ext, component.insulator_props, temp) +end + +""" +$(TYPEDSIGNATURES) + +Build a `ConductorGroup` equivalent for a `CableComponent`, preserving +`num_turns` and `num_wires` from the original group. + +Constructs a single-layer `ConductorGroup{T}` from the computed equivalent +`Tubular(component)`, but carries over bookkeeping fields needed by downstream +corrections (e.g., solenoid correction using `num_turns`). +""" +function ConductorGroup(component::CableComponent{T}) where {T<:REALSCALAR} + orig = component.conductor_group + t = Tubular(component) + return ConductorGroup{T}( + t.radius_in, + t.radius_ext, + t.cross_section, + orig.num_wires, + orig.num_turns, + t.resistance, + t.material_props.alpha, + t.gmr, + AbstractConductorPart{T}[t], + ) +end + +""" +$(TYPEDSIGNATURES) + +Build an `InsulatorGroup` equivalent for a `CableComponent` while maintaining +geometric coupling to the equivalent conductor group. + +Stacks a single insulating layer of equivalent material and thickness over the +new conductor group created from the same component. +""" +InsulatorGroup(component::CableComponent{T}) where {T<:REALSCALAR} = InsulatorGroup{T}(Insulator(component)) + + +include("cablecomponent/base.jl") diff --git a/src/datamodel/cablecomponent/base.jl b/src/datamodel/cablecomponent/base.jl index cc639649..4d7eb4e0 100644 --- a/src/datamodel/cablecomponent/base.jl +++ b/src/datamodel/cablecomponent/base.jl @@ -1,101 +1,101 @@ - -Base.eltype(::CableComponent{T}) where {T} = T -Base.eltype(::Type{CableComponent{T}}) where {T} = T - -""" -$(TYPEDSIGNATURES) - -Defines the display representation of a [`CableComponent`](@ref) object for REPL or text output. - -# Arguments - -- `io`: Output stream. -- `::MIME"text/plain"`: MIME type for plain text output. -- `component`: The [`CableComponent`](@ref) object to be displayed. - -# Returns - -- Nothing. Modifies `io` by writing text representation of the object. -""" -function Base.show(io::IO, ::MIME"text/plain", component::CableComponent) - # Calculate total number of parts across both groups - total_parts = - length(component.conductor_group.layers) + length(component.insulator_group.layers) - - # Print header - println(io, "$(total_parts)-element CableComponent \"$(component.id)\":") - - # Display conductor group parts in a tree structure - print(io, "├─ $(length(component.conductor_group.layers))-element ConductorGroup: [") - _print_fields( - io, - component.conductor_group, - [:radius_in, :radius_ext, :cross_section, :resistance, :gmr], - ) - println(io, "]") - print(io, "│ ", "├─", " Effective properties: [") - _print_fields(io, component.conductor_props, [:rho, :eps_r, :mu_r, :alpha]) - println(io, "]") - - for (i, part) in enumerate(component.conductor_group.layers) - - prefix = i == length(component.conductor_group.layers) ? "└───" : "├───" - - # Print part information with proper indentation - print(io, "│ ", prefix, " $(nameof(typeof(part))): [") - - # Print each field with proper formatting - _print_fields( - io, - part, - [:radius_in, :radius_ext, :cross_section, :resistance, :gmr], - ) - - println(io, "]") - end - - # Display insulator group parts - if !isempty(component.insulator_group.layers) - print( - io, - "└─ $(length(component.insulator_group.layers))-element InsulatorGroup: [", - ) - _print_fields( - io, - component.insulator_group, - [ - :radius_in, - :radius_ext, - :cross_section, - :shunt_capacitance, - :shunt_conductance, - ], - ) - println(io, "]") - print(io, " ", "├─", " Effective properties: [") - _print_fields(io, component.insulator_props, [:rho, :eps_r, :mu_r, :alpha]) - println(io, "]") - for (i, part) in enumerate(component.insulator_group.layers) - # Determine prefix based on whether it's the last part - prefix = i == length(component.insulator_group.layers) ? "└───" : "├───" - - # Print part information with proper indentation - print(io, " ", prefix, " $(nameof(typeof(part))): [") - - # Print each field with proper formatting - _print_fields( - io, - part, - [ - :radius_in, - :radius_ext, - :cross_section, - :shunt_capacitance, - :shunt_conductance, - ], - ) - - println(io, "]") - end - end + +Base.eltype(::CableComponent{T}) where {T} = T +Base.eltype(::Type{CableComponent{T}}) where {T} = T + +""" +$(TYPEDSIGNATURES) + +Defines the display representation of a [`CableComponent`](@ref) object for REPL or text output. + +# Arguments + +- `io`: Output stream. +- `::MIME"text/plain"`: MIME type for plain text output. +- `component`: The [`CableComponent`](@ref) object to be displayed. + +# Returns + +- Nothing. Modifies `io` by writing text representation of the object. +""" +function Base.show(io::IO, ::MIME"text/plain", component::CableComponent) + # Calculate total number of parts across both groups + total_parts = + length(component.conductor_group.layers) + length(component.insulator_group.layers) + + # Print header + println(io, "$(total_parts)-element CableComponent \"$(component.id)\":") + + # Display conductor group parts in a tree structure + print(io, "├─ $(length(component.conductor_group.layers))-element ConductorGroup: [") + _print_fields( + io, + component.conductor_group, + [:radius_in, :radius_ext, :cross_section, :resistance, :gmr], + ) + println(io, "]") + print(io, "│ ", "├─", " Effective properties: [") + _print_fields(io, component.conductor_props, [:rho, :eps_r, :mu_r, :alpha]) + println(io, "]") + + for (i, part) in enumerate(component.conductor_group.layers) + + prefix = i == length(component.conductor_group.layers) ? "└───" : "├───" + + # Print part information with proper indentation + print(io, "│ ", prefix, " $(nameof(typeof(part))): [") + + # Print each field with proper formatting + _print_fields( + io, + part, + [:radius_in, :radius_ext, :cross_section, :resistance, :gmr], + ) + + println(io, "]") + end + + # Display insulator group parts + if !isempty(component.insulator_group.layers) + print( + io, + "└─ $(length(component.insulator_group.layers))-element InsulatorGroup: [", + ) + _print_fields( + io, + component.insulator_group, + [ + :radius_in, + :radius_ext, + :cross_section, + :shunt_capacitance, + :shunt_conductance, + ], + ) + println(io, "]") + print(io, " ", "├─", " Effective properties: [") + _print_fields(io, component.insulator_props, [:rho, :eps_r, :mu_r, :alpha]) + println(io, "]") + for (i, part) in enumerate(component.insulator_group.layers) + # Determine prefix based on whether it's the last part + prefix = i == length(component.insulator_group.layers) ? "└───" : "├───" + + # Print part information with proper indentation + print(io, " ", prefix, " $(nameof(typeof(part))): [") + + # Print each field with proper formatting + _print_fields( + io, + part, + [ + :radius_in, + :radius_ext, + :cross_section, + :shunt_capacitance, + :shunt_conductance, + ], + ) + + println(io, "]") + end + end end \ No newline at end of file diff --git a/src/datamodel/cabledesign.jl b/src/datamodel/cabledesign.jl index 3441e3de..ccc9d8a6 100644 --- a/src/datamodel/cabledesign.jl +++ b/src/datamodel/cabledesign.jl @@ -1,271 +1,271 @@ - -""" -$(TYPEDEF) - -Represents the design of a cable, including its unique identifier, nominal data, and components. - -$(TYPEDFIELDS) -""" -mutable struct CableDesign{T <: REALSCALAR} - "Unique identifier for the cable design." - cable_id::String - "Informative reference data." - nominal_data::Union{Nothing, NominalData{T}} - "Vector of cable components." - components::Vector{CableComponent{T}} - - @doc """ - $(TYPEDSIGNATURES) - - **Strict numeric kernel**: constructs a `CableDesign{T}` from one component - (typed) and optional nominal data (typed or `nothing`). Assumes all inputs - are already at scalar type `T`. - - # Arguments - - - `cable_id`: Unique identifier for the cable design. - - `component`: Initial [`CableComponent`](@ref) for the design. - - `nominal_data`: Reference data for the cable design. Default: `NominalData()`. - - # Returns - - - A [`CableDesign`](@ref) object with the specified properties. - - # Examples - - ```julia - conductor_group = ConductorGroup(central_conductor) - insulator_group = InsulatorGroup(main_insulator) - component = CableComponent(conductor_group, insulator_group) - design = $(FUNCTIONNAME)("example", component) - ``` - - # See also - - - [`CableComponent`](@ref) - - [`ConductorGroup`](@ref) - - [`InsulatorGroup`](@ref) - """ - @inline function CableDesign{T}( - cable_id::String, - component::CableComponent{T}; - nominal_data::Union{Nothing, NominalData{T}} = nothing, - ) where {T <: REALSCALAR} - new{T}(cable_id, nominal_data, CableComponent{T}[component]) - end - - @inline function CableDesign{T}( - cable_id::String, - components::Vector{CableComponent{T}}; - nominal_data::Union{Nothing, NominalData{T}} = nothing, - ) where {T <: REALSCALAR} - new{T}(cable_id, nominal_data, components) - end -end - -""" -$(TYPEDSIGNATURES) - -**Weakly-typed constructor** that infers the scalar type from the `component` (and nominal data if present), coerces values to that type, and calls the typed kernel. -""" -function CableDesign( - cable_id::String, - component::CableComponent; - nominal_data::NominalData = NominalData(), -) - # Resolve T from component and nominal_data (ignoring `nothing` fields in the latter) - T = resolve_T(component, nominal_data) - - compT = coerce_to_T(component, T) - ndT = coerce_to_T(nominal_data, T) # identity if already T - - return CableDesign{T}(cable_id, compT; nominal_data = ndT) -end - -""" -$(TYPEDSIGNATURES) - -Constructs a [`CableDesign`](@ref) instance **from conductor and insulator groups**. -Convenience wrapper that builds the component with reduced boilerplate. -""" -function CableDesign( - cable_id::String, - conductor_group::ConductorGroup, - insulator_group::InsulatorGroup; - component_id::String = "component1", - nominal_data::NominalData = NominalData(), -) - component = CableComponent(component_id, conductor_group, insulator_group) - return CableDesign(cable_id, component; nominal_data) -end - -function add!(design::CableDesign{T}, component::CableComponent) where {T} - Tnew = resolve_T(design, component) - - if Tnew === T - compT = coerce_to_T(component, T) - if (idx = findfirst(c -> c.id == compT.id, design.components)) !== nothing - @warn "Component with ID '$(compT.id)' already exists and will be overwritten." - design.components[idx] = compT - else - push!(design.components, compT) - end - return design - else - @warn """ - Adding a `$Tnew` component to a `CableDesign{$T}` returns a **promoted** design. - Capture the result: design = add!(design, component) - """ - # promote whole design, then insert coerced component - promoted = coerce_to_T(design, Tnew) - compT = coerce_to_T(component, Tnew) - if (idx = findfirst(c -> c.id == compT.id, promoted.components)) !== nothing - promoted.components[idx] = compT - else - push!(promoted.components, compT) - end - return promoted - end -end - -# --- add!(design, by groups): wraps the above --- -function add!( - design::CableDesign{T}, - component_id::String, - conductor_group::ConductorGroup, - insulator_group::InsulatorGroup, -) where {T} - comp = CableComponent(component_id, conductor_group, insulator_group) - add!(design, comp) # may return the same or a promoted design -end - -""" -$(TYPEDSIGNATURES) - -Builds a simplified [`CableDesign`](@ref) by replacing each component with a -homogeneous equivalent and leverages shorthand constructors: - -- `ConductorGroup(component::CableComponent{T}) = ConductorGroup(Tubular(component))` -- `InsulatorGroup(component::CableComponent{T}) = InsulatorGroup(Insulator(component))` - -The geometry is preserved from the original component, while materials are -derived from the component's effective conductor and insulator properties. -""" -function equivalent( - original_design::CableDesign; - new_id::String = "", -)::CableDesign - - if isempty(original_design.components) - throw(ArgumentError("CableDesign must contain at least one component.")) - end - - # Determine the ID for the new equivalent cable. - equivalent_id = isempty(new_id) ? original_design.cable_id * "_equivalent" : new_id - - equivalent_design = nothing - - for (i, original_component) in enumerate(original_design.components) - - new_cond_group = ConductorGroup(original_component) - new_ins_group = InsulatorGroup(original_component) - - if i == 1 - new_component = - CableComponent(original_component.id, new_cond_group, new_ins_group) - equivalent_design = CableDesign( - equivalent_id, - new_component, - nominal_data = original_design.nominal_data, - ) - else - add!(equivalent_design, original_component.id, new_cond_group, new_ins_group) - end - end - - return equivalent_design -end - -""" -nonsensify(original_design::CableDesign; new_id::String="")::CableDesign - -Recreates a cable design by bulldozing reality into a "simplified" shape -with only the so-called "main" material properties. - -Translation: if you wanted physics, you came to the wrong neighborhood. - -For each component, this abomination does: -- `ConductorGroup(Tubular(...))` with radii stolen from the first and last - conductor layers, and material blindly copied from the first conductor layer. - Because high-fidelity is for losers. - -- `InsulatorGroup(Insulator(...))` spanning from the new conductor outer radius - to the original insulator group's outer radius; material is taken from the - first `Insulator` layer available (or whatever warm body it can find). - -⚠ WARNING: This is *deliberately* nonsensical. It laughs in the face of proper -equivalent property corrections and just slaps the "main" props on like duct tape. -Use only when you don’t give a damn about accuracy and just want something -that looks cable-ish, e.g., never. -""" -function nonsensify( - original_design::CableDesign; - new_id::String = "", -)::CableDesign - - if isempty(original_design.components) - throw(ArgumentError("CableDesign must contain at least one component.")) - end - - # Determine the ID for the new cable. - target_id = isempty(new_id) ? original_design.cable_id * "_nonsense" : new_id - - rebuilt_design = nothing - - for (i, original_component) in enumerate(original_design.components) - # Source data from original component - cg = original_component.conductor_group - ig = original_component.insulator_group - - # Radii from conductor group layers - rin = cg.layers[1].radius_in - rex = cg.layers[end].radius_ext - - # "Main" material props and temperature for conductor from first conductor layer - mat_con = cg.layers[1].material_props - temp_con = cg.layers[1].temperature - - # Build simplified parts and groups - tubular = Tubular(rin, rex, mat_con, temp_con) - new_cond_group = ConductorGroup(tubular) - - ins_rin = new_cond_group.radius_ext # ensure interface matches - ins_rex = ig.radius_ext # keep original outer boundary - - # Pick first Insulator layer in insulator group (skip Semicon); fallback to first layer - idx_ins = findfirst(x -> x isa Insulator, ig.layers) - idx_ins = isnothing(idx_ins) ? 1 : idx_ins - mat_ins = ig.layers[idx_ins].material_props - temp_ins = ig.layers[idx_ins].temperature - - ins = Insulator(ins_rin, ins_rex, mat_ins, temp_ins) - new_ins_group = InsulatorGroup(ins) - - if i == 1 - new_component = - CableComponent(original_component.id, new_cond_group, new_ins_group) - rebuilt_design = CableDesign( - target_id, - new_component, - nominal_data = original_design.nominal_data, - ) - else - add!(rebuilt_design, original_component.id, new_cond_group, new_ins_group) - end - end - - return rebuilt_design -end - -include("cabledesign/base.jl") -include("cabledesign/dataframe.jl") + +""" +$(TYPEDEF) + +Represents the design of a cable, including its unique identifier, nominal data, and components. + +$(TYPEDFIELDS) +""" +mutable struct CableDesign{T <: REALSCALAR} + "Unique identifier for the cable design." + cable_id::String + "Informative reference data." + nominal_data::Union{Nothing, NominalData{T}} + "Vector of cable components." + components::Vector{CableComponent{T}} + + @doc """ + $(TYPEDSIGNATURES) + + **Strict numeric kernel**: constructs a `CableDesign{T}` from one component + (typed) and optional nominal data (typed or `nothing`). Assumes all inputs + are already at scalar type `T`. + + # Arguments + + - `cable_id`: Unique identifier for the cable design. + - `component`: Initial [`CableComponent`](@ref) for the design. + - `nominal_data`: Reference data for the cable design. Default: `NominalData()`. + + # Returns + + - A [`CableDesign`](@ref) object with the specified properties. + + # Examples + + ```julia + conductor_group = ConductorGroup(central_conductor) + insulator_group = InsulatorGroup(main_insulator) + component = CableComponent(conductor_group, insulator_group) + design = $(FUNCTIONNAME)("example", component) + ``` + + # See also + + - [`CableComponent`](@ref) + - [`ConductorGroup`](@ref) + - [`InsulatorGroup`](@ref) + """ + @inline function CableDesign{T}( + cable_id::String, + component::CableComponent{T}; + nominal_data::Union{Nothing, NominalData{T}} = nothing, + ) where {T <: REALSCALAR} + new{T}(cable_id, nominal_data, CableComponent{T}[component]) + end + + @inline function CableDesign{T}( + cable_id::String, + components::Vector{CableComponent{T}}; + nominal_data::Union{Nothing, NominalData{T}} = nothing, + ) where {T <: REALSCALAR} + new{T}(cable_id, nominal_data, components) + end +end + +""" +$(TYPEDSIGNATURES) + +**Weakly-typed constructor** that infers the scalar type from the `component` (and nominal data if present), coerces values to that type, and calls the typed kernel. +""" +function CableDesign( + cable_id::String, + component::CableComponent; + nominal_data::NominalData = NominalData(), +) + # Resolve T from component and nominal_data (ignoring `nothing` fields in the latter) + T = resolve_T(component, nominal_data) + + compT = coerce_to_T(component, T) + ndT = coerce_to_T(nominal_data, T) # identity if already T + + return CableDesign{T}(cable_id, compT; nominal_data = ndT) +end + +""" +$(TYPEDSIGNATURES) + +Constructs a [`CableDesign`](@ref) instance **from conductor and insulator groups**. +Convenience wrapper that builds the component with reduced boilerplate. +""" +function CableDesign( + cable_id::String, + conductor_group::ConductorGroup, + insulator_group::InsulatorGroup; + component_id::String = "component1", + nominal_data::NominalData = NominalData(), +) + component = CableComponent(component_id, conductor_group, insulator_group) + return CableDesign(cable_id, component; nominal_data) +end + +function add!(design::CableDesign{T}, component::CableComponent) where {T} + Tnew = resolve_T(design, component) + + if Tnew === T + compT = coerce_to_T(component, T) + if (idx = findfirst(c -> c.id == compT.id, design.components)) !== nothing + @warn "Component with ID '$(compT.id)' already exists and will be overwritten." + design.components[idx] = compT + else + push!(design.components, compT) + end + return design + else + @warn """ + Adding a `$Tnew` component to a `CableDesign{$T}` returns a **promoted** design. + Capture the result: design = add!(design, component) + """ + # promote whole design, then insert coerced component + promoted = coerce_to_T(design, Tnew) + compT = coerce_to_T(component, Tnew) + if (idx = findfirst(c -> c.id == compT.id, promoted.components)) !== nothing + promoted.components[idx] = compT + else + push!(promoted.components, compT) + end + return promoted + end +end + +# --- add!(design, by groups): wraps the above --- +function add!( + design::CableDesign{T}, + component_id::String, + conductor_group::ConductorGroup, + insulator_group::InsulatorGroup, +) where {T} + comp = CableComponent(component_id, conductor_group, insulator_group) + add!(design, comp) # may return the same or a promoted design +end + +""" +$(TYPEDSIGNATURES) + +Builds a simplified [`CableDesign`](@ref) by replacing each component with a +homogeneous equivalent and leverages shorthand constructors: + +- `ConductorGroup(component::CableComponent{T}) = ConductorGroup(Tubular(component))` +- `InsulatorGroup(component::CableComponent{T}) = InsulatorGroup(Insulator(component))` + +The geometry is preserved from the original component, while materials are +derived from the component's effective conductor and insulator properties. +""" +function equivalent( + original_design::CableDesign; + new_id::String = "", +)::CableDesign + + if isempty(original_design.components) + throw(ArgumentError("CableDesign must contain at least one component.")) + end + + # Determine the ID for the new equivalent cable. + equivalent_id = isempty(new_id) ? original_design.cable_id * "_equivalent" : new_id + + equivalent_design = nothing + + for (i, original_component) in enumerate(original_design.components) + + new_cond_group = ConductorGroup(original_component) + new_ins_group = InsulatorGroup(original_component) + + if i == 1 + new_component = + CableComponent(original_component.id, new_cond_group, new_ins_group) + equivalent_design = CableDesign( + equivalent_id, + new_component, + nominal_data = original_design.nominal_data, + ) + else + add!(equivalent_design, original_component.id, new_cond_group, new_ins_group) + end + end + + return equivalent_design +end + +""" +nonsensify(original_design::CableDesign; new_id::String="")::CableDesign + +Recreates a cable design by bulldozing reality into a "simplified" shape +with only the so-called "main" material properties. + +Translation: if you wanted physics, you came to the wrong neighborhood. + +For each component, this abomination does: +- `ConductorGroup(Tubular(...))` with radii stolen from the first and last + conductor layers, and material blindly copied from the first conductor layer. + Because high-fidelity is for losers. + +- `InsulatorGroup(Insulator(...))` spanning from the new conductor outer radius + to the original insulator group's outer radius; material is taken from the + first `Insulator` layer available (or whatever warm body it can find). + +⚠ WARNING: This is *deliberately* nonsensical. It laughs in the face of proper +equivalent property corrections and just slaps the "main" props on like duct tape. +Use only when you don’t give a damn about accuracy and just want something +that looks cable-ish, e.g., never. +""" +function nonsensify( + original_design::CableDesign; + new_id::String = "", +)::CableDesign + + if isempty(original_design.components) + throw(ArgumentError("CableDesign must contain at least one component.")) + end + + # Determine the ID for the new cable. + target_id = isempty(new_id) ? original_design.cable_id * "_nonsense" : new_id + + rebuilt_design = nothing + + for (i, original_component) in enumerate(original_design.components) + # Source data from original component + cg = original_component.conductor_group + ig = original_component.insulator_group + + # Radii from conductor group layers + rin = cg.layers[1].radius_in + rex = cg.layers[end].radius_ext + + # "Main" material props and temperature for conductor from first conductor layer + mat_con = cg.layers[1].material_props + temp_con = cg.layers[1].temperature + + # Build simplified parts and groups + tubular = Tubular(rin, rex, mat_con, temp_con) + new_cond_group = ConductorGroup(tubular) + + ins_rin = new_cond_group.radius_ext # ensure interface matches + ins_rex = ig.radius_ext # keep original outer boundary + + # Pick first Insulator layer in insulator group (skip Semicon); fallback to first layer + idx_ins = findfirst(x -> x isa Insulator, ig.layers) + idx_ins = isnothing(idx_ins) ? 1 : idx_ins + mat_ins = ig.layers[idx_ins].material_props + temp_ins = ig.layers[idx_ins].temperature + + ins = Insulator(ins_rin, ins_rex, mat_ins, temp_ins) + new_ins_group = InsulatorGroup(ins) + + if i == 1 + new_component = + CableComponent(original_component.id, new_cond_group, new_ins_group) + rebuilt_design = CableDesign( + target_id, + new_component, + nominal_data = original_design.nominal_data, + ) + else + add!(rebuilt_design, original_component.id, new_cond_group, new_ins_group) + end + end + + return rebuilt_design +end + +include("cabledesign/base.jl") +include("cabledesign/dataframe.jl") diff --git a/src/datamodel/cabledesign/base.jl b/src/datamodel/cabledesign/base.jl index bb3afc89..e0ab42e4 100644 --- a/src/datamodel/cabledesign/base.jl +++ b/src/datamodel/cabledesign/base.jl @@ -1,116 +1,116 @@ - - -Base.eltype(::CableDesign{T}) where {T} = T -Base.eltype(::Type{CableDesign{T}}) where {T} = T - - -""" -$(TYPEDSIGNATURES) - -Defines the display representation of a [`CableDesign`](@ref) object for REPL or text output. - -# Arguments - -- `io`: Output stream. -- `::MIME"text/plain"`: MIME type for plain text output. -- `design`: The [`CableDesign`](@ref) object to be displayed. - -# Returns - -- Nothing. Modifies `io` by writing text representation of the object. -""" -function Base.show(io::IO, ::MIME"text/plain", design::CableDesign) - # Print header with cable ID and count of components - print(io, "$(length(design.components))-element CableDesign \"$(design.cable_id)\"") - - # Add nominal values if available - nominal_values = [] - if design.nominal_data.resistance !== nothing - push!( - nominal_values, - "resistance=$(round(design.nominal_data.resistance, sigdigits=4))", - ) - end - if design.nominal_data.inductance !== nothing - push!( - nominal_values, - "inductance=$(round(design.nominal_data.inductance, sigdigits=4))", - ) - end - if design.nominal_data.capacitance !== nothing - push!( - nominal_values, - "capacitance=$(round(design.nominal_data.capacitance, sigdigits=4))", - ) - end - - if !isempty(nominal_values) - print(io, ", with nominal values: [", join(nominal_values, ", "), "]") - end - println(io) - - # For each component, display its properties in a tree structure - for (i, component) in enumerate(design.components) - # Determine if this is the last component - is_last_component = i == length(design.components) - - # Determine component prefix and continuation line - component_prefix = is_last_component ? "└─" : "├─" - continuation_line = is_last_component ? " " : "│ " - - # Print component name and header - println(io, component_prefix, " Component \"", component.id, "\":") - - # Display conductor group with combined properties - print(io, continuation_line, "├─ ConductorGroup: [") - - # Combine properties from conductor_group and conductor_props - conductor_props = [ - "radius_in" => component.conductor_group.radius_in, - "radius_ext" => component.conductor_group.radius_ext, - "rho" => component.conductor_props.rho, - "eps_r" => component.conductor_props.eps_r, - "mu_r" => component.conductor_props.mu_r, - "alpha" => component.conductor_props.alpha, - ] - - # Display combined conductor properties - displayed_fields = 0 - for (field, value) in conductor_props - if !(value isa Number && isnan(value)) - if displayed_fields > 0 - print(io, ", ") - end - print(io, "$field=$(round(value, sigdigits=4))") - displayed_fields += 1 - end - end - println(io, "]") - - # Display insulator group with combined properties - print(io, continuation_line, "└─ InsulatorGroup: [") - - # Combine properties from insulator_group and insulator_props - insulator_props = [ - "radius_in" => component.insulator_group.radius_in, - "radius_ext" => component.insulator_group.radius_ext, - "rho" => component.insulator_props.rho, - "eps_r" => component.insulator_props.eps_r, - "mu_r" => component.insulator_props.mu_r, - "alpha" => component.insulator_props.alpha, - ] - - # Display combined insulator properties - displayed_fields = 0 - for (field, value) in insulator_props - if !(value isa Number && isnan(value)) - if displayed_fields > 0 - print(io, ", ") - end - print(io, "$field=$(round(value, sigdigits=4))") - displayed_fields += 1 - end - end - println(io, "]") - end + + +Base.eltype(::CableDesign{T}) where {T} = T +Base.eltype(::Type{CableDesign{T}}) where {T} = T + + +""" +$(TYPEDSIGNATURES) + +Defines the display representation of a [`CableDesign`](@ref) object for REPL or text output. + +# Arguments + +- `io`: Output stream. +- `::MIME"text/plain"`: MIME type for plain text output. +- `design`: The [`CableDesign`](@ref) object to be displayed. + +# Returns + +- Nothing. Modifies `io` by writing text representation of the object. +""" +function Base.show(io::IO, ::MIME"text/plain", design::CableDesign) + # Print header with cable ID and count of components + print(io, "$(length(design.components))-element CableDesign \"$(design.cable_id)\"") + + # Add nominal values if available + nominal_values = [] + if design.nominal_data.resistance !== nothing + push!( + nominal_values, + "resistance=$(round(design.nominal_data.resistance, sigdigits=4))", + ) + end + if design.nominal_data.inductance !== nothing + push!( + nominal_values, + "inductance=$(round(design.nominal_data.inductance, sigdigits=4))", + ) + end + if design.nominal_data.capacitance !== nothing + push!( + nominal_values, + "capacitance=$(round(design.nominal_data.capacitance, sigdigits=4))", + ) + end + + if !isempty(nominal_values) + print(io, ", with nominal values: [", join(nominal_values, ", "), "]") + end + println(io) + + # For each component, display its properties in a tree structure + for (i, component) in enumerate(design.components) + # Determine if this is the last component + is_last_component = i == length(design.components) + + # Determine component prefix and continuation line + component_prefix = is_last_component ? "└─" : "├─" + continuation_line = is_last_component ? " " : "│ " + + # Print component name and header + println(io, component_prefix, " Component \"", component.id, "\":") + + # Display conductor group with combined properties + print(io, continuation_line, "├─ ConductorGroup: [") + + # Combine properties from conductor_group and conductor_props + conductor_props = [ + "radius_in" => component.conductor_group.radius_in, + "radius_ext" => component.conductor_group.radius_ext, + "rho" => component.conductor_props.rho, + "eps_r" => component.conductor_props.eps_r, + "mu_r" => component.conductor_props.mu_r, + "alpha" => component.conductor_props.alpha, + ] + + # Display combined conductor properties + displayed_fields = 0 + for (field, value) in conductor_props + if !(value isa Number && isnan(value)) + if displayed_fields > 0 + print(io, ", ") + end + print(io, "$field=$(round(value, sigdigits=4))") + displayed_fields += 1 + end + end + println(io, "]") + + # Display insulator group with combined properties + print(io, continuation_line, "└─ InsulatorGroup: [") + + # Combine properties from insulator_group and insulator_props + insulator_props = [ + "radius_in" => component.insulator_group.radius_in, + "radius_ext" => component.insulator_group.radius_ext, + "rho" => component.insulator_props.rho, + "eps_r" => component.insulator_props.eps_r, + "mu_r" => component.insulator_props.mu_r, + "alpha" => component.insulator_props.alpha, + ] + + # Display combined insulator properties + displayed_fields = 0 + for (field, value) in insulator_props + if !(value isa Number && isnan(value)) + if displayed_fields > 0 + print(io, ", ") + end + print(io, "$field=$(round(value, sigdigits=4))") + displayed_fields += 1 + end + end + println(io, "]") + end end \ No newline at end of file diff --git a/src/datamodel/cabledesign/dataframe.jl b/src/datamodel/cabledesign/dataframe.jl index 1a226b2b..e6566336 100644 --- a/src/datamodel/cabledesign/dataframe.jl +++ b/src/datamodel/cabledesign/dataframe.jl @@ -1,336 +1,336 @@ -import DataFrames: DataFrame - -""" -$(TYPEDSIGNATURES) - -Extracts and displays data from a [`CableDesign`](@ref). - -# Arguments - -- `design`: A [`CableDesign`](@ref) object to extract data from. -- `format`: Symbol indicating the level of detail: - - `:baseparams`: Basic RLC parameters with nominal value comparison (default). - - `:components`: Component-level equivalent properties. - - `:detailed`: Individual cable part properties with layer-by-layer breakdown. -- `S`: Separation distance between cables \\[m\\] (only used for `:baseparams` format). Default: outermost cable diameter. -- `rho_e`: Resistivity of the earth \\[Ω·m\\] (only used for `:baseparams` format). Default: 100. - -# Returns - -- A `DataFrame` containing the requested cable data in the specified format. - -# Examples - -```julia -# Get basic RLC parameters -data = DataFrame(design) # Default is :baseparams format - -# Get component-level data -comp_data = DataFrame(design, :components) - -# Get detailed part-by-part breakdown -detailed_data = DataFrame(design, :detailed) - -# Specify earth parameters for core calculations -core_data = DataFrame(design, :baseparams, S=0.5, rho_e=150) -``` - -# See also - -- [`CableDesign`](@ref) -- [`calc_tubular_resistance`](@ref) -- [`calc_inductance_trifoil`](@ref) -- [`calc_shunt_capacitance`](@ref) -""" -function DataFrame( - design::CableDesign, - format::Symbol=:baseparams; - S::Union{Nothing,Number}=nothing, - rho_e::Number=100.0, -)::DataFrame - - - - if format == :baseparams - # Core parameters calculation - # Get components from the vector - if length(design.components) < 2 - throw( - ArgumentError( - "At least two components are required for :baseparams format.", - ), - ) - end - - cable_core = design.components[1] - cable_shield = design.components[2] - cable_outer = design.components[end] - - # Determine separation distance if not provided - S = - S === nothing ? - ( - # Check if we need to use insulator or conductor radius - isnan(cable_outer.insulator_group.radius_ext) ? - 2 * cable_outer.conductor_group.radius_ext : - 2 * cable_outer.insulator_group.radius_ext - ) : S - - # Compute R, L, and C using given formulas - mapped to new data structure - # Cable core resistance - R = - calc_tubular_resistance( - cable_core.conductor_group.radius_in, - cable_core.conductor_group.radius_ext, - cable_core.conductor_props.rho, - 0.0, 20.0, 20.0, - ) * 1e3 - - # Inductance calculation - L = - calc_inductance_trifoil( - cable_core.conductor_group.radius_in, - cable_core.conductor_group.radius_ext, - cable_core.conductor_props.rho, - cable_core.conductor_props.mu_r, - cable_shield.conductor_group.radius_in, - cable_shield.conductor_group.radius_ext, - cable_shield.conductor_props.rho, - cable_shield.conductor_props.mu_r, - S, - rho_e=rho_e, - ) * 1e6 - - # Capacitance calculation - C = - calc_shunt_capacitance( - cable_core.conductor_group.radius_ext, - cable_core.insulator_group.radius_ext, - cable_core.insulator_props.eps_r, - ) * 1e6 * 1e3 - - # Prepare nominal values from CableDesign - nominals = [ - design.nominal_data.resistance, - design.nominal_data.inductance, - design.nominal_data.capacitance, - ] - - # Calculate differences - diffs = map(zip([R, L, C], nominals)) do (computed, nominal) - if isnothing(nominal) - return missing - else - return to_nominal(abs(nominal - computed) / nominal * 100) - end - end - - # Compute the comparison DataFrame - data = DataFrame( - parameter=["R [Ω/km]", "L [mH/km]", "C [μF/km]"], - computed=[R, L, C], - nominal=to_nominal.(nominals), - ) - - # Add percent_diff column only for rows with non-nothing nominal values - data[!, "percent_diff"] = diffs - - # Handle measurement bounds if present - has_error_bounds = !(isnan(to_lower(R)) || isnan(to_upper(R))) - if has_error_bounds - data[!, "lower"] = [to_lower(R), to_lower(L), to_lower(C)] - data[!, "upper"] = [to_upper(R), to_upper(L), to_upper(C)] - - # Add compliance column only for rows with non-nothing nominal values - data[!, "in_range?"] = - map(zip(data.nominal, data.lower, data.upper)) do (nom, low, up) - isnothing(nom) ? missing : (nom >= low && nom <= up) - end - end - - elseif format == :components - # Component-level properties - properties = [ - :radius_in_con, - :radius_ext_con, - :rho_con, - :alpha_con, - :mu_con, - :radius_ext_ins, - :eps_ins, - :mu_ins, - :loss_factor_ins, - ] - - # Initialize the DataFrame - data = DataFrame(property=properties) - - # Process each component - now using vector - for component in design.components - # Use component ID as column name - col = component.id - - # For each component, we need to map new structure to old column names - # Calculate loss factor from resistivity - ω = 2 * π * f₀ # Using default frequency - C_eq = component.insulator_group.shunt_capacitance - G_eq = component.insulator_group.shunt_conductance - loss_factor = G_eq / (ω * C_eq) - - # Collect values for each property - mapping from new structure to old property names - new_col = [ - component.conductor_group.radius_in, # radius_in_con - component.conductor_group.radius_ext, # radius_ext_con - component.conductor_props.rho, # rho_con - component.conductor_props.alpha, # alpha_con - component.conductor_props.mu_r, # mu_con - component.insulator_group.radius_ext, # radius_ext_ins - component.insulator_props.eps_r, # eps_ins - component.insulator_props.mu_r, # mu_ins - loss_factor, # loss_factor_ins - ] - - # Add to DataFrame - data[!, col] = new_col - end - - elseif format == :detailed - # Detailed part-by-part breakdown - properties = [ - "type", - "radius_in", - "radius_ext", - "diam_in", - "diam_ext", - "thickness", - "cross_section", - "num_wires", - "resistance", - "alpha", - "gmr", - "gmr/radius", - "shunt_capacitance", - "shunt_conductance", - ] - - # Initialize the DataFrame - data = DataFrame(property=properties) - - # Process each component - for component in design.components - # Handle conductor group layers - for (i, part) in enumerate(component.conductor_group.layers) - # Column name with component ID and layer number - col = lowercase(component.id) * ", cond. layer " * string(i) - - # Collect values for each property - new_col = _extract_part_properties(part, properties) - - # Add to DataFrame - data[!, col] = new_col - end - - # Handle insulator group layers - for (i, part) in enumerate(component.insulator_group.layers) - # Column name with component ID and layer number - col = lowercase(component.id) * ", ins. layer " * string(i) - - # Collect values for each property - new_col = _extract_part_properties(part, properties) - - # Add to DataFrame - data[!, col] = new_col - end - end - else - Base.error( - "Unsupported format: $format. Use :baseparams, :components, or :detailed", - ) - end - - return data -end - -""" -$(TYPEDSIGNATURES) - -Helper function to extract properties from a part for detailed format. - -# Arguments - -- `part`: An instance of [`AbstractCablePart`](@ref) from which to extract properties. -- `properties`: A vector of symbols indicating which properties to extract (not used in the current implementation). - -# Returns - -- A vector containing the extracted properties in the following order: - - `type`: The lowercase string representation of the part's type. - - `radius_in`: The inner radius of the part, if it exists, otherwise `missing`. - - `radius_ext`: The outer radius of the part, if it exists, otherwise `missing`. - - `diameter_in`: The inner diameter of the part (2 * radius_in), if `radius_in` exists, otherwise `missing`. - - `diameter_ext`: The outer diameter of the part (2 * radius_ext), if `radius_ext` exists, otherwise `missing`. - - `thickness`: The difference between `radius_ext` and `radius_in`, if both exist, otherwise `missing`. - - `cross_section`: The cross-sectional area of the part, if it exists, otherwise `missing`. - - `num_wires`: The number of wires in the part, if it exists, otherwise `missing`. - - `resistance`: The resistance of the part, if it exists, otherwise `missing`. - - `alpha`: The temperature coefficient of resistivity of the part or its material, if it exists, otherwise `missing`. - - `gmr`: The geometric mean radius of the part, if it exists, otherwise `missing`. - - `gmr_ratio`: The ratio of `gmr` to `radius_ext`, if both exist, otherwise `missing`. - - `shunt_capacitance`: The shunt capacitance of the part, if it exists, otherwise `missing`. - - `shunt_conductance`: The shunt conductance of the part, if it exists, otherwise `missing`. - -# Notes - -This function is used to create a standardized format for displaying detailed information about cable parts. - -# Examples - -```julia -part = Conductor(...) -properties = [:radius_in, :radius_ext, :resistance] # Example of properties to extract -extracted_properties = _extract_part_properties(part, properties) -println(extracted_properties) -``` -""" -function _extract_part_properties(part, properties) - return [ - lowercase(string(typeof(part))), # type - hasfield(typeof(part), :radius_in) ? - getfield(part, :radius_in) : missing, - hasfield(typeof(part), :radius_ext) ? - getfield(part, :radius_ext) : missing, - hasfield(typeof(part), :radius_in) ? - 2 * getfield(part, :radius_in) : missing, - hasfield(typeof(part), :radius_ext) ? - 2 * getfield(part, :radius_ext) : missing, - hasfield(typeof(part), :radius_ext) && - hasfield(typeof(part), :radius_in) ? - (getfield(part, :radius_ext) - getfield(part, :radius_in)) : - missing, - hasfield(typeof(part), :cross_section) ? - getfield(part, :cross_section) : missing, - hasfield(typeof(part), :num_wires) ? - getfield(part, :num_wires) : missing, - hasfield(typeof(part), :resistance) ? - getfield(part, :resistance) : missing, - hasfield(typeof(part), :alpha) || - ( - hasfield(typeof(part), :material_props) && - hasfield(typeof(getfield(part, :material_props)), :alpha) - ) ? - ( - hasfield(typeof(part), :alpha) ? - getfield(part, :alpha) : - getfield(getfield(part, :material_props), :alpha) - ) : missing, - hasfield(typeof(part), :gmr) ? - getfield(part, :gmr) : missing, - hasfield(typeof(part), :gmr) && - hasfield(typeof(part), :radius_ext) ? - (getfield(part, :gmr) / getfield(part, :radius_ext)) : missing, - hasfield(typeof(part), :shunt_capacitance) ? - getfield(part, :shunt_capacitance) : missing, - hasfield(typeof(part), :shunt_conductance) ? - getfield(part, :shunt_conductance) : missing, - ] -end +import DataFrames: DataFrame + +""" +$(TYPEDSIGNATURES) + +Extracts and displays data from a [`CableDesign`](@ref). + +# Arguments + +- `design`: A [`CableDesign`](@ref) object to extract data from. +- `format`: Symbol indicating the level of detail: + - `:baseparams`: Basic RLC parameters with nominal value comparison (default). + - `:components`: Component-level equivalent properties. + - `:detailed`: Individual cable part properties with layer-by-layer breakdown. +- `S`: Separation distance between cables \\[m\\] (only used for `:baseparams` format). Default: outermost cable diameter. +- `rho_e`: Resistivity of the earth \\[Ω·m\\] (only used for `:baseparams` format). Default: 100. + +# Returns + +- A `DataFrame` containing the requested cable data in the specified format. + +# Examples + +```julia +# Get basic RLC parameters +data = DataFrame(design) # Default is :baseparams format + +# Get component-level data +comp_data = DataFrame(design, :components) + +# Get detailed part-by-part breakdown +detailed_data = DataFrame(design, :detailed) + +# Specify earth parameters for core calculations +core_data = DataFrame(design, :baseparams, S=0.5, rho_e=150) +``` + +# See also + +- [`CableDesign`](@ref) +- [`calc_tubular_resistance`](@ref) +- [`calc_inductance_trifoil`](@ref) +- [`calc_shunt_capacitance`](@ref) +""" +function DataFrame( + design::CableDesign, + format::Symbol=:baseparams; + S::Union{Nothing,Number}=nothing, + rho_e::Number=100.0, +)::DataFrame + + + + if format == :baseparams + # Core parameters calculation + # Get components from the vector + if length(design.components) < 2 + throw( + ArgumentError( + "At least two components are required for :baseparams format.", + ), + ) + end + + cable_core = design.components[1] + cable_shield = design.components[2] + cable_outer = design.components[end] + + # Determine separation distance if not provided + S = + S === nothing ? + ( + # Check if we need to use insulator or conductor radius + isnan(cable_outer.insulator_group.radius_ext) ? + 2 * cable_outer.conductor_group.radius_ext : + 2 * cable_outer.insulator_group.radius_ext + ) : S + + # Compute R, L, and C using given formulas - mapped to new data structure + # Cable core resistance + R = + calc_tubular_resistance( + cable_core.conductor_group.radius_in, + cable_core.conductor_group.radius_ext, + cable_core.conductor_props.rho, + 0.0, 20.0, 20.0, + ) * 1e3 + + # Inductance calculation + L = + calc_inductance_trifoil( + cable_core.conductor_group.radius_in, + cable_core.conductor_group.radius_ext, + cable_core.conductor_props.rho, + cable_core.conductor_props.mu_r, + cable_shield.conductor_group.radius_in, + cable_shield.conductor_group.radius_ext, + cable_shield.conductor_props.rho, + cable_shield.conductor_props.mu_r, + S, + rho_e=rho_e, + ) * 1e6 + + # Capacitance calculation + C = + calc_shunt_capacitance( + cable_core.conductor_group.radius_ext, + cable_core.insulator_group.radius_ext, + cable_core.insulator_props.eps_r, + ) * 1e6 * 1e3 + + # Prepare nominal values from CableDesign + nominals = [ + design.nominal_data.resistance, + design.nominal_data.inductance, + design.nominal_data.capacitance, + ] + + # Calculate differences + diffs = map(zip([R, L, C], nominals)) do (computed, nominal) + if isnothing(nominal) + return missing + else + return to_nominal(abs(nominal - computed) / nominal * 100) + end + end + + # Compute the comparison DataFrame + data = DataFrame( + parameter=["R [Ω/km]", "L [mH/km]", "C [μF/km]"], + computed=[R, L, C], + nominal=to_nominal.(nominals), + ) + + # Add percent_diff column only for rows with non-nothing nominal values + data[!, "percent_diff"] = diffs + + # Handle measurement bounds if present + has_error_bounds = !(isnan(to_lower(R)) || isnan(to_upper(R))) + if has_error_bounds + data[!, "lower"] = [to_lower(R), to_lower(L), to_lower(C)] + data[!, "upper"] = [to_upper(R), to_upper(L), to_upper(C)] + + # Add compliance column only for rows with non-nothing nominal values + data[!, "in_range?"] = + map(zip(data.nominal, data.lower, data.upper)) do (nom, low, up) + isnothing(nom) ? missing : (nom >= low && nom <= up) + end + end + + elseif format == :components + # Component-level properties + properties = [ + :radius_in_con, + :radius_ext_con, + :rho_con, + :alpha_con, + :mu_con, + :radius_ext_ins, + :eps_ins, + :mu_ins, + :loss_factor_ins, + ] + + # Initialize the DataFrame + data = DataFrame(property=properties) + + # Process each component - now using vector + for component in design.components + # Use component ID as column name + col = component.id + + # For each component, we need to map new structure to old column names + # Calculate loss factor from resistivity + ω = 2 * π * f₀ # Using default frequency + C_eq = component.insulator_group.shunt_capacitance + G_eq = component.insulator_group.shunt_conductance + loss_factor = G_eq / (ω * C_eq) + + # Collect values for each property - mapping from new structure to old property names + new_col = [ + component.conductor_group.radius_in, # radius_in_con + component.conductor_group.radius_ext, # radius_ext_con + component.conductor_props.rho, # rho_con + component.conductor_props.alpha, # alpha_con + component.conductor_props.mu_r, # mu_con + component.insulator_group.radius_ext, # radius_ext_ins + component.insulator_props.eps_r, # eps_ins + component.insulator_props.mu_r, # mu_ins + loss_factor, # loss_factor_ins + ] + + # Add to DataFrame + data[!, col] = new_col + end + + elseif format == :detailed + # Detailed part-by-part breakdown + properties = [ + "type", + "radius_in", + "radius_ext", + "diam_in", + "diam_ext", + "thickness", + "cross_section", + "num_wires", + "resistance", + "alpha", + "gmr", + "gmr/radius", + "shunt_capacitance", + "shunt_conductance", + ] + + # Initialize the DataFrame + data = DataFrame(property=properties) + + # Process each component + for component in design.components + # Handle conductor group layers + for (i, part) in enumerate(component.conductor_group.layers) + # Column name with component ID and layer number + col = lowercase(component.id) * ", cond. layer " * string(i) + + # Collect values for each property + new_col = _extract_part_properties(part, properties) + + # Add to DataFrame + data[!, col] = new_col + end + + # Handle insulator group layers + for (i, part) in enumerate(component.insulator_group.layers) + # Column name with component ID and layer number + col = lowercase(component.id) * ", ins. layer " * string(i) + + # Collect values for each property + new_col = _extract_part_properties(part, properties) + + # Add to DataFrame + data[!, col] = new_col + end + end + else + Base.error( + "Unsupported format: $format. Use :baseparams, :components, or :detailed", + ) + end + + return data +end + +""" +$(TYPEDSIGNATURES) + +Helper function to extract properties from a part for detailed format. + +# Arguments + +- `part`: An instance of [`AbstractCablePart`](@ref) from which to extract properties. +- `properties`: A vector of symbols indicating which properties to extract (not used in the current implementation). + +# Returns + +- A vector containing the extracted properties in the following order: + - `type`: The lowercase string representation of the part's type. + - `radius_in`: The inner radius of the part, if it exists, otherwise `missing`. + - `radius_ext`: The outer radius of the part, if it exists, otherwise `missing`. + - `diameter_in`: The inner diameter of the part (2 * radius_in), if `radius_in` exists, otherwise `missing`. + - `diameter_ext`: The outer diameter of the part (2 * radius_ext), if `radius_ext` exists, otherwise `missing`. + - `thickness`: The difference between `radius_ext` and `radius_in`, if both exist, otherwise `missing`. + - `cross_section`: The cross-sectional area of the part, if it exists, otherwise `missing`. + - `num_wires`: The number of wires in the part, if it exists, otherwise `missing`. + - `resistance`: The resistance of the part, if it exists, otherwise `missing`. + - `alpha`: The temperature coefficient of resistivity of the part or its material, if it exists, otherwise `missing`. + - `gmr`: The geometric mean radius of the part, if it exists, otherwise `missing`. + - `gmr_ratio`: The ratio of `gmr` to `radius_ext`, if both exist, otherwise `missing`. + - `shunt_capacitance`: The shunt capacitance of the part, if it exists, otherwise `missing`. + - `shunt_conductance`: The shunt conductance of the part, if it exists, otherwise `missing`. + +# Notes + +This function is used to create a standardized format for displaying detailed information about cable parts. + +# Examples + +```julia +part = Conductor(...) +properties = [:radius_in, :radius_ext, :resistance] # Example of properties to extract +extracted_properties = _extract_part_properties(part, properties) +println(extracted_properties) +``` +""" +function _extract_part_properties(part, properties) + return [ + lowercase(string(typeof(part))), # type + hasfield(typeof(part), :radius_in) ? + getfield(part, :radius_in) : missing, + hasfield(typeof(part), :radius_ext) ? + getfield(part, :radius_ext) : missing, + hasfield(typeof(part), :radius_in) ? + 2 * getfield(part, :radius_in) : missing, + hasfield(typeof(part), :radius_ext) ? + 2 * getfield(part, :radius_ext) : missing, + hasfield(typeof(part), :radius_ext) && + hasfield(typeof(part), :radius_in) ? + (getfield(part, :radius_ext) - getfield(part, :radius_in)) : + missing, + hasfield(typeof(part), :cross_section) ? + getfield(part, :cross_section) : missing, + hasfield(typeof(part), :num_wires) ? + getfield(part, :num_wires) : missing, + hasfield(typeof(part), :resistance) ? + getfield(part, :resistance) : missing, + hasfield(typeof(part), :alpha) || + ( + hasfield(typeof(part), :material_props) && + hasfield(typeof(getfield(part, :material_props)), :alpha) + ) ? + ( + hasfield(typeof(part), :alpha) ? + getfield(part, :alpha) : + getfield(getfield(part, :material_props), :alpha) + ) : missing, + hasfield(typeof(part), :gmr) ? + getfield(part, :gmr) : missing, + hasfield(typeof(part), :gmr) && + hasfield(typeof(part), :radius_ext) ? + (getfield(part, :gmr) / getfield(part, :radius_ext)) : missing, + hasfield(typeof(part), :shunt_capacitance) ? + getfield(part, :shunt_capacitance) : missing, + hasfield(typeof(part), :shunt_conductance) ? + getfield(part, :shunt_conductance) : missing, + ] +end diff --git a/src/datamodel/cableslibrary.jl b/src/datamodel/cableslibrary.jl index 7570ddd6..b1c3dc73 100644 --- a/src/datamodel/cableslibrary.jl +++ b/src/datamodel/cableslibrary.jl @@ -1,82 +1,82 @@ -""" -$(TYPEDEF) - -Represents a library of cable designs stored as a dictionary. - -$(TYPEDFIELDS) -""" -mutable struct CablesLibrary - "Dictionary mapping cable IDs to the respective CableDesign objects." - data::Dict{String,CableDesign} - - @doc """ - $(TYPEDSIGNATURES) - - Constructs an empty [`CablesLibrary`](@ref) instance. - - # Arguments - - - None. - - # Returns - - - A [`CablesLibrary`](@ref) object with an empty dictionary of cable designs. - - # Examples - - ```julia - # Create a new, empty library - library = $(FUNCTIONNAME)() - ``` - - # See also - - - [`CableDesign`](@ref) - - [`add!`](@ref) - - [`delete!`](@ref) - - [`LineCableModels.ImportExport.save`](@ref) - - [`DataFrame`](@ref) - """ - function CablesLibrary()::CablesLibrary - library = new(Dict{String,CableDesign}()) - @info "Initializing empty cables database..." - return library - end -end - - -""" -Stores a cable design in a [`CablesLibrary`](@ref) object. - -# Arguments - -- `library`: An instance of [`CablesLibrary`](@ref) to which the cable design will be added. -- `design`: A [`CableDesign`](@ref) object representing the cable design to be added. This object must have a `cable_id` field to uniquely identify it. - -# Returns - -- None. Modifies the `data` field of the [`CablesLibrary`](@ref) object in-place by adding the new cable design. - -# Examples -```julia -library = CablesLibrary() -design = CableDesign("example", ...) # Initialize CableDesign with required fields -add!(library, design) -println(library) # Prints the updated dictionary containing the new cable design -``` -# See also - -- [`CablesLibrary`](@ref) -- [`CableDesign`](@ref) -- [`delete!`](@ref) -""" -function add!(library::CablesLibrary, design::CableDesign) - library.data[design.cable_id] = design - @info "Cable design with ID `$(design.cable_id)` added to the library." - library -end - -include("cableslibrary/base.jl") -include("cableslibrary/dataframe.jl") - - +""" +$(TYPEDEF) + +Represents a library of cable designs stored as a dictionary. + +$(TYPEDFIELDS) +""" +mutable struct CablesLibrary + "Dictionary mapping cable IDs to the respective CableDesign objects." + data::Dict{String,CableDesign} + + @doc """ + $(TYPEDSIGNATURES) + + Constructs an empty [`CablesLibrary`](@ref) instance. + + # Arguments + + - None. + + # Returns + + - A [`CablesLibrary`](@ref) object with an empty dictionary of cable designs. + + # Examples + + ```julia + # Create a new, empty library + library = $(FUNCTIONNAME)() + ``` + + # See also + + - [`CableDesign`](@ref) + - [`add!`](@ref) + - [`delete!`](@ref) + - [`LineCableModels.ImportExport.save`](@ref) + - [`DataFrame`](@ref) + """ + function CablesLibrary()::CablesLibrary + library = new(Dict{String,CableDesign}()) + @info "Initializing empty cables database..." + return library + end +end + + +""" +Stores a cable design in a [`CablesLibrary`](@ref) object. + +# Arguments + +- `library`: An instance of [`CablesLibrary`](@ref) to which the cable design will be added. +- `design`: A [`CableDesign`](@ref) object representing the cable design to be added. This object must have a `cable_id` field to uniquely identify it. + +# Returns + +- None. Modifies the `data` field of the [`CablesLibrary`](@ref) object in-place by adding the new cable design. + +# Examples +```julia +library = CablesLibrary() +design = CableDesign("example", ...) # Initialize CableDesign with required fields +add!(library, design) +println(library) # Prints the updated dictionary containing the new cable design +``` +# See also + +- [`CablesLibrary`](@ref) +- [`CableDesign`](@ref) +- [`delete!`](@ref) +""" +function add!(library::CablesLibrary, design::CableDesign) + library.data[design.cable_id] = design + @info "Cable design with ID `$(design.cable_id)` added to the library." + library +end + +include("cableslibrary/base.jl") +include("cableslibrary/dataframe.jl") + + diff --git a/src/datamodel/cableslibrary/base.jl b/src/datamodel/cableslibrary/base.jl index 02341d1d..532ce525 100644 --- a/src/datamodel/cableslibrary/base.jl +++ b/src/datamodel/cableslibrary/base.jl @@ -1,97 +1,97 @@ - -# Implement the AbstractDict interface -Base.length(lib::CablesLibrary) = length(lib.data) -Base.setindex!(lib::CablesLibrary, value::CableDesign, key::String) = (lib.data[key] = value) -Base.iterate(lib::CablesLibrary, state...) = iterate(lib.data, state...) -Base.keys(lib::CablesLibrary) = keys(lib.data) -Base.values(lib::CablesLibrary) = values(lib.data) -Base.haskey(lib::CablesLibrary, key::String) = haskey(lib.data, key) -Base.getindex(lib::CablesLibrary, key::String) = getindex(lib.data, key) - -""" -$(TYPEDSIGNATURES) - -Retrieves a cable design from a [`CablesLibrary`](@ref) object by its ID. - -# Arguments - -- `library`: An instance of [`CablesLibrary`](@ref) from which the cable design will be retrieved. -- `cable_id`: The ID of the cable design to retrieve. - -# Returns - -- A [`CableDesign`](@ref) object corresponding to the given `cable_id` if found, otherwise `nothing`. - -# Examples - -```julia -library = CablesLibrary() -design = CableDesign("example", ...) # Initialize a CableDesign -add!(library, design) - -# Retrieve the cable design -retrieved_design = $(FUNCTIONNAME)(library, "cable1") -println(retrieved_design.id) # Prints "example" - -# Attempt to retrieve a non-existent design -missing_design = $(FUNCTIONNAME)(library, "nonexistent_id") -println(missing_design === nothing) # Prints true -``` - -# See also - -- [`CablesLibrary`](@ref) -- [`CableDesign`](@ref) -- [`add!`](@ref) -- [`delete!`](@ref) -""" -function Base.get(library::CablesLibrary, cable_id::String, default=nothing) - if haskey(library, cable_id) - @info "Cable design with ID `$cable_id` loaded from the library." - return library[cable_id] - else - @warn "Cable design with ID `$cable_id` not found in the library; returning default." - return default - end -end - -""" -$(TYPEDSIGNATURES) - -Removes a cable design from a [`CablesLibrary`](@ref) object by its ID. - -# Arguments - -- `library`: An instance of [`CablesLibrary`](@ref) from which the cable design will be removed. -- `cable_id`: The ID of the cable design to remove. - -# Returns - -- Nothing. Modifies the `data` field of the [`CablesLibrary`](@ref) object in-place by removing the specified cable design if it exists. - -# Examples - -```julia -library = CablesLibrary() -design = CableDesign("example", ...) # Initialize a CableDesign -add!(library, design) - -# Remove the cable design -$(FUNCTIONNAME)(library, "example") -haskey(library, "example") # Returns false -``` - -# See also - -- [`CablesLibrary`](@ref) -- [`add!`](@ref) -""" -function Base.delete!(library::CablesLibrary, cable_id::String) - if haskey(library, cable_id) - delete!(library.data, cable_id) - @info "Cable design with ID `$cable_id` removed from the library." - else - @error "Cable design with ID `$cable_id` not found in the library; cannot delete." - throw(KeyError(cable_id)) - end + +# Implement the AbstractDict interface +Base.length(lib::CablesLibrary) = length(lib.data) +Base.setindex!(lib::CablesLibrary, value::CableDesign, key::String) = (lib.data[key] = value) +Base.iterate(lib::CablesLibrary, state...) = iterate(lib.data, state...) +Base.keys(lib::CablesLibrary) = keys(lib.data) +Base.values(lib::CablesLibrary) = values(lib.data) +Base.haskey(lib::CablesLibrary, key::String) = haskey(lib.data, key) +Base.getindex(lib::CablesLibrary, key::String) = getindex(lib.data, key) + +""" +$(TYPEDSIGNATURES) + +Retrieves a cable design from a [`CablesLibrary`](@ref) object by its ID. + +# Arguments + +- `library`: An instance of [`CablesLibrary`](@ref) from which the cable design will be retrieved. +- `cable_id`: The ID of the cable design to retrieve. + +# Returns + +- A [`CableDesign`](@ref) object corresponding to the given `cable_id` if found, otherwise `nothing`. + +# Examples + +```julia +library = CablesLibrary() +design = CableDesign("example", ...) # Initialize a CableDesign +add!(library, design) + +# Retrieve the cable design +retrieved_design = $(FUNCTIONNAME)(library, "cable1") +println(retrieved_design.id) # Prints "example" + +# Attempt to retrieve a non-existent design +missing_design = $(FUNCTIONNAME)(library, "nonexistent_id") +println(missing_design === nothing) # Prints true +``` + +# See also + +- [`CablesLibrary`](@ref) +- [`CableDesign`](@ref) +- [`add!`](@ref) +- [`delete!`](@ref) +""" +function Base.get(library::CablesLibrary, cable_id::String, default=nothing) + if haskey(library, cable_id) + @info "Cable design with ID `$cable_id` loaded from the library." + return library[cable_id] + else + @warn "Cable design with ID `$cable_id` not found in the library; returning default." + return default + end +end + +""" +$(TYPEDSIGNATURES) + +Removes a cable design from a [`CablesLibrary`](@ref) object by its ID. + +# Arguments + +- `library`: An instance of [`CablesLibrary`](@ref) from which the cable design will be removed. +- `cable_id`: The ID of the cable design to remove. + +# Returns + +- Nothing. Modifies the `data` field of the [`CablesLibrary`](@ref) object in-place by removing the specified cable design if it exists. + +# Examples + +```julia +library = CablesLibrary() +design = CableDesign("example", ...) # Initialize a CableDesign +add!(library, design) + +# Remove the cable design +$(FUNCTIONNAME)(library, "example") +haskey(library, "example") # Returns false +``` + +# See also + +- [`CablesLibrary`](@ref) +- [`add!`](@ref) +""" +function Base.delete!(library::CablesLibrary, cable_id::String) + if haskey(library, cable_id) + delete!(library.data, cable_id) + @info "Cable design with ID `$cable_id` removed from the library." + else + @error "Cable design with ID `$cable_id` not found in the library; cannot delete." + throw(KeyError(cable_id)) + end end \ No newline at end of file diff --git a/src/datamodel/cableslibrary/dataframe.jl b/src/datamodel/cableslibrary/dataframe.jl index 8f755020..1dc92354 100644 --- a/src/datamodel/cableslibrary/dataframe.jl +++ b/src/datamodel/cableslibrary/dataframe.jl @@ -1,52 +1,52 @@ -import DataFrames: DataFrame - -""" -$(TYPEDSIGNATURES) - -Lists the cable designs in a [`CablesLibrary`](@ref) object as a `DataFrame`. - -# Arguments - -- `library`: An instance of [`CablesLibrary`](@ref) whose cable designs are to be displayed. - -# Returns - -- A `DataFrame` object with the following columns: - - `cable_id`: The unique identifier for each cable design. - - `nominal_data`: A string representation of the nominal data for each cable design. - - `components`: A comma-separated string listing the components of each cable design. - -# Examples - -```julia -library = CablesLibrary() -design1 = CableDesign("example1", nominal_data=NominalData(...), components=Dict("A"=>...)) -design2 = CableDesign("example2", nominal_data=NominalData(...), components=Dict("C"=>...)) -add!(library, design1) -add!(library, design2) - -# Display the library as a DataFrame -df = $(FUNCTIONNAME)(library) -first(df, 5) # Show the first 5 rows of the DataFrame -``` - -# See also - -- [`CablesLibrary`](@ref) -- [`CableDesign`](@ref) -- [`add!`](@ref) -""" -function DataFrame(library::CablesLibrary)::DataFrame - ids = keys(library) - nominal_data = [string(design.nominal_data) for design in values(library)] - components = [ - join([comp.id for comp in design.components], ", ") for - design in values(library) - ] - df = DataFrame( - cable_id=collect(ids), - nominal_data=nominal_data, - components=components, - ) - return (df) +import DataFrames: DataFrame + +""" +$(TYPEDSIGNATURES) + +Lists the cable designs in a [`CablesLibrary`](@ref) object as a `DataFrame`. + +# Arguments + +- `library`: An instance of [`CablesLibrary`](@ref) whose cable designs are to be displayed. + +# Returns + +- A `DataFrame` object with the following columns: + - `cable_id`: The unique identifier for each cable design. + - `nominal_data`: A string representation of the nominal data for each cable design. + - `components`: A comma-separated string listing the components of each cable design. + +# Examples + +```julia +library = CablesLibrary() +design1 = CableDesign("example1", nominal_data=NominalData(...), components=Dict("A"=>...)) +design2 = CableDesign("example2", nominal_data=NominalData(...), components=Dict("C"=>...)) +add!(library, design1) +add!(library, design2) + +# Display the library as a DataFrame +df = $(FUNCTIONNAME)(library) +first(df, 5) # Show the first 5 rows of the DataFrame +``` + +# See also + +- [`CablesLibrary`](@ref) +- [`CableDesign`](@ref) +- [`add!`](@ref) +""" +function DataFrame(library::CablesLibrary)::DataFrame + ids = keys(library) + nominal_data = [string(design.nominal_data) for design in values(library)] + components = [ + join([comp.id for comp in design.components], ", ") for + design in values(library) + ] + df = DataFrame( + cable_id=collect(ids), + nominal_data=nominal_data, + components=components, + ) + return (df) end \ No newline at end of file diff --git a/src/datamodel/conductorgroup.jl b/src/datamodel/conductorgroup.jl index 17e86bf1..441c2548 100644 --- a/src/datamodel/conductorgroup.jl +++ b/src/datamodel/conductorgroup.jl @@ -1,224 +1,224 @@ -""" -$(TYPEDEF) - -Represents a composite conductor group assembled from multiple conductive layers or stranded wires. - -This structure serves as a container for different [`AbstractConductorPart`](@ref) elements -(such as wire arrays, strips, and tubular conductors) arranged in concentric layers. -The `ConductorGroup` aggregates these individual parts and provides equivalent electrical -properties that represent the composite behavior of the entire assembly. - -# Attributes - -$(TYPEDFIELDS) -""" -mutable struct ConductorGroup{T<:REALSCALAR} <: AbstractConductorPart{T} - "Inner radius of the conductor group \\[m\\]." - radius_in::T - "Outer radius of the conductor group \\[m\\]." - radius_ext::T - "Cross-sectional area of the entire conductor group \\[m²\\]." - cross_section::T - "Number of individual wires in the conductor group \\[dimensionless\\]." - num_wires::Int - "Number of turns per meter of each wire strand \\[1/m\\]." - num_turns::T - "DC resistance of the conductor group \\[Ω\\]." - resistance::T - "Temperature coefficient of resistance \\[1/°C\\]." - alpha::T - "Geometric mean radius of the conductor group \\[m\\]." - gmr::T - "Vector of conductor layer components." - layers::Vector{AbstractConductorPart{T}} - - @doc """ - $(TYPEDSIGNATURES) - - Constructs a [`ConductorGroup`](@ref) instance initializing with the central conductor part. - - # Arguments - - - `central_conductor`: An [`AbstractConductorPart`](@ref) object located at the center of the conductor group. - - # Returns - - - A [`ConductorGroup`](@ref) object initialized with geometric and electrical properties derived from the central conductor. - """ - function ConductorGroup{T}( - radius_in::T, - radius_ext::T, - cross_section::T, - num_wires::Int, - num_turns::T, - resistance::T, - alpha::T, - gmr::T, - layers::Vector{AbstractConductorPart{T}}, - ) where {T} - return new{T}(radius_in, radius_ext, cross_section, num_wires, num_turns, - resistance, alpha, gmr, layers) - end - - function ConductorGroup{T}(central::AbstractConductorPart{T}) where {T} - num_wires::Int = 0 - num_turns::T = zero(T) - - # only touch fields that exist inside the guarded branches - if central isa WireArray{T} - num_wires = central.num_wires - num_turns = central.pitch_length > zero(T) ? one(T) / central.pitch_length : zero(T) - elseif central isa Strip{T} - num_wires = 1 - num_turns = central.pitch_length > zero(T) ? one(T) / central.pitch_length : zero(T) - end - - return new{T}( - central.radius_in, - central.radius_ext, - central.cross_section, - num_wires, - num_turns, - central.resistance, - central.material_props.alpha, - central.gmr, - AbstractConductorPart{T}[central], - ) - end -end - - - -# Outer helper that infers T from the central part -ConductorGroup(con::AbstractConductorPart{T}) where {T} = ConductorGroup{T}(con) - -""" -$(TYPEDSIGNATURES) - -Add a new conductor part to a [`ConductorGroup`](@ref), validating raw inputs, -normalizing proxies, and **promoting** the group’s numeric type if required. - -# Behavior: - -1. Apply part-level keyword defaults. -2. Default `radius_in` to `group.radius_ext` if absent. -3. Compute `Tnew = resolve_T(group, radius_in, args..., values(kwargs)...)`. -4. If `Tnew === T`, mutate in place; else `coerce_to_T(group, Tnew)` then mutate and **return the promoted group**. - -# Arguments - -- `group`: [`ConductorGroup`](@ref) object to which the new part will be added. -- `part_type`: Type of conductor part to add ([`AbstractConductorPart`](@ref)). -- `args...`: Positional arguments specific to the constructor of the `part_type` ([`AbstractConductorPart`](@ref)) \\[various\\]. -- `kwargs...`: Named arguments for the constructor including optional values specific to the constructor of the `part_type` ([`AbstractConductorPart`](@ref)) \\[various\\]. - -# Returns - -- The function modifies the [`ConductorGroup`](@ref) instance in place and does not return a value. - -# Notes - -- Updates `gmr`, `resistance`, `alpha`, `radius_ext`, `cross_section`, and `num_wires` to account for the new part. -- The `temperature` of the new part defaults to the temperature of the first layer if not specified. -- The `radius_in` of the new part defaults to the external radius of the existing conductor if not specified. - -!!! warning "Note" - - When an [`AbstractCablePart`](@ref) is provided as `radius_in`, the constructor retrieves its `radius_ext` value, allowing the new cable part to be placed directly over the existing part in a layered cable design. - - In case of uncertain measurements, if the added cable part is of a different type than the existing one, the uncertainty is removed from the radius value before being passed to the new component. This ensures that measurement uncertainties do not inappropriately cascade across different cable parts. - -# Examples - -```julia -material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) -conductor = ConductorGroup(Strip(0.01, 0.002, 0.05, 10, material_props)) -$(FUNCTIONNAME)(conductor, WireArray, 0.02, 0.002, 7, 15, material_props, temperature = 25) -``` - -# See also - -- [`ConductorGroup`](@ref) -- [`WireArray`](@ref) -- [`Strip`](@ref) -- [`Tubular`](@ref) -- [`calc_equivalent_gmr`](@ref) -- [`calc_parallel_equivalent`](@ref) -- [`calc_equivalent_alpha`](@ref) -""" -function add!( - group::ConductorGroup{T}, - part_type::Type{C}, - args...; - kwargs... -) where {T,C<:AbstractConductorPart} - - # 1) Merge declared keyword defaults for this part type - kwv = _with_kwdefaults(C, (; kwargs...)) - - # 2) Default stacking: inner radius = current outer radius unless overridden - rin = get(kwv, :radius_in, group.radius_ext) - kwv = haskey(kwv, :radius_in) ? kwv : merge(kwv, (; radius_in=rin)) - - # 3) Decide target numeric type using *current group + raw inputs* - Tnew = resolve_T(group, rin, args..., values(kwv)...) - - if Tnew === T - # 4a) Fast path: mutate in place - return _do_add!(group, C, args...; kwv...) - else - @warn """ - Adding a `$Tnew` part to a `ConductorGroup{$T}` returns a **promoted** group. - Capture the result: group = add!(group, $C, …) - """ - promoted = coerce_to_T(group, Tnew) - return _do_add!(promoted, C, args...; kwv...) - end -end - -""" -$(TYPEDSIGNATURES) - -Internal, in-place insertion (no promotion logic). Assumes `:radius_in` was materialized. -Runs Validation → parsing, then coerces fields to the group’s `T` and updates -equivalent properties and book-keeping. -""" -function _do_add!( - group::ConductorGroup{Tg}, - C::Type{<:AbstractConductorPart}, - args...; - kwargs... -) where {Tg} - # Materialize keyword args into a NamedTuple (never poke Base.Pairs internals) - kw = (; kwargs...) - - # Validate + parse with the part’s own pipeline (proxies resolved here) - ntv = Validation.validate!(C, kw.radius_in, args...; kw...) - - # Coerce validated values to group’s T and call strict numeric core - order = (Validation.required_fields(C)..., Validation.keyword_fields(C)...) - coerced = _coerced_args(C, ntv, Tg, order) # respects coercive_fields(C) - new_part = C(coerced...) - - # Update equivalent properties - group.gmr = calc_equivalent_gmr(group, new_part) - group.alpha = calc_equivalent_alpha(group.alpha, group.resistance, - new_part.material_props.alpha, - new_part.resistance) - group.resistance = calc_parallel_equivalent(group.resistance, new_part.resistance) - group.radius_ext += (new_part.radius_ext - new_part.radius_in) - group.cross_section += new_part.cross_section - - # WireArray / Strip bookkeeping - if new_part isa WireArray || new_part isa Strip - old_wires = group.num_wires - old_turns = group.num_turns - nw = new_part isa WireArray ? new_part.num_wires : 1 - nt = new_part.pitch_length > 0 ? inv(new_part.pitch_length) : zero(Tg) - group.num_wires += nw - group.num_turns = (old_wires * old_turns + nw * nt) / group.num_wires - end - - push!(group.layers, new_part) - return group -end - +""" +$(TYPEDEF) + +Represents a composite conductor group assembled from multiple conductive layers or stranded wires. + +This structure serves as a container for different [`AbstractConductorPart`](@ref) elements +(such as wire arrays, strips, and tubular conductors) arranged in concentric layers. +The `ConductorGroup` aggregates these individual parts and provides equivalent electrical +properties that represent the composite behavior of the entire assembly. + +# Attributes + +$(TYPEDFIELDS) +""" +mutable struct ConductorGroup{T<:REALSCALAR} <: AbstractConductorPart{T} + "Inner radius of the conductor group \\[m\\]." + radius_in::T + "Outer radius of the conductor group \\[m\\]." + radius_ext::T + "Cross-sectional area of the entire conductor group \\[m²\\]." + cross_section::T + "Number of individual wires in the conductor group \\[dimensionless\\]." + num_wires::Int + "Number of turns per meter of each wire strand \\[1/m\\]." + num_turns::T + "DC resistance of the conductor group \\[Ω\\]." + resistance::T + "Temperature coefficient of resistance \\[1/°C\\]." + alpha::T + "Geometric mean radius of the conductor group \\[m\\]." + gmr::T + "Vector of conductor layer components." + layers::Vector{AbstractConductorPart{T}} + + @doc """ + $(TYPEDSIGNATURES) + + Constructs a [`ConductorGroup`](@ref) instance initializing with the central conductor part. + + # Arguments + + - `central_conductor`: An [`AbstractConductorPart`](@ref) object located at the center of the conductor group. + + # Returns + + - A [`ConductorGroup`](@ref) object initialized with geometric and electrical properties derived from the central conductor. + """ + function ConductorGroup{T}( + radius_in::T, + radius_ext::T, + cross_section::T, + num_wires::Int, + num_turns::T, + resistance::T, + alpha::T, + gmr::T, + layers::Vector{AbstractConductorPart{T}}, + ) where {T} + return new{T}(radius_in, radius_ext, cross_section, num_wires, num_turns, + resistance, alpha, gmr, layers) + end + + function ConductorGroup{T}(central::AbstractConductorPart{T}) where {T} + num_wires::Int = 0 + num_turns::T = zero(T) + + # only touch fields that exist inside the guarded branches + if central isa WireArray{T} + num_wires = central.num_wires + num_turns = central.pitch_length > zero(T) ? one(T) / central.pitch_length : zero(T) + elseif central isa Strip{T} + num_wires = 1 + num_turns = central.pitch_length > zero(T) ? one(T) / central.pitch_length : zero(T) + end + + return new{T}( + central.radius_in, + central.radius_ext, + central.cross_section, + num_wires, + num_turns, + central.resistance, + central.material_props.alpha, + central.gmr, + AbstractConductorPart{T}[central], + ) + end +end + + + +# Outer helper that infers T from the central part +ConductorGroup(con::AbstractConductorPart{T}) where {T} = ConductorGroup{T}(con) + +""" +$(TYPEDSIGNATURES) + +Add a new conductor part to a [`ConductorGroup`](@ref), validating raw inputs, +normalizing proxies, and **promoting** the group’s numeric type if required. + +# Behavior: + +1. Apply part-level keyword defaults. +2. Default `radius_in` to `group.radius_ext` if absent. +3. Compute `Tnew = resolve_T(group, radius_in, args..., values(kwargs)...)`. +4. If `Tnew === T`, mutate in place; else `coerce_to_T(group, Tnew)` then mutate and **return the promoted group**. + +# Arguments + +- `group`: [`ConductorGroup`](@ref) object to which the new part will be added. +- `part_type`: Type of conductor part to add ([`AbstractConductorPart`](@ref)). +- `args...`: Positional arguments specific to the constructor of the `part_type` ([`AbstractConductorPart`](@ref)) \\[various\\]. +- `kwargs...`: Named arguments for the constructor including optional values specific to the constructor of the `part_type` ([`AbstractConductorPart`](@ref)) \\[various\\]. + +# Returns + +- The function modifies the [`ConductorGroup`](@ref) instance in place and does not return a value. + +# Notes + +- Updates `gmr`, `resistance`, `alpha`, `radius_ext`, `cross_section`, and `num_wires` to account for the new part. +- The `temperature` of the new part defaults to the temperature of the first layer if not specified. +- The `radius_in` of the new part defaults to the external radius of the existing conductor if not specified. + +!!! warning "Note" + - When an [`AbstractCablePart`](@ref) is provided as `radius_in`, the constructor retrieves its `radius_ext` value, allowing the new cable part to be placed directly over the existing part in a layered cable design. + - In case of uncertain measurements, if the added cable part is of a different type than the existing one, the uncertainty is removed from the radius value before being passed to the new component. This ensures that measurement uncertainties do not inappropriately cascade across different cable parts. + +# Examples + +```julia +material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) +conductor = ConductorGroup(Strip(0.01, 0.002, 0.05, 10, material_props)) +$(FUNCTIONNAME)(conductor, WireArray, 0.02, 0.002, 7, 15, material_props, temperature = 25) +``` + +# See also + +- [`ConductorGroup`](@ref) +- [`WireArray`](@ref) +- [`Strip`](@ref) +- [`Tubular`](@ref) +- [`calc_equivalent_gmr`](@ref) +- [`calc_parallel_equivalent`](@ref) +- [`calc_equivalent_alpha`](@ref) +""" +function add!( + group::ConductorGroup{T}, + part_type::Type{C}, + args...; + kwargs... +) where {T,C<:AbstractConductorPart} + + # 1) Merge declared keyword defaults for this part type + kwv = _with_kwdefaults(C, (; kwargs...)) + + # 2) Default stacking: inner radius = current outer radius unless overridden + rin = get(kwv, :radius_in, group.radius_ext) + kwv = haskey(kwv, :radius_in) ? kwv : merge(kwv, (; radius_in=rin)) + + # 3) Decide target numeric type using *current group + raw inputs* + Tnew = resolve_T(group, rin, args..., values(kwv)...) + + if Tnew === T + # 4a) Fast path: mutate in place + return _do_add!(group, C, args...; kwv...) + else + @warn """ + Adding a `$Tnew` part to a `ConductorGroup{$T}` returns a **promoted** group. + Capture the result: group = add!(group, $C, …) + """ + promoted = coerce_to_T(group, Tnew) + return _do_add!(promoted, C, args...; kwv...) + end +end + +""" +$(TYPEDSIGNATURES) + +Internal, in-place insertion (no promotion logic). Assumes `:radius_in` was materialized. +Runs Validation → parsing, then coerces fields to the group’s `T` and updates +equivalent properties and book-keeping. +""" +function _do_add!( + group::ConductorGroup{Tg}, + C::Type{<:AbstractConductorPart}, + args...; + kwargs... +) where {Tg} + # Materialize keyword args into a NamedTuple (never poke Base.Pairs internals) + kw = (; kwargs...) + + # Validate + parse with the part’s own pipeline (proxies resolved here) + ntv = Validation.validate!(C, kw.radius_in, args...; kw...) + + # Coerce validated values to group’s T and call strict numeric core + order = (Validation.required_fields(C)..., Validation.keyword_fields(C)...) + coerced = _coerced_args(C, ntv, Tg, order) # respects coercive_fields(C) + new_part = C(coerced...) + + # Update equivalent properties + group.gmr = calc_equivalent_gmr(group, new_part) + group.alpha = calc_equivalent_alpha(group.alpha, group.resistance, + new_part.material_props.alpha, + new_part.resistance) + group.resistance = calc_parallel_equivalent(group.resistance, new_part.resistance) + group.radius_ext += (new_part.radius_ext - new_part.radius_in) + group.cross_section += new_part.cross_section + + # WireArray / Strip bookkeeping + if new_part isa WireArray || new_part isa Strip + old_wires = group.num_wires + old_turns = group.num_turns + nw = new_part isa WireArray ? new_part.num_wires : 1 + nt = new_part.pitch_length > 0 ? inv(new_part.pitch_length) : zero(Tg) + group.num_wires += nw + group.num_turns = (old_wires * old_turns + nw * nt) / group.num_wires + end + + push!(group.layers, new_part) + return group +end + include("conductorgroup/base.jl") \ No newline at end of file diff --git a/src/datamodel/conductorgroup/base.jl b/src/datamodel/conductorgroup/base.jl index 3d4998d3..8eec32c3 100644 --- a/src/datamodel/conductorgroup/base.jl +++ b/src/datamodel/conductorgroup/base.jl @@ -1,4 +1,4 @@ -import Base: eltype - -eltype(::ConductorGroup{T}) where {T} = T +import Base: eltype + +eltype(::ConductorGroup{T}) where {T} = T eltype(::Type{ConductorGroup{T}}) where {T} = T \ No newline at end of file diff --git a/src/datamodel/helpers.jl b/src/datamodel/helpers.jl index 378458e6..cff7839e 100644 --- a/src/datamodel/helpers.jl +++ b/src/datamodel/helpers.jl @@ -1,90 +1,90 @@ -""" -$(TYPEDSIGNATURES) - -Calculates the coordinates of three cables arranged in a trifoil pattern. - -# Arguments - -- `x0`: X-coordinate of the center point \\[m\\]. -- `y0`: Y-coordinate of the center point \\[m\\]. -- `r_ext`: External radius of the circular layout \\[m\\]. - -# Returns - -- A tuple containing: - - `xa`, `ya`: Coordinates of the top cable \\[m\\]. - - `xb`, `yb`: Coordinates of the bottom-left cable \\[m\\]. - - `xc`, `yc`: Coordinates of the bottom-right cable \\[m\\]. - -# Examples - -```julia -xa, ya, xb, yb, xc, yc = $(FUNCTIONNAME)(0.0, 0.0, 0.035) -println((xa, ya)) # Coordinates of top cable -println((xb, yb)) # Coordinates of bottom-left cable -println((xc, yc)) # Coordinates of bottom-right cable -``` -""" -function trifoil_formation(x0::T, y0::T, r_ext::T) where {T<:REALSCALAR} - @assert r_ext > 0 "External radius must be positive" - - d = r_ext / cos(deg2rad(30)) - xa = x0 - ya = y0 + d * sin(deg2rad(90)) - - xb = x0 + d * cos(deg2rad(210)) - yb = y0 + d * sin(deg2rad(210)) - - xc = x0 + d * cos(deg2rad(330)) - yc = y0 + d * sin(deg2rad(330)) - - return xa, ya, xb, yb, xc, yc -end - -""" -$(TYPEDSIGNATURES) - -Calculates the coordinates of three conductors arranged in a flat (horizontal or vertical) formation. - -# Arguments - -- `xc`: X-coordinate of the reference point \\[m\\]. -- `yc`: Y-coordinate of the reference point \\[m\\]. -- `s`: Spacing between adjacent conductors \\[m\\]. -- `vertical`: Boolean flag indicating whether the formation is vertical. - -# Returns - -- A tuple containing: - - `xa`, `ya`: Coordinates of the first conductor \\[m\\]. - - `xb`, `yb`: Coordinates of the second conductor \\[m\\]. - - `xc`, `yc`: Coordinates of the third conductor \\[m\\]. - -# Examples - -```julia -# Horizontal formation -xa, ya, xb, yb, xc, yc = $(FUNCTIONNAME)(0.0, 0.0, 0.1) -println((xa, ya)) # First conductor coordinates -println((xb, yb)) # Second conductor coordinates -println((xc, yc)) # Third conductor coordinates - -# Vertical formation -xa, ya, xb, yb, xc, yc = $(FUNCTIONNAME)(0.0, 0.0, 0.1, vertical=true) -``` -""" -function flat_formation(xc::T, yc::T, s::T; vertical=false) where {T<:REALSCALAR} - if vertical - # Layout is vertical; adjust only y-coordinates - xa, ya = xc, yc - xb, yb = xc, yc - s - xc, yc = xc, yc - 2s - else - # Layout is horizontal; adjust only x-coordinates - xa, ya = xc, yc - xb, yb = xc + s, yc - xc, yc = xc + 2s, yc - end - - return xa, ya, xb, yb, xc, yc +""" +$(TYPEDSIGNATURES) + +Calculates the coordinates of three cables arranged in a trifoil pattern. + +# Arguments + +- `x0`: X-coordinate of the center point \\[m\\]. +- `y0`: Y-coordinate of the center point \\[m\\]. +- `r_ext`: External radius of the circular layout \\[m\\]. + +# Returns + +- A tuple containing: + - `xa`, `ya`: Coordinates of the top cable \\[m\\]. + - `xb`, `yb`: Coordinates of the bottom-left cable \\[m\\]. + - `xc`, `yc`: Coordinates of the bottom-right cable \\[m\\]. + +# Examples + +```julia +xa, ya, xb, yb, xc, yc = $(FUNCTIONNAME)(0.0, 0.0, 0.035) +println((xa, ya)) # Coordinates of top cable +println((xb, yb)) # Coordinates of bottom-left cable +println((xc, yc)) # Coordinates of bottom-right cable +``` +""" +function trifoil_formation(x0::T, y0::T, r_ext::T) where {T<:REALSCALAR} + @assert r_ext > 0 "External radius must be positive" + + d = r_ext / cos(deg2rad(30)) + xa = x0 + ya = y0 + d * sin(deg2rad(90)) + + xb = x0 + d * cos(deg2rad(210)) + yb = y0 + d * sin(deg2rad(210)) + + xc = x0 + d * cos(deg2rad(330)) + yc = y0 + d * sin(deg2rad(330)) + + return xa, ya, xb, yb, xc, yc +end + +""" +$(TYPEDSIGNATURES) + +Calculates the coordinates of three conductors arranged in a flat (horizontal or vertical) formation. + +# Arguments + +- `xc`: X-coordinate of the reference point \\[m\\]. +- `yc`: Y-coordinate of the reference point \\[m\\]. +- `s`: Spacing between adjacent conductors \\[m\\]. +- `vertical`: Boolean flag indicating whether the formation is vertical. + +# Returns + +- A tuple containing: + - `xa`, `ya`: Coordinates of the first conductor \\[m\\]. + - `xb`, `yb`: Coordinates of the second conductor \\[m\\]. + - `xc`, `yc`: Coordinates of the third conductor \\[m\\]. + +# Examples + +```julia +# Horizontal formation +xa, ya, xb, yb, xc, yc = $(FUNCTIONNAME)(0.0, 0.0, 0.1) +println((xa, ya)) # First conductor coordinates +println((xb, yb)) # Second conductor coordinates +println((xc, yc)) # Third conductor coordinates + +# Vertical formation +xa, ya, xb, yb, xc, yc = $(FUNCTIONNAME)(0.0, 0.0, 0.1, vertical=true) +``` +""" +function flat_formation(xc::T, yc::T, s::T; vertical=false) where {T<:REALSCALAR} + if vertical + # Layout is vertical; adjust only y-coordinates + xa, ya = xc, yc + xb, yb = xc, yc - s + xc, yc = xc, yc - 2s + else + # Layout is horizontal; adjust only x-coordinates + xa, ya = xc, yc + xb, yb = xc + s, yc + xc, yc = xc + 2s, yc + end + + return xa, ya, xb, yb, xc, yc end \ No newline at end of file diff --git a/src/datamodel/insulator.jl b/src/datamodel/insulator.jl index de1bfb70..e2326db3 100644 --- a/src/datamodel/insulator.jl +++ b/src/datamodel/insulator.jl @@ -1,112 +1,112 @@ -""" -$(TYPEDEF) - -Represents an insulating layer with defined geometric, material, and electrical properties given by the attributes: - -$(TYPEDFIELDS) -""" -struct Insulator{T <: REALSCALAR} <: AbstractInsulatorPart{T} - "Internal radius of the insulating layer \\[m\\]." - radius_in::T - "External radius of the insulating layer \\[m\\]." - radius_ext::T - "Material properties of the insulator." - material_props::Material{T} - "Operating temperature of the insulator \\[°C\\]." - temperature::T - "Cross-sectional area of the insulating layer \\[m²\\]." - cross_section::T - "Electrical resistance of the insulating layer \\[Ω/m\\]." - resistance::T - "Geometric mean radius of the insulator \\[m\\]." - gmr::T - "Shunt capacitance per unit length of the insulating layer \\[F/m\\]." - shunt_capacitance::T - "Shunt conductance per unit length of the insulating layer \\[S·m\\]." - shunt_conductance::T -end - -""" -$(TYPEDSIGNATURES) - -Constructs an [`Insulator`](@ref) object with specified geometric and material parameters. - -# Arguments - -- `radius_in`: Internal radius of the insulating layer \\[m\\]. -- `radius_ext`: External radius or thickness of the layer \\[m\\]. -- `material_props`: Material properties of the insulating material. -- `temperature`: Operating temperature of the insulator \\[°C\\]. - -# Returns - -- An [`Insulator`](@ref) object with calculated electrical properties. - -# Examples - -```julia -material_props = Material(1e10, 3.0, 1.0, 20.0, 0.0) -insulator_layer = $(FUNCTIONNAME)(0.01, 0.015, material_props, temperature=25) -``` -""" -function Insulator( - radius_in::T, - radius_ext::T, - material_props::Material{T}, - temperature::T, -) where {T <: REALSCALAR} - - rho = material_props.rho - T0 = material_props.T0 - alpha = material_props.alpha - epsr_r = material_props.eps_r - - cross_section = π * (radius_ext^2 - radius_in^2) - - resistance = - calc_tubular_resistance(radius_in, radius_ext, rho, alpha, T0, temperature) - gmr = calc_tubular_gmr(radius_ext, radius_in, material_props.mu_r) - shunt_capacitance = calc_shunt_capacitance(radius_in, radius_ext, epsr_r) - shunt_conductance = calc_shunt_conductance(radius_in, radius_ext, rho) - - # Initialize object - return Insulator( - radius_in, - radius_ext, - material_props, - temperature, - cross_section, - resistance, - gmr, - shunt_capacitance, - shunt_conductance, - ) -end - -const _REQ_INSULATOR = (:radius_in, :radius_ext, :material_props) -const _OPT_INSULATOR = (:temperature,) -const _DEFS_INSULATOR = (T₀,) - -Validation.has_radii(::Type{Insulator}) = true -Validation.has_temperature(::Type{Insulator}) = true -Validation.required_fields(::Type{Insulator}) = _REQ_INSULATOR -Validation.keyword_fields(::Type{Insulator}) = _OPT_INSULATOR -Validation.keyword_defaults(::Type{Insulator}) = _DEFS_INSULATOR - -# accept proxies for radii -Validation.is_radius_input(::Type{Insulator}, ::Val{:radius_in}, x::AbstractCablePart) = - true -Validation.is_radius_input(::Type{Insulator}, ::Val{:radius_in}, x::Thickness) = true -Validation.is_radius_input(::Type{Insulator}, ::Val{:radius_ext}, x::Thickness) = true -Validation.is_radius_input(::Type{Insulator}, ::Val{:radius_ext}, x::Diameter) = true - -Validation.extra_rules(::Type{Insulator}) = (IsA{Material}(:material_props),) - -# normalize proxies -> numbers -Validation.parse(::Type{Insulator}, nt) = begin - rin, rex = _normalize_radii(Insulator, nt.radius_in, nt.radius_ext) - (; nt..., radius_in = rin, radius_ext = rex) -end - -# This macro expands to a weakly-typed constructor for Insulator -@construct Insulator _REQ_INSULATOR _OPT_INSULATOR _DEFS_INSULATOR +""" +$(TYPEDEF) + +Represents an insulating layer with defined geometric, material, and electrical properties given by the attributes: + +$(TYPEDFIELDS) +""" +struct Insulator{T <: REALSCALAR} <: AbstractInsulatorPart{T} + "Internal radius of the insulating layer \\[m\\]." + radius_in::T + "External radius of the insulating layer \\[m\\]." + radius_ext::T + "Material properties of the insulator." + material_props::Material{T} + "Operating temperature of the insulator \\[°C\\]." + temperature::T + "Cross-sectional area of the insulating layer \\[m²\\]." + cross_section::T + "Electrical resistance of the insulating layer \\[Ω/m\\]." + resistance::T + "Geometric mean radius of the insulator \\[m\\]." + gmr::T + "Shunt capacitance per unit length of the insulating layer \\[F/m\\]." + shunt_capacitance::T + "Shunt conductance per unit length of the insulating layer \\[S·m\\]." + shunt_conductance::T +end + +""" +$(TYPEDSIGNATURES) + +Constructs an [`Insulator`](@ref) object with specified geometric and material parameters. + +# Arguments + +- `radius_in`: Internal radius of the insulating layer \\[m\\]. +- `radius_ext`: External radius or thickness of the layer \\[m\\]. +- `material_props`: Material properties of the insulating material. +- `temperature`: Operating temperature of the insulator \\[°C\\]. + +# Returns + +- An [`Insulator`](@ref) object with calculated electrical properties. + +# Examples + +```julia +material_props = Material(1e10, 3.0, 1.0, 20.0, 0.0) +insulator_layer = $(FUNCTIONNAME)(0.01, 0.015, material_props, temperature=25) +``` +""" +function Insulator( + radius_in::T, + radius_ext::T, + material_props::Material{T}, + temperature::T, +) where {T <: REALSCALAR} + + rho = material_props.rho + T0 = material_props.T0 + alpha = material_props.alpha + epsr_r = material_props.eps_r + + cross_section = π * (radius_ext^2 - radius_in^2) + + resistance = + calc_tubular_resistance(radius_in, radius_ext, rho, alpha, T0, temperature) + gmr = calc_tubular_gmr(radius_ext, radius_in, material_props.mu_r) + shunt_capacitance = calc_shunt_capacitance(radius_in, radius_ext, epsr_r) + shunt_conductance = calc_shunt_conductance(radius_in, radius_ext, rho) + + # Initialize object + return Insulator( + radius_in, + radius_ext, + material_props, + temperature, + cross_section, + resistance, + gmr, + shunt_capacitance, + shunt_conductance, + ) +end + +const _REQ_INSULATOR = (:radius_in, :radius_ext, :material_props) +const _OPT_INSULATOR = (:temperature,) +const _DEFS_INSULATOR = (T₀,) + +Validation.has_radii(::Type{Insulator}) = true +Validation.has_temperature(::Type{Insulator}) = true +Validation.required_fields(::Type{Insulator}) = _REQ_INSULATOR +Validation.keyword_fields(::Type{Insulator}) = _OPT_INSULATOR +Validation.keyword_defaults(::Type{Insulator}) = _DEFS_INSULATOR + +# accept proxies for radii +Validation.is_radius_input(::Type{Insulator}, ::Val{:radius_in}, x::AbstractCablePart) = + true +Validation.is_radius_input(::Type{Insulator}, ::Val{:radius_in}, x::Thickness) = true +Validation.is_radius_input(::Type{Insulator}, ::Val{:radius_ext}, x::Thickness) = true +Validation.is_radius_input(::Type{Insulator}, ::Val{:radius_ext}, x::Diameter) = true + +Validation.extra_rules(::Type{Insulator}) = (IsA{Material}(:material_props),) + +# normalize proxies -> numbers +Validation.parse(::Type{Insulator}, nt) = begin + rin, rex = _normalize_radii(Insulator, nt.radius_in, nt.radius_ext) + (; nt..., radius_in = rin, radius_ext = rex) +end + +# This macro expands to a weakly-typed constructor for Insulator +@construct Insulator _REQ_INSULATOR _OPT_INSULATOR _DEFS_INSULATOR diff --git a/src/datamodel/insulatorgroup.jl b/src/datamodel/insulatorgroup.jl index 9c37bb37..546074ea 100644 --- a/src/datamodel/insulatorgroup.jl +++ b/src/datamodel/insulatorgroup.jl @@ -1,202 +1,202 @@ - -""" -$(TYPEDEF) - -Represents a composite coaxial insulator group assembled from multiple insulating layers. - -This structure serves as a container for different [`AbstractInsulatorPart`](@ref) elements -(such as insulators and semiconductors) arranged in concentric layers. -The `InsulatorGroup` aggregates these individual parts and provides equivalent electrical -properties that represent the composite behavior of the entire assembly, stored in the attributes: - -$(TYPEDFIELDS) -""" -mutable struct InsulatorGroup{T<:REALSCALAR} <: AbstractInsulatorPart{T} - "Inner radius of the insulator group \\[m\\]." - radius_in::T - "Outer radius of the insulator group \\[m\\]." - radius_ext::T - "Cross-sectional area of the entire insulator group \\[m²\\]." - cross_section::T - "Shunt capacitance per unit length of the insulator group \\[F/m\\]." - shunt_capacitance::T - "Shunt conductance per unit length of the insulator group \\[S·m\\]." - shunt_conductance::T - "Vector of insulator layer components." - layers::Vector{AbstractInsulatorPart{T}} - - @doc """ - $(TYPEDSIGNATURES) - - Constructs an [`InsulatorGroup`](@ref) instance initializing with the initial insulator part. - - # Arguments - - - `initial_insulator`: An [`AbstractInsulatorPart`](@ref) object located at the innermost position of the insulator group. - - # Returns - - - An [`InsulatorGroup`](@ref) object initialized with geometric and electrical properties derived from the initial insulator. - - # Examples - - ```julia - material_props = Material(1e10, 3.0, 1.0, 20.0, 0.0) - initial_insulator = Insulator(0.01, 0.015, material_props) - insulator_group = $(FUNCTIONNAME)(initial_insulator) - println(insulator_group.layers) # Output: [initial_insulator] - println(insulator_group.shunt_capacitance) # Output: Capacitance in [F/m] - ``` - """ - function InsulatorGroup{T}( - radius_in::T, - radius_ext::T, - cross_section::T, - shunt_capacitance::T, - shunt_conductance::T, - layers::Vector{AbstractInsulatorPart{T}}, - ) where {T} - return new{T}(radius_in, radius_ext, cross_section, - shunt_capacitance, shunt_conductance, layers) - end - - function InsulatorGroup{T}(initial_insulator::AbstractInsulatorPart{T}) where {T} - return new{T}( - initial_insulator.radius_in, - initial_insulator.radius_ext, - initial_insulator.cross_section, - initial_insulator.shunt_capacitance, - initial_insulator.shunt_conductance, - AbstractInsulatorPart{T}[initial_insulator], - ) - end -end - -# Convenience outer -InsulatorGroup(ins::AbstractInsulatorPart{T}) where {T} = InsulatorGroup{T}(ins) - -""" -$(TYPEDSIGNATURES) - -Adds a new part to an existing [`InsulatorGroup`](@ref) object and updates its equivalent electrical parameters. - -# Behavior: - -1. Apply part-level keyword defaults (from `Validation.keyword_defaults`). -2. Default `radius_in` to `group.radius_ext` if absent. -3. Compute `Tnew = resolve_T(group, radius_in, args..., values(kwargs)..., f)`. -4. If `Tnew === T`, mutate in place; else `coerce_to_T(group, Tnew)` then mutate and **return the promoted group**. - -# Arguments - -- `group`: [`InsulatorGroup`](@ref) object to which the new part will be added. -- `part_type`: Type of insulator part to add ([`AbstractInsulatorPart`](@ref)). -- `args...`: Positional arguments specific to the constructor of the `part_type` ([`AbstractInsulatorPart`](@ref)) \\[various\\]. -- `kwargs...`: Named arguments for the constructor including optional values specific to the constructor of the `part_type` ([`AbstractInsulatorPart`](@ref)) \\[various\\]. - -# Returns - -- The function modifies the [`InsulatorGroup`](@ref) instance in place and does not return a value. - -# Notes - -- Updates `shunt_capacitance`, `shunt_conductance`, `radius_ext`, and `cross_section` to account for the new part. -- The `radius_in` of the new part defaults to the external radius of the existing insulator group if not specified. - -!!! warning "Note" - - When an [`AbstractCablePart`](@ref) is provided as `radius_in`, the constructor retrieves its `radius_ext` value, allowing the new cable part to be placed directly over the existing part in a layered cable design. - - In case of uncertain measurements, if the added cable part is of a different type than the existing one, the uncertainty is removed from the radius value before being passed to the new component. This ensures that measurement uncertainties do not inappropriately cascade across different cable parts. - -# Examples - -```julia -material_props = Material(1e10, 3.0, 1.0, 20.0, 0.0) -insulator_group = InsulatorGroup(Insulator(0.01, 0.015, material_props)) -$(FUNCTIONNAME)(insulator_group, Semicon, 0.015, 0.018, material_props) -``` - -# See also - -- [`InsulatorGroup`](@ref) -- [`Insulator`](@ref) -- [`Semicon`](@ref) -- [`calc_parallel_equivalent`](@ref) -""" -function add!( - group::InsulatorGroup{T}, - part_type::Type{C}, - args...; - f::Number=f₀, - kwargs... -) where {T,C<:AbstractInsulatorPart} - - # 1) Merge declared keyword defaults for this part type - kwv = _with_kwdefaults(C, (; kwargs...)) - - # 2) Default stacking: inner radius = current outer radius unless overridden - rin = get(kwv, :radius_in, group.radius_ext) - kwv = haskey(kwv, :radius_in) ? kwv : merge(kwv, (; radius_in=rin)) - - # 3) Decide target numeric type using *current group + raw inputs + f* - Tnew = resolve_T(group, rin, args..., values(kwv)..., f) - - if Tnew === T - # 4a) Fast path: mutate in place - return _do_add!(group, C, args...; f, kwv...) - else - @warn """ - Adding a `$Tnew` part to an `InsulatorGroup{$T}` returns a **promoted** group. - Capture the result: group = add!(group, $C, …) - """ - promoted = coerce_to_T(group, Tnew) - return _do_add!(promoted, C, args...; f, kwv...) - end -end - -""" -$(TYPEDSIGNATURES) - -Do the actual insertion for `InsulatorGroup` with the group already at the -correct scalar type. Validates/parses the part, coerces to the group’s `T`, -constructs the strict numeric core, and updates geometry and admittances at the -provided frequency. - -Returns the mutated group (same object). -""" -function _do_add!( - group::InsulatorGroup{Tg}, - C::Type{<:AbstractInsulatorPart}, - args...; - f::Number=f₀, - kwargs... -) where {Tg} - - # Materialize keyword args into a NamedTuple - kw = (; kwargs...) - - # Validate + parse with the part’s own pipeline (proxies resolved here) - ntv = Validation.validate!(C, kw.radius_in, args...; kw...) - - # Build argument order and coerce validated values to group’s T - order = (Validation.required_fields(C)..., Validation.keyword_fields(C)...) - coerced = _coerced_args(C, ntv, Tg, order) # respects coercive_fields(C) - new_part = C(coerced...) # call strict numeric core - - # Parallel admittances at frequency f - ω = Tg(2π) * coerce_to_T(f, Tg) - Yg = Complex(group.shunt_conductance, ω * group.shunt_capacitance) - Yp = Complex(new_part.shunt_conductance, ω * new_part.shunt_capacitance) - Ye = calc_parallel_equivalent(Yg, Yp) - group.shunt_conductance = real(Ye) - group.shunt_capacitance = imag(Ye) / ω - - # Update geometry - group.radius_ext += new_part.radius_ext - new_part.radius_in - group.cross_section += new_part.cross_section - - push!(group.layers, new_part) - return group -end - -include("insulatorgroup/base.jl") - + +""" +$(TYPEDEF) + +Represents a composite coaxial insulator group assembled from multiple insulating layers. + +This structure serves as a container for different [`AbstractInsulatorPart`](@ref) elements +(such as insulators and semiconductors) arranged in concentric layers. +The `InsulatorGroup` aggregates these individual parts and provides equivalent electrical +properties that represent the composite behavior of the entire assembly, stored in the attributes: + +$(TYPEDFIELDS) +""" +mutable struct InsulatorGroup{T<:REALSCALAR} <: AbstractInsulatorPart{T} + "Inner radius of the insulator group \\[m\\]." + radius_in::T + "Outer radius of the insulator group \\[m\\]." + radius_ext::T + "Cross-sectional area of the entire insulator group \\[m²\\]." + cross_section::T + "Shunt capacitance per unit length of the insulator group \\[F/m\\]." + shunt_capacitance::T + "Shunt conductance per unit length of the insulator group \\[S·m\\]." + shunt_conductance::T + "Vector of insulator layer components." + layers::Vector{AbstractInsulatorPart{T}} + + @doc """ + $(TYPEDSIGNATURES) + + Constructs an [`InsulatorGroup`](@ref) instance initializing with the initial insulator part. + + # Arguments + + - `initial_insulator`: An [`AbstractInsulatorPart`](@ref) object located at the innermost position of the insulator group. + + # Returns + + - An [`InsulatorGroup`](@ref) object initialized with geometric and electrical properties derived from the initial insulator. + + # Examples + + ```julia + material_props = Material(1e10, 3.0, 1.0, 20.0, 0.0) + initial_insulator = Insulator(0.01, 0.015, material_props) + insulator_group = $(FUNCTIONNAME)(initial_insulator) + println(insulator_group.layers) # Output: [initial_insulator] + println(insulator_group.shunt_capacitance) # Output: Capacitance in [F/m] + ``` + """ + function InsulatorGroup{T}( + radius_in::T, + radius_ext::T, + cross_section::T, + shunt_capacitance::T, + shunt_conductance::T, + layers::Vector{AbstractInsulatorPart{T}}, + ) where {T} + return new{T}(radius_in, radius_ext, cross_section, + shunt_capacitance, shunt_conductance, layers) + end + + function InsulatorGroup{T}(initial_insulator::AbstractInsulatorPart{T}) where {T} + return new{T}( + initial_insulator.radius_in, + initial_insulator.radius_ext, + initial_insulator.cross_section, + initial_insulator.shunt_capacitance, + initial_insulator.shunt_conductance, + AbstractInsulatorPart{T}[initial_insulator], + ) + end +end + +# Convenience outer +InsulatorGroup(ins::AbstractInsulatorPart{T}) where {T} = InsulatorGroup{T}(ins) + +""" +$(TYPEDSIGNATURES) + +Adds a new part to an existing [`InsulatorGroup`](@ref) object and updates its equivalent electrical parameters. + +# Behavior: + +1. Apply part-level keyword defaults (from `Validation.keyword_defaults`). +2. Default `radius_in` to `group.radius_ext` if absent. +3. Compute `Tnew = resolve_T(group, radius_in, args..., values(kwargs)..., f)`. +4. If `Tnew === T`, mutate in place; else `coerce_to_T(group, Tnew)` then mutate and **return the promoted group**. + +# Arguments + +- `group`: [`InsulatorGroup`](@ref) object to which the new part will be added. +- `part_type`: Type of insulator part to add ([`AbstractInsulatorPart`](@ref)). +- `args...`: Positional arguments specific to the constructor of the `part_type` ([`AbstractInsulatorPart`](@ref)) \\[various\\]. +- `kwargs...`: Named arguments for the constructor including optional values specific to the constructor of the `part_type` ([`AbstractInsulatorPart`](@ref)) \\[various\\]. + +# Returns + +- The function modifies the [`InsulatorGroup`](@ref) instance in place and does not return a value. + +# Notes + +- Updates `shunt_capacitance`, `shunt_conductance`, `radius_ext`, and `cross_section` to account for the new part. +- The `radius_in` of the new part defaults to the external radius of the existing insulator group if not specified. + +!!! warning "Note" + - When an [`AbstractCablePart`](@ref) is provided as `radius_in`, the constructor retrieves its `radius_ext` value, allowing the new cable part to be placed directly over the existing part in a layered cable design. + - In case of uncertain measurements, if the added cable part is of a different type than the existing one, the uncertainty is removed from the radius value before being passed to the new component. This ensures that measurement uncertainties do not inappropriately cascade across different cable parts. + +# Examples + +```julia +material_props = Material(1e10, 3.0, 1.0, 20.0, 0.0) +insulator_group = InsulatorGroup(Insulator(0.01, 0.015, material_props)) +$(FUNCTIONNAME)(insulator_group, Semicon, 0.015, 0.018, material_props) +``` + +# See also + +- [`InsulatorGroup`](@ref) +- [`Insulator`](@ref) +- [`Semicon`](@ref) +- [`calc_parallel_equivalent`](@ref) +""" +function add!( + group::InsulatorGroup{T}, + part_type::Type{C}, + args...; + f::Number=f₀, + kwargs... +) where {T,C<:AbstractInsulatorPart} + + # 1) Merge declared keyword defaults for this part type + kwv = _with_kwdefaults(C, (; kwargs...)) + + # 2) Default stacking: inner radius = current outer radius unless overridden + rin = get(kwv, :radius_in, group.radius_ext) + kwv = haskey(kwv, :radius_in) ? kwv : merge(kwv, (; radius_in=rin)) + + # 3) Decide target numeric type using *current group + raw inputs + f* + Tnew = resolve_T(group, rin, args..., values(kwv)..., f) + + if Tnew === T + # 4a) Fast path: mutate in place + return _do_add!(group, C, args...; f, kwv...) + else + @warn """ + Adding a `$Tnew` part to an `InsulatorGroup{$T}` returns a **promoted** group. + Capture the result: group = add!(group, $C, …) + """ + promoted = coerce_to_T(group, Tnew) + return _do_add!(promoted, C, args...; f, kwv...) + end +end + +""" +$(TYPEDSIGNATURES) + +Do the actual insertion for `InsulatorGroup` with the group already at the +correct scalar type. Validates/parses the part, coerces to the group’s `T`, +constructs the strict numeric core, and updates geometry and admittances at the +provided frequency. + +Returns the mutated group (same object). +""" +function _do_add!( + group::InsulatorGroup{Tg}, + C::Type{<:AbstractInsulatorPart}, + args...; + f::Number=f₀, + kwargs... +) where {Tg} + + # Materialize keyword args into a NamedTuple + kw = (; kwargs...) + + # Validate + parse with the part’s own pipeline (proxies resolved here) + ntv = Validation.validate!(C, kw.radius_in, args...; kw...) + + # Build argument order and coerce validated values to group’s T + order = (Validation.required_fields(C)..., Validation.keyword_fields(C)...) + coerced = _coerced_args(C, ntv, Tg, order) # respects coercive_fields(C) + new_part = C(coerced...) # call strict numeric core + + # Parallel admittances at frequency f + ω = Tg(2π) * coerce_to_T(f, Tg) + Yg = Complex(group.shunt_conductance, ω * group.shunt_capacitance) + Yp = Complex(new_part.shunt_conductance, ω * new_part.shunt_capacitance) + Ye = calc_parallel_equivalent(Yg, Yp) + group.shunt_conductance = real(Ye) + group.shunt_capacitance = imag(Ye) / ω + + # Update geometry + group.radius_ext += new_part.radius_ext - new_part.radius_in + group.cross_section += new_part.cross_section + + push!(group.layers, new_part) + return group +end + +include("insulatorgroup/base.jl") + diff --git a/src/datamodel/insulatorgroup/base.jl b/src/datamodel/insulatorgroup/base.jl index f4bf7185..736aa037 100644 --- a/src/datamodel/insulatorgroup/base.jl +++ b/src/datamodel/insulatorgroup/base.jl @@ -1,3 +1,3 @@ - -Base.eltype(::InsulatorGroup{T}) where {T} = T + +Base.eltype(::InsulatorGroup{T}) where {T} = T Base.eltype(::Type{InsulatorGroup{T}}) where {T} = T \ No newline at end of file diff --git a/src/datamodel/io.jl b/src/datamodel/io.jl index 97222bef..77026bcb 100644 --- a/src/datamodel/io.jl +++ b/src/datamodel/io.jl @@ -1,147 +1,147 @@ - - -""" -$(TYPEDSIGNATURES) - -Defines the display representation of an [`AbstractCablePart`](@ref) object for REPL or text output. - -# Arguments - -- `io`: Output stream. -- `::MIME"text/plain"`: MIME type for plain text output. -- `part`: The [`AbstractCablePart`](@ref) instance to be displayed. - -# Returns - -- Nothing. Modifies `io` by writing text representation of the object. -""" -function Base.show(io::IO, ::MIME"text/plain", part::T) where {T<:AbstractCablePart} - # Start output with type name - print(io, "$(nameof(T)): [") - - # Use _print_fields to display all relevant fields - _print_fields( - io, - part, - [ - :radius_in, - :radius_ext, - :cross_section, - :resistance, - :gmr, - :shunt_capacitance, - :shunt_conductance, - ], - ) - - println(io, "]") - - # Display material properties if available - if hasproperty(part, :material_props) - print(io, "└─ Material properties: [") - _print_fields( - io, - part.material_props, - [:rho, :eps_r, :mu_r, :alpha], - ) - println(io, "]") - end -end - - -""" -$(TYPEDSIGNATURES) - -Defines the display representation of a [`ConductorGroup`](@ref) or [`InsulatorGroup`](@ref)objects for REPL or text output. - -# Arguments - -- `io`: Output stream. -- `::MIME"text/plain"`: MIME type for plain text output. -- `group`: The [`ConductorGroup`](@ref) or [`InsulatorGroup`](@ref) instance to be displayed. - -# Returns - -- Nothing. Modifies `io` by writing text representation of the object. -""" -function Base.show(io::IO, ::MIME"text/plain", group::Union{ConductorGroup,InsulatorGroup}) - - print(io, "$(length(group.layers))-element $(nameof(typeof(group))): [") - _print_fields( - io, - group, - [ - :radius_in, - :radius_ext, - :cross_section, - :resistance, - :gmr, - :shunt_capacitance, - :shunt_conductance, - ], - ) - println(io, "]") - - # Tree-like layer representation - - for (i, layer) in enumerate(group.layers) - # Determine prefix based on whether it's the last layer - prefix = i == length(group.layers) ? "└─" : "├─" - # Print layer information with only selected fields - print(io, prefix, "$(nameof(typeof(layer))): [") - _print_fields( - io, - layer, - [ - :radius_in, - :radius_ext, - :cross_section, - :resistance, - :gmr, - :shunt_capacitance, - :shunt_conductance, - ], - ) - println(io, "]") - end -end - -""" -$(TYPEDSIGNATURES) - -Print the specified fields of an object in a compact format. - -# Arguments -- `io`: The output stream. -- `obj`: The object whose fields will be displayed. -- `fields_to_show`: Vector of field names (as Symbols) to display. -- `sigdigits`: Number of significant digits for rounding numeric values. - -# Returns - -- Number of fields that were actually displayed. -""" -function _print_fields(io::IO, obj, fields_to_show::Vector{Symbol}; sigdigits::Int=4) - displayed_fields = 0 - for field in fields_to_show - if hasproperty(obj, field) - value = getfield(obj, field) - # Skip NaN values - if value isa Number && isnan(value) - continue - end - # Add comma if not the first item - if displayed_fields > 0 - print(io, ", ") - end - # Format numbers with rounding - if value isa Number - print(io, "$field=$(round(value, sigdigits=sigdigits))") - else - print(io, "$field=$value") - end - displayed_fields += 1 - end - end - return displayed_fields + + +""" +$(TYPEDSIGNATURES) + +Defines the display representation of an [`AbstractCablePart`](@ref) object for REPL or text output. + +# Arguments + +- `io`: Output stream. +- `::MIME"text/plain"`: MIME type for plain text output. +- `part`: The [`AbstractCablePart`](@ref) instance to be displayed. + +# Returns + +- Nothing. Modifies `io` by writing text representation of the object. +""" +function Base.show(io::IO, ::MIME"text/plain", part::T) where {T<:AbstractCablePart} + # Start output with type name + print(io, "$(nameof(T)): [") + + # Use _print_fields to display all relevant fields + _print_fields( + io, + part, + [ + :radius_in, + :radius_ext, + :cross_section, + :resistance, + :gmr, + :shunt_capacitance, + :shunt_conductance, + ], + ) + + println(io, "]") + + # Display material properties if available + if hasproperty(part, :material_props) + print(io, "└─ Material properties: [") + _print_fields( + io, + part.material_props, + [:rho, :eps_r, :mu_r, :alpha], + ) + println(io, "]") + end +end + + +""" +$(TYPEDSIGNATURES) + +Defines the display representation of a [`ConductorGroup`](@ref) or [`InsulatorGroup`](@ref)objects for REPL or text output. + +# Arguments + +- `io`: Output stream. +- `::MIME"text/plain"`: MIME type for plain text output. +- `group`: The [`ConductorGroup`](@ref) or [`InsulatorGroup`](@ref) instance to be displayed. + +# Returns + +- Nothing. Modifies `io` by writing text representation of the object. +""" +function Base.show(io::IO, ::MIME"text/plain", group::Union{ConductorGroup,InsulatorGroup}) + + print(io, "$(length(group.layers))-element $(nameof(typeof(group))): [") + _print_fields( + io, + group, + [ + :radius_in, + :radius_ext, + :cross_section, + :resistance, + :gmr, + :shunt_capacitance, + :shunt_conductance, + ], + ) + println(io, "]") + + # Tree-like layer representation + + for (i, layer) in enumerate(group.layers) + # Determine prefix based on whether it's the last layer + prefix = i == length(group.layers) ? "└─" : "├─" + # Print layer information with only selected fields + print(io, prefix, "$(nameof(typeof(layer))): [") + _print_fields( + io, + layer, + [ + :radius_in, + :radius_ext, + :cross_section, + :resistance, + :gmr, + :shunt_capacitance, + :shunt_conductance, + ], + ) + println(io, "]") + end +end + +""" +$(TYPEDSIGNATURES) + +Print the specified fields of an object in a compact format. + +# Arguments +- `io`: The output stream. +- `obj`: The object whose fields will be displayed. +- `fields_to_show`: Vector of field names (as Symbols) to display. +- `sigdigits`: Number of significant digits for rounding numeric values. + +# Returns + +- Number of fields that were actually displayed. +""" +function _print_fields(io::IO, obj, fields_to_show::Vector{Symbol}; sigdigits::Int=4) + displayed_fields = 0 + for field in fields_to_show + if hasproperty(obj, field) + value = getfield(obj, field) + # Skip NaN values + if value isa Number && isnan(value) + continue + end + # Add comma if not the first item + if displayed_fields > 0 + print(io, ", ") + end + # Format numbers with rounding + if value isa Number + print(io, "$field=$(round(value, sigdigits=sigdigits))") + else + print(io, "$field=$value") + end + displayed_fields += 1 + end + end + return displayed_fields end \ No newline at end of file diff --git a/src/datamodel/linecablesystem.jl b/src/datamodel/linecablesystem.jl index 3c5bc0e1..38543b4c 100644 --- a/src/datamodel/linecablesystem.jl +++ b/src/datamodel/linecablesystem.jl @@ -1,367 +1,367 @@ - -""" -$(TYPEDEF) - -Represents a physically defined cable with position and phase mapping within a system. - -$(TYPEDFIELDS) -""" -struct CablePosition{T <: REALSCALAR} - "The [`CableDesign`](@ref) object assigned to this cable position." - design_data::CableDesign{T} - "Horizontal coordinate \\[m\\]." - horz::T - "Vertical coordinate \\[m\\]." - vert::T - "Phase mapping vector (aligned with design_data.components)." - conn::Vector{Int} - - @doc """ - $(TYPEDSIGNATURES) - - Constructs a [`CablePosition`](@ref) instance with specified cable design, coordinates, and phase mapping. - - # Arguments - - - `cable`: A [`CableDesign`](@ref) object defining the cable structure. - - `horz`: Horizontal coordinate \\[m\\]. - - `vert`: Vertical coordinate \\[m\\]. - - `conn`: A dictionary mapping component names to phase indices, or `nothing` for default mapping. - - # Returns - - - A [`CablePosition`](@ref) object with the assigned cable design, coordinates, and phase mapping. - - !!! note "Phase mapping" - The `conn` argument is a `Dict` that maps the cable components to their respective phases. The values (1, 2, 3) represent the phase numbers (A, B, C) in a three-phase system. Components mapped to phase 0 will be Kron-eliminated (grounded). Components set to the same phase will be bundled into an equivalent phase. - - # Examples - - ```julia - cable_design = CableDesign("example", nominal_data, components_dict) - xa, ya = 0.0, -1.0 # Coordinates in meters - - # With explicit phase mapping - cablepos1 = $(FUNCTIONNAME)(cable_design, xa, ya, Dict("core" => 1)) - - # With default phase mapping (first component to phase 1, others to 0) - default_cablepos = $(FUNCTIONNAME)(cable_design, xa, ya) - ``` - - # See also - - - [`CableDesign`](@ref) - """ - function CablePosition{T}( - cable::CableDesign{T}, - horz::T, - vert::T, - conn::Vector{Int}, - ) where {T <: REALSCALAR} - # Validate: cable not empty - @assert !isempty(cable.components) "CableDesign must contain at least one component" - - # Find outermost radius (last component) - last_comp = cable.components[end] - r_cond = last_comp.conductor_group.radius_ext - r_ins = last_comp.insulator_group.radius_ext - r_max = max(r_cond, r_ins) - - # Validate vertical position - if iszero(vert) - throw( - ArgumentError( - "Vertical position cannot be exactly at the air/earth interface (z=0)", - ), - ) - end - if abs(vert) < r_max - throw( - ArgumentError( - "Vertical position |$vert| must be ≥ cable's outer radius $r_max to avoid crossing z=0", - ), - ) - end - - return new{T}(cable, horz, vert, conn) - end -end - -""" -$(TYPEDSIGNATURES) - -**Weakly-typed constructor** that infers `T` from the `cable` and coordinates, builds/validates the phase mapping, coerces inputs to `T`, and calls the typed kernel. -""" -function CablePosition( - cable::Union{CableDesign, Nothing}, - horz::Number, - vert::Number, - conn::Union{Dict{String, Int}, Nothing} = nothing, -) - @assert !isnothing(cable) "A valid CableDesign must be provided" - @assert !isempty(cable.components) "CableDesign must contain at least one component" - - # Build phase mapping vector aligned to component order - names = [comp.id for comp in cable.components] - conn_vector = if isnothing(conn) - [i == 1 ? 1 : 0 for i in 1:length(names)] # default: first component → phase 1, others grounded - else - [get(conn, name, 0) for name in names] - end - - # Validate provided mapping keys exist (only when conn was given) - if conn !== nothing - for component_id in keys(conn) - if !(component_id in names) - throw( - ArgumentError( - "Component ID '$component_id' not found in the cable design.", - ), - ) - end - end - end - - # Warn if all grounded - !all(iszero, conn_vector) || - @warn("At least one component should be assigned to a non-zero phase.") - - # Resolve scalar type and coerce — with identity-preserving pass-through - T = resolve_T(cable, horz, vert) - cableT = coerce_to_T(cable, T) - horzT = (horz isa T) ? horz : coerce_to_T(horz, T) - vertT = (vert isa T) ? vert : coerce_to_T(vert, T) - - return CablePosition{T}(cableT, horzT, vertT, conn_vector) -end - -""" -$(TYPEDEF) - -Represents a cable system configuration, defining the physical structure, cables, and their positions. - -$(TYPEDFIELDS) -""" -mutable struct LineCableSystem{T <: REALSCALAR} - "Unique identifier for the system." - system_id::String - "Length of the cable system \\[m\\]." - line_length::T - "Number of cables in the system." - num_cables::Int - "Number of actual phases in the system." - num_phases::Int - "Cross-section cable positions." - cables::Vector{CablePosition{T}} - - @doc """ - $(TYPEDSIGNATURES) - - Constructs a [`LineCableSystem`](@ref) with an initial cable position and system parameters. - - # Arguments - - - `system_id`: Identifier for the cable system. - - `line_length`: Length of the cable system \\[m\\]. - - `cable`: Initial [`CablePosition`](@ref) object defining a cable position and phase mapping. - - # Returns - - - A [`LineCableSystem`](@ref) object initialized with a single cable position. - - # Examples - - ```julia - cable_design = CableDesign("example", nominal_data, components_dict) - cablepos1 = CablePosition(cable_design, 0.0, 0.0, Dict("core" => 1)) - - cable_system = $(FUNCTIONNAME)("test_case_1", 1000.0, cablepos1) - println(cable_system.num_phases) # Prints number of unique phase assignments - ``` - - # See also - - - [`CablePosition`](@ref) - - [`CableDesign`](@ref) - """ - @inline function LineCableSystem{T}( - system_id::String, - line_length::T, - cable::CablePosition{T}, - ) where {T <: REALSCALAR} - # phase accounting from this single position - conn = cable.conn - # count unique non-zero phases - nph = count(x -> x > 0, unique(conn)) - return new{T}(system_id, line_length, 1, nph, CablePosition{T}[cable]) - end - - @doc """ - $(TYPEDSIGNATURES) - - **Strict numeric kernel**. Builds a typed `LineCableSystem{T}` from a vector of `CablePosition{T}`. - """ - @inline function LineCableSystem{T}( - system_id::String, - line_length::T, - cables::Vector{CablePosition{T}}, - ) where {T <: REALSCALAR} - @assert !isempty(cables) "At least one CablePosition must be provided" - # flatten & count phases - assigned = unique(vcat((cp.conn for cp in cables)...)) - nph = count(x -> x > 0, assigned) - return new{T}(system_id, line_length, length(cables), nph, cables) - end -end - -""" -$(TYPEDSIGNATURES) - -Weakly-typed constructor. Infers scalar type `T` from `line_length` and the `cable` (or its design), coerces as needed, and calls the strict kernel. -""" -function LineCableSystem( - system_id::String, - line_length::Number, - cable::CablePosition, -) - T = resolve_T(line_length, cable) - return LineCableSystem{T}( - system_id, - coerce_to_T(line_length, T), - coerce_to_T(cable, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Weakly-typed convenience constructor. Builds a `CablePosition` from a `CableDesign` and coordinates, then constructs the system. -""" -function LineCableSystem( - system_id::String, - line_length::Number, - cable::CableDesign, - horz::Number, - vert::Number, - conn::Union{Dict{String, Int}, Nothing} = nothing, -) - pos = CablePosition(cable, horz, vert, conn) - return LineCableSystem(system_id, line_length, pos) -end - -# Outer (maximum) radius of the last component of a position's design -@inline function _outer_radius(cp::CablePosition) - comp = cp.design_data.components[end] - return max(comp.conductor_group.radius_ext, comp.insulator_group.radius_ext) -end - -# True if two cable disks overlap (strictly), evaluated in a common scalar type T -@inline function _overlaps(a::CablePosition, b::CablePosition, ::Type{T}) where {T} - x1 = coerce_to_T(a.horz, T) - y1 = coerce_to_T(a.vert, T) - r1 = coerce_to_T(_outer_radius(a), T) - x2 = coerce_to_T(b.horz, T) - y2 = coerce_to_T(b.vert, T) - r2 = coerce_to_T(_outer_radius(b), T) - d = hypot(x1 - x2, y1 - y2) - return d < (r1 + r2) # strict overlap; grazing contact (==) allowed -end - -""" -$(TYPEDSIGNATURES) - -Adds a new cable position to an existing [`LineCableSystem`](@ref), updating its phase mapping and cable count. If adding the position introduces a different numeric scalar type, the system is **promoted** and the promoted system is returned. Otherwise, mutation happens in place. - -# Arguments - -- `system`: Instance of [`LineCableSystem`](@ref) to which the cable will be added. -- `cable`: A [`CableDesign`](@ref) object defining the cable structure. -- `horz`: Horizontal coordinate \\[m\\]. -- `vert`: Vertical coordinate \\[m\\]. -- `conn`: Dictionary mapping component names to phase indices, or `nothing` for automatic assignment. - -# Returns - -- The modified [`LineCableSystem`](@ref) object with the new cable added. - -# Examples - -```julia -cable_design = CableDesign("example", nominal_data, components_dict) - -# Define coordinates for two cables -xa, ya = 0.0, -1.0 -xb, yb = 1.0, -2.0 - -# Create initial system with one cable -cablepos1 = CablePosition(cable_design, xa, ya, Dict("core" => 1)) -cable_system = LineCableSystem("test_case_1", 1000.0, cablepos1) - -# Add second cable to system -$(FUNCTIONNAME)(cable_system, cable_design, xb, yb, Dict("core" => 2)) - -println(cable_system.num_cables) # Prints: 2 -``` - -# See also - -- [`LineCableSystem`](@ref) -- [`CablePosition`](@ref) -- [`CableDesign`](@ref) -""" -function add!(system::LineCableSystem{T}, pos::CablePosition) where {T} - # Decide the common numeric type first - Tnew = resolve_T(system, pos) - - # Geometric guard once, in a common type (no mutation, no allocation) - for cp in system.cables - if _overlaps(cp, pos, Tnew) - throw( - ArgumentError( - "Cable position overlaps an existing cable (disks intersect).", - ), - ) - end - end - - if Tnew === T - posT = coerce_to_T(pos, T) # identity if already T - push!(system.cables, posT) - system.num_cables += 1 - assigned = unique(vcat((cp.conn for cp in system.cables)...)) - system.num_phases = count(x -> x > 0, assigned) - return system - else - @warn """ - Adding a `$Tnew` position to a `LineCableSystem{$T}` returns a **promoted** system. - Capture the result: system = add!(system, position) - """ - sysT = coerce_to_T(system, Tnew) - posT = coerce_to_T(pos, Tnew) - push!(sysT.cables, posT) - sysT.num_cables += 1 - assigned = unique(vcat((cp.conn for cp in sysT.cables)...)) - sysT.num_phases = count(x -> x > 0, assigned) - return sysT - end -end - -""" -$(TYPEDSIGNATURES) - -Convenience `add!` that accepts a cable design and coordinates (and optional mapping). -Builds a [`CablePosition`](@ref) and forwards to `add!(system, pos)`. -""" -function add!( - system::LineCableSystem{T}, - cable::CableDesign, - horz::Number, - vert::Number, - conn::Union{Dict{String, Int}, Nothing} = nothing, -) where {T} - pos = CablePosition(cable, horz, vert, conn) - return add!(system, pos) # may mutate or return a promoted system -end - -include("linecablesystem/dataframe.jl") -include("linecablesystem/base.jl") + +""" +$(TYPEDEF) + +Represents a physically defined cable with position and phase mapping within a system. + +$(TYPEDFIELDS) +""" +struct CablePosition{T <: REALSCALAR} + "The [`CableDesign`](@ref) object assigned to this cable position." + design_data::CableDesign{T} + "Horizontal coordinate \\[m\\]." + horz::T + "Vertical coordinate \\[m\\]." + vert::T + "Phase mapping vector (aligned with design_data.components)." + conn::Vector{Int} + + @doc """ + $(TYPEDSIGNATURES) + + Constructs a [`CablePosition`](@ref) instance with specified cable design, coordinates, and phase mapping. + + # Arguments + + - `cable`: A [`CableDesign`](@ref) object defining the cable structure. + - `horz`: Horizontal coordinate \\[m\\]. + - `vert`: Vertical coordinate \\[m\\]. + - `conn`: A dictionary mapping component names to phase indices, or `nothing` for default mapping. + + # Returns + + - A [`CablePosition`](@ref) object with the assigned cable design, coordinates, and phase mapping. + + !!! note "Phase mapping" + The `conn` argument is a `Dict` that maps the cable components to their respective phases. The values (1, 2, 3) represent the phase numbers (A, B, C) in a three-phase system. Components mapped to phase 0 will be Kron-eliminated (grounded). Components set to the same phase will be bundled into an equivalent phase. + + # Examples + + ```julia + cable_design = CableDesign("example", nominal_data, components_dict) + xa, ya = 0.0, -1.0 # Coordinates in meters + + # With explicit phase mapping + cablepos1 = $(FUNCTIONNAME)(cable_design, xa, ya, Dict("core" => 1)) + + # With default phase mapping (first component to phase 1, others to 0) + default_cablepos = $(FUNCTIONNAME)(cable_design, xa, ya) + ``` + + # See also + + - [`CableDesign`](@ref) + """ + function CablePosition{T}( + cable::CableDesign{T}, + horz::T, + vert::T, + conn::Vector{Int}, + ) where {T <: REALSCALAR} + # Validate: cable not empty + @assert !isempty(cable.components) "CableDesign must contain at least one component" + + # Find outermost radius (last component) + last_comp = cable.components[end] + r_cond = last_comp.conductor_group.radius_ext + r_ins = last_comp.insulator_group.radius_ext + r_max = max(r_cond, r_ins) + + # Validate vertical position + if iszero(vert) + throw( + ArgumentError( + "Vertical position cannot be exactly at the air/earth interface (z=0)", + ), + ) + end + if abs(vert) < r_max + throw( + ArgumentError( + "Vertical position |$vert| must be ≥ cable's outer radius $r_max to avoid crossing z=0", + ), + ) + end + + return new{T}(cable, horz, vert, conn) + end +end + +""" +$(TYPEDSIGNATURES) + +**Weakly-typed constructor** that infers `T` from the `cable` and coordinates, builds/validates the phase mapping, coerces inputs to `T`, and calls the typed kernel. +""" +function CablePosition( + cable::Union{CableDesign, Nothing}, + horz::Number, + vert::Number, + conn::Union{Dict{String, Int}, Nothing} = nothing, +) + @assert !isnothing(cable) "A valid CableDesign must be provided" + @assert !isempty(cable.components) "CableDesign must contain at least one component" + + # Build phase mapping vector aligned to component order + names = [comp.id for comp in cable.components] + conn_vector = if isnothing(conn) + [i == 1 ? 1 : 0 for i in 1:length(names)] # default: first component → phase 1, others grounded + else + [get(conn, name, 0) for name in names] + end + + # Validate provided mapping keys exist (only when conn was given) + if conn !== nothing + for component_id in keys(conn) + if !(component_id in names) + throw( + ArgumentError( + "Component ID '$component_id' not found in the cable design.", + ), + ) + end + end + end + + # Warn if all grounded + !all(iszero, conn_vector) || + @warn("At least one component should be assigned to a non-zero phase.") + + # Resolve scalar type and coerce — with identity-preserving pass-through + T = resolve_T(cable, horz, vert) + cableT = coerce_to_T(cable, T) + horzT = (horz isa T) ? horz : coerce_to_T(horz, T) + vertT = (vert isa T) ? vert : coerce_to_T(vert, T) + + return CablePosition{T}(cableT, horzT, vertT, conn_vector) +end + +""" +$(TYPEDEF) + +Represents a cable system configuration, defining the physical structure, cables, and their positions. + +$(TYPEDFIELDS) +""" +mutable struct LineCableSystem{T <: REALSCALAR} + "Unique identifier for the system." + system_id::String + "Length of the cable system \\[m\\]." + line_length::T + "Number of cables in the system." + num_cables::Int + "Number of actual phases in the system." + num_phases::Int + "Cross-section cable positions." + cables::Vector{CablePosition{T}} + + @doc """ + $(TYPEDSIGNATURES) + + Constructs a [`LineCableSystem`](@ref) with an initial cable position and system parameters. + + # Arguments + + - `system_id`: Identifier for the cable system. + - `line_length`: Length of the cable system \\[m\\]. + - `cable`: Initial [`CablePosition`](@ref) object defining a cable position and phase mapping. + + # Returns + + - A [`LineCableSystem`](@ref) object initialized with a single cable position. + + # Examples + + ```julia + cable_design = CableDesign("example", nominal_data, components_dict) + cablepos1 = CablePosition(cable_design, 0.0, 0.0, Dict("core" => 1)) + + cable_system = $(FUNCTIONNAME)("test_case_1", 1000.0, cablepos1) + println(cable_system.num_phases) # Prints number of unique phase assignments + ``` + + # See also + + - [`CablePosition`](@ref) + - [`CableDesign`](@ref) + """ + @inline function LineCableSystem{T}( + system_id::String, + line_length::T, + cable::CablePosition{T}, + ) where {T <: REALSCALAR} + # phase accounting from this single position + conn = cable.conn + # count unique non-zero phases + nph = count(x -> x > 0, unique(conn)) + return new{T}(system_id, line_length, 1, nph, CablePosition{T}[cable]) + end + + @doc """ + $(TYPEDSIGNATURES) + + **Strict numeric kernel**. Builds a typed `LineCableSystem{T}` from a vector of `CablePosition{T}`. + """ + @inline function LineCableSystem{T}( + system_id::String, + line_length::T, + cables::Vector{CablePosition{T}}, + ) where {T <: REALSCALAR} + @assert !isempty(cables) "At least one CablePosition must be provided" + # flatten & count phases + assigned = unique(vcat((cp.conn for cp in cables)...)) + nph = count(x -> x > 0, assigned) + return new{T}(system_id, line_length, length(cables), nph, cables) + end +end + +""" +$(TYPEDSIGNATURES) + +Weakly-typed constructor. Infers scalar type `T` from `line_length` and the `cable` (or its design), coerces as needed, and calls the strict kernel. +""" +function LineCableSystem( + system_id::String, + line_length::Number, + cable::CablePosition, +) + T = resolve_T(line_length, cable) + return LineCableSystem{T}( + system_id, + coerce_to_T(line_length, T), + coerce_to_T(cable, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Weakly-typed convenience constructor. Builds a `CablePosition` from a `CableDesign` and coordinates, then constructs the system. +""" +function LineCableSystem( + system_id::String, + line_length::Number, + cable::CableDesign, + horz::Number, + vert::Number, + conn::Union{Dict{String, Int}, Nothing} = nothing, +) + pos = CablePosition(cable, horz, vert, conn) + return LineCableSystem(system_id, line_length, pos) +end + +# Outer (maximum) radius of the last component of a position's design +@inline function _outer_radius(cp::CablePosition) + comp = cp.design_data.components[end] + return max(comp.conductor_group.radius_ext, comp.insulator_group.radius_ext) +end + +# True if two cable disks overlap (strictly), evaluated in a common scalar type T +@inline function _overlaps(a::CablePosition, b::CablePosition, ::Type{T}) where {T} + x1 = coerce_to_T(a.horz, T) + y1 = coerce_to_T(a.vert, T) + r1 = coerce_to_T(_outer_radius(a), T) + x2 = coerce_to_T(b.horz, T) + y2 = coerce_to_T(b.vert, T) + r2 = coerce_to_T(_outer_radius(b), T) + d = hypot(x1 - x2, y1 - y2) + return d < (r1 + r2) # strict overlap; grazing contact (==) allowed +end + +""" +$(TYPEDSIGNATURES) + +Adds a new cable position to an existing [`LineCableSystem`](@ref), updating its phase mapping and cable count. If adding the position introduces a different numeric scalar type, the system is **promoted** and the promoted system is returned. Otherwise, mutation happens in place. + +# Arguments + +- `system`: Instance of [`LineCableSystem`](@ref) to which the cable will be added. +- `cable`: A [`CableDesign`](@ref) object defining the cable structure. +- `horz`: Horizontal coordinate \\[m\\]. +- `vert`: Vertical coordinate \\[m\\]. +- `conn`: Dictionary mapping component names to phase indices, or `nothing` for automatic assignment. + +# Returns + +- The modified [`LineCableSystem`](@ref) object with the new cable added. + +# Examples + +```julia +cable_design = CableDesign("example", nominal_data, components_dict) + +# Define coordinates for two cables +xa, ya = 0.0, -1.0 +xb, yb = 1.0, -2.0 + +# Create initial system with one cable +cablepos1 = CablePosition(cable_design, xa, ya, Dict("core" => 1)) +cable_system = LineCableSystem("test_case_1", 1000.0, cablepos1) + +# Add second cable to system +$(FUNCTIONNAME)(cable_system, cable_design, xb, yb, Dict("core" => 2)) + +println(cable_system.num_cables) # Prints: 2 +``` + +# See also + +- [`LineCableSystem`](@ref) +- [`CablePosition`](@ref) +- [`CableDesign`](@ref) +""" +function add!(system::LineCableSystem{T}, pos::CablePosition) where {T} + # Decide the common numeric type first + Tnew = resolve_T(system, pos) + + # Geometric guard once, in a common type (no mutation, no allocation) + for cp in system.cables + if _overlaps(cp, pos, Tnew) + throw( + ArgumentError( + "Cable position overlaps an existing cable (disks intersect).", + ), + ) + end + end + + if Tnew === T + posT = coerce_to_T(pos, T) # identity if already T + push!(system.cables, posT) + system.num_cables += 1 + assigned = unique(vcat((cp.conn for cp in system.cables)...)) + system.num_phases = count(x -> x > 0, assigned) + return system + else + @warn """ + Adding a `$Tnew` position to a `LineCableSystem{$T}` returns a **promoted** system. + Capture the result: system = add!(system, position) + """ + sysT = coerce_to_T(system, Tnew) + posT = coerce_to_T(pos, Tnew) + push!(sysT.cables, posT) + sysT.num_cables += 1 + assigned = unique(vcat((cp.conn for cp in sysT.cables)...)) + sysT.num_phases = count(x -> x > 0, assigned) + return sysT + end +end + +""" +$(TYPEDSIGNATURES) + +Convenience `add!` that accepts a cable design and coordinates (and optional mapping). +Builds a [`CablePosition`](@ref) and forwards to `add!(system, pos)`. +""" +function add!( + system::LineCableSystem{T}, + cable::CableDesign, + horz::Number, + vert::Number, + conn::Union{Dict{String, Int}, Nothing} = nothing, +) where {T} + pos = CablePosition(cable, horz, vert, conn) + return add!(system, pos) # may mutate or return a promoted system +end + +include("linecablesystem/dataframe.jl") +include("linecablesystem/base.jl") diff --git a/src/datamodel/linecablesystem/base.jl b/src/datamodel/linecablesystem/base.jl index 327c4d38..7093747e 100644 --- a/src/datamodel/linecablesystem/base.jl +++ b/src/datamodel/linecablesystem/base.jl @@ -1,35 +1,35 @@ - -Base.eltype(::CablePosition{T}) where {T} = T -Base.eltype(::Type{CablePosition{T}}) where {T} = T -Base.eltype(::LineCableSystem{T}) where {T} = T -Base.eltype(::Type{LineCableSystem{T}}) where {T} = T - -function Base.show(io::IO, ::MIME"text/plain", system::LineCableSystem) - # Print top level info - println( - io, - "LineCableSystem \"$(system.system_id)\": [line_length=$(system.line_length), num_cables=$(system.num_cables), num_phases=$(system.num_phases)]", - ) - - # Print cable definitions - println(io, "└─ $(length(system.cables))-element CablePosition:") - - # Display each cable definition - for (i, cable_position) in enumerate(system.cables) - # Cable prefix - prefix = i == length(system.cables) ? " └─" : " ├─" - - # Format connections as a string - components = [comp.id for comp in cable_position.design_data.components] - conn_str = join( - ["$(comp)→$(phase)" for (comp, phase) in zip(components, cable_position.conn)], - ", ", - ) - - # Print cable info - println( - io, - "$(prefix) CableDesign \"$(cable_position.design_data.cable_id)\": [horz=$(round(cable_position.horz, sigdigits=4)), vert=$(round(cable_position.vert, sigdigits=4)), conn=($(conn_str))]", - ) - end + +Base.eltype(::CablePosition{T}) where {T} = T +Base.eltype(::Type{CablePosition{T}}) where {T} = T +Base.eltype(::LineCableSystem{T}) where {T} = T +Base.eltype(::Type{LineCableSystem{T}}) where {T} = T + +function Base.show(io::IO, ::MIME"text/plain", system::LineCableSystem) + # Print top level info + println( + io, + "LineCableSystem \"$(system.system_id)\": [line_length=$(system.line_length), num_cables=$(system.num_cables), num_phases=$(system.num_phases)]", + ) + + # Print cable definitions + println(io, "└─ $(length(system.cables))-element CablePosition:") + + # Display each cable definition + for (i, cable_position) in enumerate(system.cables) + # Cable prefix + prefix = i == length(system.cables) ? " └─" : " ├─" + + # Format connections as a string + components = [comp.id for comp in cable_position.design_data.components] + conn_str = join( + ["$(comp)→$(phase)" for (comp, phase) in zip(components, cable_position.conn)], + ", ", + ) + + # Print cable info + println( + io, + "$(prefix) CableDesign \"$(cable_position.design_data.cable_id)\": [horz=$(round(cable_position.horz, sigdigits=4)), vert=$(round(cable_position.vert, sigdigits=4)), conn=($(conn_str))]", + ) + end end \ No newline at end of file diff --git a/src/datamodel/linecablesystem/dataframe.jl b/src/datamodel/linecablesystem/dataframe.jl index c51cef71..22c1b03c 100644 --- a/src/datamodel/linecablesystem/dataframe.jl +++ b/src/datamodel/linecablesystem/dataframe.jl @@ -1,62 +1,62 @@ -import DataFrames: DataFrame - -""" -$(TYPEDSIGNATURES) - -Generates a summary DataFrame for cable positions and phase mappings within a [`LineCableSystem`](@ref). - -# Arguments - -- `system`: A [`LineCableSystem`](@ref) object containing the cable definitions and their configurations. - -# Returns - -- A `DataFrame` containing: - - `cable_id`: Identifier of each cable design. - - `horz`: Horizontal coordinate of each cable \\[m\\]. - - `vert`: Vertical coordinate of each cable \\[m\\]. - - `phase_mapping`: Human-readable string representation mapping each cable component to its assigned phase. - -# Examples - -```julia -df = $(FUNCTIONNAME)(cable_system) -println(df) -# Output: -# │ cable_id │ horz │ vert │ phase_mapping │ -# │------------│------│-------│-------------------------│ -# │ "Cable1" │ 0.0 │ -0.5 │ core: 1, sheath: 0 │ -# │ "Cable2" │ 0.35 │ -1.25 │ core: 2, sheath: 0 │ -``` - -# See also - -- [`LineCableSystem`](@ref) -- [`CablePosition`](@ref) -""" -function DataFrame(system::LineCableSystem)::DataFrame - cable_ids = String[] - horz_coords = Number[] - vert_coords = Number[] - mappings = String[] - - for cable_position in system.cables - push!(cable_ids, cable_position.design_data.cable_id) - push!(horz_coords, cable_position.horz) - push!(vert_coords, cable_position.vert) - - component_names = [comp.id for comp in cable_position.design_data.components] - mapping_str = join( - ["$(name): $(phase)" for (name, phase) in zip(component_names, cable_position.conn)], - ", ", - ) - push!(mappings, mapping_str) - end - data = DataFrame( - cable_id=cable_ids, - horz=horz_coords, - vert=vert_coords, - phase_mapping=mappings - ) - return data +import DataFrames: DataFrame + +""" +$(TYPEDSIGNATURES) + +Generates a summary DataFrame for cable positions and phase mappings within a [`LineCableSystem`](@ref). + +# Arguments + +- `system`: A [`LineCableSystem`](@ref) object containing the cable definitions and their configurations. + +# Returns + +- A `DataFrame` containing: + - `cable_id`: Identifier of each cable design. + - `horz`: Horizontal coordinate of each cable \\[m\\]. + - `vert`: Vertical coordinate of each cable \\[m\\]. + - `phase_mapping`: Human-readable string representation mapping each cable component to its assigned phase. + +# Examples + +```julia +df = $(FUNCTIONNAME)(cable_system) +println(df) +# Output: +# │ cable_id │ horz │ vert │ phase_mapping │ +# │------------│------│-------│-------------------------│ +# │ "Cable1" │ 0.0 │ -0.5 │ core: 1, sheath: 0 │ +# │ "Cable2" │ 0.35 │ -1.25 │ core: 2, sheath: 0 │ +``` + +# See also + +- [`LineCableSystem`](@ref) +- [`CablePosition`](@ref) +""" +function DataFrame(system::LineCableSystem)::DataFrame + cable_ids = String[] + horz_coords = Number[] + vert_coords = Number[] + mappings = String[] + + for cable_position in system.cables + push!(cable_ids, cable_position.design_data.cable_id) + push!(horz_coords, cable_position.horz) + push!(vert_coords, cable_position.vert) + + component_names = [comp.id for comp in cable_position.design_data.components] + mapping_str = join( + ["$(name): $(phase)" for (name, phase) in zip(component_names, cable_position.conn)], + ", ", + ) + push!(mappings, mapping_str) + end + data = DataFrame( + cable_id=cable_ids, + horz=horz_coords, + vert=vert_coords, + phase_mapping=mappings + ) + return data end \ No newline at end of file diff --git a/src/datamodel/macros.jl b/src/datamodel/macros.jl index 3da0d9ef..2d2a3f57 100644 --- a/src/datamodel/macros.jl +++ b/src/datamodel/macros.jl @@ -1,184 +1,184 @@ -""" -$(TYPEDSIGNATURES) - -Determines the promoted numeric element type for convenience constructors of component `C`. The promotion is computed across the values of `coercive_fields(C)`, extracted from the normalized `NamedTuple` `ntv` produced by [`validate!`](@ref). This ensures all numeric fields that participate in calculations share a common element type (e.g., `Float64`, `Measurement{Float64}`). - -# Arguments - -- `::Type{C}`: Component type \\[dimensionless\\]. -- `ntv`: Normalized `NamedTuple` returned by `validate!` \\[dimensionless\\]. -- `_order::Tuple`: Ignored by this method; present for arity symmetry with `_coerced_args` \\[dimensionless\\]. - -# Returns - -- The promoted numeric element type \\[dimensionless\\]. - -# Examples - -```julia -Tp = $(FUNCTIONNAME)(Tubular, (radius_in=0.01, radius_ext=0.02, material_props=mat, temperature=20.0), ()) -``` -""" -@inline _promotion_T(::Type{C}, ntv, _order::Tuple) where {C} = - resolve_T((getfield(ntv, k) for k in coercive_fields(C))...) - -""" -$(TYPEDSIGNATURES) - -Builds the positional argument tuple to feed the **typed core** constructor, coercing only the fields returned by `coercive_fields(C)` to type `Tp`. Non‑coercive fields (e.g., integer flags) are passed through unchanged. Field order is controlled by `order` (a tuple of symbols), typically `(required_fields(C)..., keyword_fields(C)...)`. - -# Arguments - -- `::Type{C}`: Component type \\[dimensionless\\]. -- `ntv`: Normalized `NamedTuple` returned by `validate!` \\[dimensionless\\]. -- `Tp`: Target element type for numeric coercion \\[dimensionless\\]. -- `order::Tuple`: Field order used to assemble the positional tuple \\[dimensionless\\]. - -# Returns - -- A `Tuple` of arguments in the requested order, with coercions applied where configured. - -# Examples - -```julia -args = $(FUNCTIONNAME)(Tubular, ntv, Float64, (:radius_in, :radius_ext, :material_props, :temperature)) -``` -""" -@inline _coerced_args(::Type{C}, ntv, Tp, order::Tuple) where {C} = - tuple(( - let k = s, v = getfield(ntv, s) - (s in coercive_fields(C)) ? coerce_to_T(v, Tp) : v - end - for s in order - )...) - -""" -$(TYPEDSIGNATURES) - -Utility for the constructor macro to *materialize* input tuples from either: - -- A tuple literal expression (e.g., `(:a, :b, :c)`), or -- A bound constant tuple name (e.g., `_REQ_TUBULAR`). - -Used to keep macro call sites short while allowing both styles. - -# Arguments - -- `mod`: Module where constants are resolved \\[dimensionless\\]. -- `x`: Expression or symbol representing a tuple \\[dimensionless\\]. - -# Returns - -- A standard Julia `Tuple` (of symbols or defaults). - -# Errors - -- `ErrorException` if `x` is neither a tuple literal nor a bound constant name. - -# Examples - -```julia -syms = $(FUNCTIONNAME)(@__MODULE__, :( :a, :b )) -syms = $(FUNCTIONNAME)(@__MODULE__, :_REQ_TUBULAR) -``` -""" -_ctor_materialize(mod, x) = - x === :(()) ? () : - x isa Expr && x.head === :tuple ? x.args : - x isa Symbol ? Base.eval(mod, x) : - Base.error("@construct: expected tuple literal or const tuple, got $(x)") - -using MacroTools: postwalk -""" -$(TYPEDSIGNATURES) - -Generates a weakly‑typed convenience constructor for a component `T`. The generated method: - -1. Accepts exactly the positional fields listed in `REQ`. -2. Accepts keyword arguments listed in `OPT` with defaults `DEFS`. -3. Calls `validate!(T, ...)` forwarding **variables** (not defaults), -4. Computes the promotion type via `_promotion_T(T, ntv, order)`, -5. Coerces only `coercive_fields(T)` via `_coerced_args(T, ntv, Tp, order)`, -6. Delegates to the numeric core `T(...)` with the coerced positional tuple. - -`REQ`, `OPT`, and `DEFS` can be provided as tuple literals or as names of bound constant tuples. `order` is implicitly `(REQ..., OPT...)`. - -# Arguments - -- `T`: Component type (bare name) \\[dimensionless\\]. -- `REQ`: Tuple of required positional field names \\[dimensionless\\]. -- `OPT`: Tuple of optional keyword field names \\[dimensionless\\]. Defaults to `()`. -- `DEFS`: Tuple of default values matching `OPT` \\[dimensionless\\]. Defaults to `()`. - -# Returns - -- A method definition for the weakly‑typed constructor. - -# Examples - -```julia -const _REQ_TUBULAR = (:radius_in, :radius_ext, :material_props) -const _OPT_TUBULAR = (:temperature,) -const _DEFS_TUBULAR = (T₀,) - -@construct Tubular _REQ_TUBULAR _OPT_TUBULAR _DEFS_TUBULAR - -# Expands roughly to: -# function Tubular(radius_in, radius_ext, material_props; temperature=T₀) -# ntv = validate!(Tubular, radius_in, radius_ext, material_props; temperature=temperature) -# Tp = _promotion_T(Tubular, ntv, (:radius_in, :radius_ext, :material_props, :temperature)) -# args = _coerced_args(Tubular, ntv, Tp, (:radius_in, :radius_ext, :material_props, :temperature)) -# return Tubular(args...) -# end -``` - -# Notes - -- Defaults supplied in `DEFS` are **escaped** into the method signature (evaluated at macro expansion time). -- Forwarding into `validate!` always uses *variables* (e.g., `temperature=temperature`), never literal defaults. -- The macro is hygiene‑aware; identifiers `validate!`, `_promotion_T`, `_coerced_args`, and the type name are properly escaped. - -# Errors - -- `ErrorException` if `length(OPT) != length(DEFS)`. -""" -macro construct(T, REQ, OPT = :(()), DEFS = :(())) - mod = __module__ - req = Symbol.(_ctor_materialize(mod, REQ)) - opt = Symbol.(_ctor_materialize(mod, OPT)) - dfx = _ctor_materialize(mod, DEFS) - length(opt) == length(dfx) || Base.error("@construct: OPT and DEFS length mismatch") - - # A) signature defaults (escape defaults) - sig_kws = [Expr(:kw, opt[i], esc(dfx[i])) for i in eachindex(opt)] - # forwarding kwargs (variables, not defaults) - pass_kws = [Expr(:kw, s, s) for s in opt] - - # B) flat order tuple - order_syms = (req..., opt...) - order = Expr(:tuple, (QuoteNode.(order_syms))...) - - ex = - isempty(sig_kws) ? quote - function $(T)($(req...)) - ntv = validate!($(T), $(req...)) - Tp = _promotion_T($(T), ntv, $order) - local __args__ = _coerced_args($(T), ntv, Tp, $order) - return $(T)(__args__...) - end - end : quote - function $(T)($(req...); $(sig_kws...)) - ntv = validate!($(T), $(req...); $(pass_kws...)) # C) pass vars - Tp = _promotion_T($(T), ntv, $order) - local __args__ = _coerced_args($(T), ntv, Tp, $order) - return $(T)(__args__...) - end - end - - # hygiene stays as you had it - free = Set{Symbol}([:validate!, :_promotion_T, :_coerced_args, T]) - ex2 = postwalk(ex) do node - node isa Symbol && (node in free) ? esc(node) : node - end - return ex2 -end +""" +$(TYPEDSIGNATURES) + +Determines the promoted numeric element type for convenience constructors of component `C`. The promotion is computed across the values of `coercive_fields(C)`, extracted from the normalized `NamedTuple` `ntv` produced by [`validate!`](@ref). This ensures all numeric fields that participate in calculations share a common element type (e.g., `Float64`, `Measurement{Float64}`). + +# Arguments + +- `::Type{C}`: Component type \\[dimensionless\\]. +- `ntv`: Normalized `NamedTuple` returned by `validate!` \\[dimensionless\\]. +- `_order::Tuple`: Ignored by this method; present for arity symmetry with `_coerced_args` \\[dimensionless\\]. + +# Returns + +- The promoted numeric element type \\[dimensionless\\]. + +# Examples + +```julia +Tp = $(FUNCTIONNAME)(Tubular, (radius_in=0.01, radius_ext=0.02, material_props=mat, temperature=20.0), ()) +``` +""" +@inline _promotion_T(::Type{C}, ntv, _order::Tuple) where {C} = + resolve_T((getfield(ntv, k) for k in coercive_fields(C))...) + +""" +$(TYPEDSIGNATURES) + +Builds the positional argument tuple to feed the **typed core** constructor, coercing only the fields returned by `coercive_fields(C)` to type `Tp`. Non‑coercive fields (e.g., integer flags) are passed through unchanged. Field order is controlled by `order` (a tuple of symbols), typically `(required_fields(C)..., keyword_fields(C)...)`. + +# Arguments + +- `::Type{C}`: Component type \\[dimensionless\\]. +- `ntv`: Normalized `NamedTuple` returned by `validate!` \\[dimensionless\\]. +- `Tp`: Target element type for numeric coercion \\[dimensionless\\]. +- `order::Tuple`: Field order used to assemble the positional tuple \\[dimensionless\\]. + +# Returns + +- A `Tuple` of arguments in the requested order, with coercions applied where configured. + +# Examples + +```julia +args = $(FUNCTIONNAME)(Tubular, ntv, Float64, (:radius_in, :radius_ext, :material_props, :temperature)) +``` +""" +@inline _coerced_args(::Type{C}, ntv, Tp, order::Tuple) where {C} = + tuple(( + let k = s, v = getfield(ntv, s) + (s in coercive_fields(C)) ? coerce_to_T(v, Tp) : v + end + for s in order + )...) + +""" +$(TYPEDSIGNATURES) + +Utility for the constructor macro to *materialize* input tuples from either: + +- A tuple literal expression (e.g., `(:a, :b, :c)`), or +- A bound constant tuple name (e.g., `_REQ_TUBULAR`). + +Used to keep macro call sites short while allowing both styles. + +# Arguments + +- `mod`: Module where constants are resolved \\[dimensionless\\]. +- `x`: Expression or symbol representing a tuple \\[dimensionless\\]. + +# Returns + +- A standard Julia `Tuple` (of symbols or defaults). + +# Errors + +- `ErrorException` if `x` is neither a tuple literal nor a bound constant name. + +# Examples + +```julia +syms = $(FUNCTIONNAME)(@__MODULE__, :( :a, :b )) +syms = $(FUNCTIONNAME)(@__MODULE__, :_REQ_TUBULAR) +``` +""" +_ctor_materialize(mod, x) = + x === :(()) ? () : + x isa Expr && x.head === :tuple ? x.args : + x isa Symbol ? Base.eval(mod, x) : + Base.error("@construct: expected tuple literal or const tuple, got $(x)") + +using MacroTools: postwalk +""" +$(TYPEDSIGNATURES) + +Generates a weakly‑typed convenience constructor for a component `T`. The generated method: + +1. Accepts exactly the positional fields listed in `REQ`. +2. Accepts keyword arguments listed in `OPT` with defaults `DEFS`. +3. Calls `validate!(T, ...)` forwarding **variables** (not defaults), +4. Computes the promotion type via `_promotion_T(T, ntv, order)`, +5. Coerces only `coercive_fields(T)` via `_coerced_args(T, ntv, Tp, order)`, +6. Delegates to the numeric core `T(...)` with the coerced positional tuple. + +`REQ`, `OPT`, and `DEFS` can be provided as tuple literals or as names of bound constant tuples. `order` is implicitly `(REQ..., OPT...)`. + +# Arguments + +- `T`: Component type (bare name) \\[dimensionless\\]. +- `REQ`: Tuple of required positional field names \\[dimensionless\\]. +- `OPT`: Tuple of optional keyword field names \\[dimensionless\\]. Defaults to `()`. +- `DEFS`: Tuple of default values matching `OPT` \\[dimensionless\\]. Defaults to `()`. + +# Returns + +- A method definition for the weakly‑typed constructor. + +# Examples + +```julia +const _REQ_TUBULAR = (:radius_in, :radius_ext, :material_props) +const _OPT_TUBULAR = (:temperature,) +const _DEFS_TUBULAR = (T₀,) + +@construct Tubular _REQ_TUBULAR _OPT_TUBULAR _DEFS_TUBULAR + +# Expands roughly to: +# function Tubular(radius_in, radius_ext, material_props; temperature=T₀) +# ntv = validate!(Tubular, radius_in, radius_ext, material_props; temperature=temperature) +# Tp = _promotion_T(Tubular, ntv, (:radius_in, :radius_ext, :material_props, :temperature)) +# args = _coerced_args(Tubular, ntv, Tp, (:radius_in, :radius_ext, :material_props, :temperature)) +# return Tubular(args...) +# end +``` + +# Notes + +- Defaults supplied in `DEFS` are **escaped** into the method signature (evaluated at macro expansion time). +- Forwarding into `validate!` always uses *variables* (e.g., `temperature=temperature`), never literal defaults. +- The macro is hygiene‑aware; identifiers `validate!`, `_promotion_T`, `_coerced_args`, and the type name are properly escaped. + +# Errors + +- `ErrorException` if `length(OPT) != length(DEFS)`. +""" +macro construct(T, REQ, OPT = :(()), DEFS = :(())) + mod = __module__ + req = Symbol.(_ctor_materialize(mod, REQ)) + opt = Symbol.(_ctor_materialize(mod, OPT)) + dfx = _ctor_materialize(mod, DEFS) + length(opt) == length(dfx) || Base.error("@construct: OPT and DEFS length mismatch") + + # A) signature defaults (escape defaults) + sig_kws = [Expr(:kw, opt[i], esc(dfx[i])) for i in eachindex(opt)] + # forwarding kwargs (variables, not defaults) + pass_kws = [Expr(:kw, s, s) for s in opt] + + # B) flat order tuple + order_syms = (req..., opt...) + order = Expr(:tuple, (QuoteNode.(order_syms))...) + + ex = + isempty(sig_kws) ? quote + function $(T)($(req...)) + ntv = validate!($(T), $(req...)) + Tp = _promotion_T($(T), ntv, $order) + local __args__ = _coerced_args($(T), ntv, Tp, $order) + return $(T)(__args__...) + end + end : quote + function $(T)($(req...); $(sig_kws...)) + ntv = validate!($(T), $(req...); $(pass_kws...)) # C) pass vars + Tp = _promotion_T($(T), ntv, $order) + local __args__ = _coerced_args($(T), ntv, Tp, $order) + return $(T)(__args__...) + end + end + + # hygiene stays as you had it + free = Set{Symbol}([:validate!, :_promotion_T, :_coerced_args, T]) + ex2 = postwalk(ex) do node + node isa Symbol && (node in free) ? esc(node) : node + end + return ex2 +end diff --git a/src/datamodel/nominaldata.jl b/src/datamodel/nominaldata.jl index 4e75ece0..edd86b0a 100644 --- a/src/datamodel/nominaldata.jl +++ b/src/datamodel/nominaldata.jl @@ -1,96 +1,96 @@ - -""" -$(TYPEDEF) - -Stores nominal electrical and geometric parameters for a cable design. - -$(TYPEDFIELDS) -""" -struct NominalData{T<:REALSCALAR} - "Cable designation as per DIN VDE 0271/0276." - designation_code::Union{Nothing,String} - "Rated phase-to-earth voltage \\[kV\\]." - U0::Union{Nothing,T} - "Rated phase-to-phase voltage \\[kV\\]." - U::Union{Nothing,T} - "Cross-sectional area of the conductor \\[mm²\\]." - conductor_cross_section::Union{Nothing,T} - "Cross-sectional area of the screen \\[mm²\\]." - screen_cross_section::Union{Nothing,T} - "Cross-sectional area of the armor \\[mm²\\]." - armor_cross_section::Union{Nothing,T} - "Base (DC) resistance of the cable core \\[Ω/km\\]." - resistance::Union{Nothing,T} - "Capacitance of the main insulation \\[μF/km\\]." - capacitance::Union{Nothing,T} - "Inductance of the cable (trifoil formation) \\[mH/km\\]." - inductance::Union{Nothing,T} - - # --- Tight / typed kernel: assumes values already coerced to T (or nothing) - @inline function NominalData{T}(; - designation_code::Union{Nothing,String}=nothing, - U0::Union{Nothing,T}=nothing, - U::Union{Nothing,T}=nothing, - conductor_cross_section::Union{Nothing,T}=nothing, - screen_cross_section::Union{Nothing,T}=nothing, - armor_cross_section::Union{Nothing,T}=nothing, - resistance::Union{Nothing,T}=nothing, - capacitance::Union{Nothing,T}=nothing, - inductance::Union{Nothing,T}=nothing, - ) where {T<:REALSCALAR} - new{T}( - designation_code, - U0, - U, - conductor_cross_section, - screen_cross_section, - armor_cross_section, - resistance, - capacitance, - inductance, - ) - end -end - -""" -$(TYPEDSIGNATURES) - -Weakly-typed constructor that infers the target scalar type `T` from the **provided numeric kwargs** (ignoring `nothing` and the string designation), coerces numerics to `T`, and calls the strict kernel. - -If no numeric kwargs are provided, it defaults to `Float64`. -""" -@inline function NominalData(; - designation_code::Union{Nothing,String}=nothing, - U0::Union{Nothing,Number}=nothing, - U::Union{Nothing,Number}=nothing, - conductor_cross_section::Union{Nothing,Number}=nothing, - screen_cross_section::Union{Nothing,Number}=nothing, - armor_cross_section::Union{Nothing,Number}=nothing, - resistance::Union{Nothing,Number}=nothing, - capacitance::Union{Nothing,Number}=nothing, - inductance::Union{Nothing,Number}=nothing, -) - # collect provided numerics (skip `nothing`) - nums = Tuple(x for x in - (U0, U, conductor_cross_section, screen_cross_section, armor_cross_section, - resistance, capacitance, inductance) if x !== nothing) - - # infer T from numerics, fallback to Float64 if none - T = isempty(nums) ? Float64 : resolve_T(nums...) - - return NominalData{T}(; - designation_code=designation_code, - U0=(U0 === nothing ? nothing : coerce_to_T(U0, T)), - U=(U === nothing ? nothing : coerce_to_T(U, T)), - conductor_cross_section=(conductor_cross_section === nothing ? nothing : coerce_to_T(conductor_cross_section, T)), - screen_cross_section=(screen_cross_section === nothing ? nothing : coerce_to_T(screen_cross_section, T)), - armor_cross_section=(armor_cross_section === nothing ? nothing : coerce_to_T(armor_cross_section, T)), - resistance=(resistance === nothing ? nothing : coerce_to_T(resistance, T)), - capacitance=(capacitance === nothing ? nothing : coerce_to_T(capacitance, T)), - inductance=(inductance === nothing ? nothing : coerce_to_T(inductance, T)), - ) -end - -include("nominaldata/base.jl") - - + +""" +$(TYPEDEF) + +Stores nominal electrical and geometric parameters for a cable design. + +$(TYPEDFIELDS) +""" +struct NominalData{T<:REALSCALAR} + "Cable designation as per DIN VDE 0271/0276." + designation_code::Union{Nothing,String} + "Rated phase-to-earth voltage \\[kV\\]." + U0::Union{Nothing,T} + "Rated phase-to-phase voltage \\[kV\\]." + U::Union{Nothing,T} + "Cross-sectional area of the conductor \\[mm²\\]." + conductor_cross_section::Union{Nothing,T} + "Cross-sectional area of the screen \\[mm²\\]." + screen_cross_section::Union{Nothing,T} + "Cross-sectional area of the armor \\[mm²\\]." + armor_cross_section::Union{Nothing,T} + "Base (DC) resistance of the cable core \\[Ω/km\\]." + resistance::Union{Nothing,T} + "Capacitance of the main insulation \\[μF/km\\]." + capacitance::Union{Nothing,T} + "Inductance of the cable (trifoil formation) \\[mH/km\\]." + inductance::Union{Nothing,T} + + # --- Tight / typed kernel: assumes values already coerced to T (or nothing) + @inline function NominalData{T}(; + designation_code::Union{Nothing,String}=nothing, + U0::Union{Nothing,T}=nothing, + U::Union{Nothing,T}=nothing, + conductor_cross_section::Union{Nothing,T}=nothing, + screen_cross_section::Union{Nothing,T}=nothing, + armor_cross_section::Union{Nothing,T}=nothing, + resistance::Union{Nothing,T}=nothing, + capacitance::Union{Nothing,T}=nothing, + inductance::Union{Nothing,T}=nothing, + ) where {T<:REALSCALAR} + new{T}( + designation_code, + U0, + U, + conductor_cross_section, + screen_cross_section, + armor_cross_section, + resistance, + capacitance, + inductance, + ) + end +end + +""" +$(TYPEDSIGNATURES) + +Weakly-typed constructor that infers the target scalar type `T` from the **provided numeric kwargs** (ignoring `nothing` and the string designation), coerces numerics to `T`, and calls the strict kernel. + +If no numeric kwargs are provided, it defaults to `Float64`. +""" +@inline function NominalData(; + designation_code::Union{Nothing,String}=nothing, + U0::Union{Nothing,Number}=nothing, + U::Union{Nothing,Number}=nothing, + conductor_cross_section::Union{Nothing,Number}=nothing, + screen_cross_section::Union{Nothing,Number}=nothing, + armor_cross_section::Union{Nothing,Number}=nothing, + resistance::Union{Nothing,Number}=nothing, + capacitance::Union{Nothing,Number}=nothing, + inductance::Union{Nothing,Number}=nothing, +) + # collect provided numerics (skip `nothing`) + nums = Tuple(x for x in + (U0, U, conductor_cross_section, screen_cross_section, armor_cross_section, + resistance, capacitance, inductance) if x !== nothing) + + # infer T from numerics, fallback to Float64 if none + T = isempty(nums) ? Float64 : resolve_T(nums...) + + return NominalData{T}(; + designation_code=designation_code, + U0=(U0 === nothing ? nothing : coerce_to_T(U0, T)), + U=(U === nothing ? nothing : coerce_to_T(U, T)), + conductor_cross_section=(conductor_cross_section === nothing ? nothing : coerce_to_T(conductor_cross_section, T)), + screen_cross_section=(screen_cross_section === nothing ? nothing : coerce_to_T(screen_cross_section, T)), + armor_cross_section=(armor_cross_section === nothing ? nothing : coerce_to_T(armor_cross_section, T)), + resistance=(resistance === nothing ? nothing : coerce_to_T(resistance, T)), + capacitance=(capacitance === nothing ? nothing : coerce_to_T(capacitance, T)), + inductance=(inductance === nothing ? nothing : coerce_to_T(inductance, T)), + ) +end + +include("nominaldata/base.jl") + + diff --git a/src/datamodel/nominaldata/base.jl b/src/datamodel/nominaldata/base.jl index 035a933e..607f9e6f 100644 --- a/src/datamodel/nominaldata/base.jl +++ b/src/datamodel/nominaldata/base.jl @@ -1,4 +1,4 @@ - -# Scalar-type query -Base.eltype(::NominalData{T}) where {T} = T + +# Scalar-type query +Base.eltype(::NominalData{T}) where {T} = T Base.eltype(::Type{NominalData{T}}) where {T} = T \ No newline at end of file diff --git a/src/datamodel/preview.jl b/src/datamodel/preview.jl index 7e8d504a..6eb6a4cb 100644 --- a/src/datamodel/preview.jl +++ b/src/datamodel/preview.jl @@ -1,1081 +1,1081 @@ -using Makie, Colors -using Printf -using Dates -using Statistics - - -# _is_interactive_backend() = nameof(Makie.current_backend()) in (:GLMakie, :WGLMakie) -_is_interactive_backend() = current_backend_symbol() in (:gl, :wgl) -_is_static_backend() = current_backend_symbol() == :cairo -_is_gl_backend() = current_backend_symbol() == :gl - - -# finite & nonnegative -_valid_finite(x, y) = isfinite(x) && isfinite(y) - - -# Tunables (bands & palettes) -# ---------------------------- -const RHO_MIN = 1e-9 # for legend floor -const RHO_METAL_MAX = 1e-6 -const RHO_SEMIMETAL = 1e-4 -const RHO_SEMI_MAX = 1e3 -const RHO_LEAKY_MAX = 1e8 -const RHO_MAX = 1e10 # for legend ceiling - -const METAL_GRADIENT = [ - RGB(0.92, 0.90, 0.86), # warm-silver (copper-ish) - RGB(0.89, 0.89, 0.89), # neutral silver - RGB(0.86, 0.89, 0.92), # cool-silver (aluminium-ish) - RGB(0.70, 0.72, 0.75), # slightly darker metal -] - -const SEMIMETAL_GRADIENT = [ - RGB(0.70, 0.72, 0.75), # gray - RGB(0.80, 0.75, 0.65), # sand/bronze hint -] - -const SEMICON_GRADIENT = [ - RGB(1.00, 0.83, 0.40), # light amber - RGB(0.85, 0.55, 0.18), # dark amber-brown -] - -const LEAKY_GRADIENT = [ - RGB(0.42, 0.55, 0.15), # olive/earthy - RGB(0.13, 0.13, 0.13), # charcoal -] - -const INSULATOR_GRADIENT = [ - RGB(0.07, 0.07, 0.07), # near-black (keep >0 so overlays remain visible) - RGB(0.00, 0.00, 0.00), -] - -# Overlays -const MU_OVERLAY_GRADIENT = [RGB(0.20, 0.50, 0.95), RGB(0.56, 0.00, 0.91)] # blue → indigo -const EPS_OVERLAY_GRADIENT = [RGB(0.00, 0.85, 0.70), RGB(0.00, 0.55, 0.90)] # teal → cyan - - -# Linear interpolation across a list of colors in [0,1] -# robust gradient (no reinterpret) -_interpolate_gradient(colors::Vector{<:Colorant}, t::Real) = begin - n = length(colors); - n >= 2 || throw(ArgumentError("Need ≥ 2 colors")) - tc = clamp(Float64(t), 0, 1) - x = tc * (n - 1) - i = clamp(floor(Int, x) + 1, 1, n - 1) - f = x - (i - 1) - c1 = RGB(colors[i]); - c2 = RGB(colors[i+1]) - RGB( - (1 - f) * red(c1) + f * red(c2), - (1 - f) * green(c1) + f * green(c2), - (1 - f) * blue(c1) + f * blue(c2), - ) -end - -# Log normalization helper: map v∈[a,b] (log10) → t∈[0,1] -_lognorm(v, a, b) = begin - va = clamp(v, min(a, b), max(a, b)) - (log10(va) - log10(a)) / (log10(b) - log10(a)) -end - -_overlay(a::Colors.RGBA, b::Colors.RGBA) = begin - a1, a2 = alpha(a), alpha(b) - out_a = a2 + a1*(1 - a2) - out_a == 0 && return Colors.RGBA(0, 0, 0, 0) - r = (red(b)*a2 + red(a)*a1*(1 - a2)) / out_a - g = (green(b)*a2 + green(a)*a1*(1 - a2)) / out_a - b_ = (blue(b)*a2 + blue(a)*a1*(1 - a2)) / out_a - Colors.RGBA(r, g, b_, out_a) -end - -# Clamp lightness to keep overlays visible on "black" -function _ensure_min_lightness(c::RGB, Lmin::Float64 = 0.07) - hsl = HSL(c) - L = max(hsl.l, Lmin) - rgb = RGB(HSL(hsl.h, hsl.s, L)) - return rgb -end - -# ---------------------------- -# Base color controlled by ρ -# ---------------------------- -function _base_color_from_rho(ρ::Real)::RGB - if !isfinite(ρ) - return INSULATOR_GRADIENT[end] - elseif ρ ≤ RHO_METAL_MAX - t = _lognorm(ρ, 1e-8, RHO_METAL_MAX) - return _interpolate_gradient(METAL_GRADIENT, t) - elseif ρ ≤ RHO_SEMIMETAL - t = _lognorm(ρ, RHO_METAL_MAX, RHO_SEMIMETAL) - return _interpolate_gradient(SEMIMETAL_GRADIENT, t) - elseif ρ ≤ RHO_SEMI_MAX - t = _lognorm(ρ, RHO_SEMIMETAL, RHO_SEMI_MAX) - return _interpolate_gradient(SEMICON_GRADIENT, t) - elseif ρ ≤ RHO_LEAKY_MAX - t = _lognorm(ρ, RHO_SEMI_MAX, RHO_LEAKY_MAX) - return _interpolate_gradient(LEAKY_GRADIENT, t) - else - t = _lognorm(min(ρ, RHO_MAX), RHO_LEAKY_MAX, RHO_MAX) - return _ensure_min_lightness(_interpolate_gradient(INSULATOR_GRADIENT, t), 0.07) - end -end - -# ---------------------------- -# Overlays (μr & εr) -# ---------------------------- -# μr in [1, 300] → alpha up to ~0.5, stronger on dark bases -function _mu_overlay(base::RGB, μr::Real)::Colors.RGBA - μn = clamp((_lognorm(max(μr, 1.0), 1.0, 300.0)), 0, 1) - tint = _interpolate_gradient(MU_OVERLAY_GRADIENT, μn) - L = HSL(base).l - α = 0.50 * μn * (0.6 + 0.4*(1 - L)) # reduce on bright silver, boost on dark - Colors.RGBA(tint.r, tint.g, tint.b, α) -end - -# εr in [1, 1000] → alpha up to ~0.6 on insulators, ~0.2 on metals -function _eps_overlay(base::RGB, εr::Real, ρ::Real)::Colors.RGBA - εn = clamp((_lognorm(max(εr, 1.0), 1.0, 1000.0)), 0, 1) - tint = _interpolate_gradient(EPS_OVERLAY_GRADIENT, εn) - # weight more if it's an insulator/leaky (so it shows on dark) - band_weight = ρ > RHO_SEMI_MAX ? 1.0 : (ρ > RHO_METAL_MAX ? 0.6 : 0.35) - L = HSL(base).l - α = (0.20 + 0.40*band_weight) * εn * (0.55 + 0.45*(1 - L)) - Colors.RGBA(tint.r, tint.g, tint.b, α) -end - -""" - get_material_color_makie(material_props; mu_scale=1.0, eps_scale=1.0) - -Piecewise ρ→base color (metals→silver, semiconductors→amber, etc.) with -blue/purple magnetic overlay (μr) and teal/cyan permittivity overlay (εr). -`mu_scale` and `eps_scale` scale overlay strength (1.0 = default). -""" -function get_material_color_makie(material_props; mu_scale = 1.0, eps_scale = 1.0) - ρ = to_nominal(material_props.rho) - εr = to_nominal(material_props.eps_r) - μr = to_nominal(material_props.mu_r) - - base = _base_color_from_rho(ρ) |> c -> _ensure_min_lightness(c, 0.07) - - # Compose overlays - mu = _mu_overlay(base, μr); - mu = Colors.RGBA(mu.r, mu.g, mu.b, clamp(alpha(mu)*mu_scale, 0, 1)) - eps = _eps_overlay(base, εr, ρ); - eps = Colors.RGBA(eps.r, eps.g, eps.b, clamp(alpha(eps)*eps_scale, 0, 1)) - - out = _overlay(Colors.RGBA(base.r, base.g, base.b, 1.0), mu) - out = _overlay(out, eps) - return out -end - -function show_material_scale(; size = (800, 400), backend = nothing) - # if backend !== nothing - # _use_makie_backend(backend) - # end - ensure_backend!(backend === nothing ? :cairo : backend) - - fig = Figure(size = size) - - # sampling density for smooth bars - N = 1024 - - # --- ρ colorbar (log scale by ticks/limits) ------------------------------- - ρmin_log, ρmax_log = log10(RHO_MIN), log10(RHO_MAX) - # sample uniformly in log(ρ) so the bar matches your piecewise mapping - cm_ρ = begin - cols = Vector{RGBA}(undef, N) - for i in 1:N - t = (i - 1) / (N - 1) - ρ = 10^(ρmin_log + t * (ρmax_log - ρmin_log)) - c = _base_color_from_rho(ρ) - cols[i] = RGBA(c.r, c.g, c.b, 1.0) - end - cols - end - - cb_ρ = Colorbar(fig[1, 1]; - colormap = cm_ρ, - limits = (ρmin_log, ρmax_log), # we encode log(ρ) in limits/ticks - vertical = false, - label = "Base color by resistivity ρ [Ω·m] (log scale)", - ) - - # label ticks at meaningful boundaries - edges = [RHO_MIN, 1e-8, 1e-7, RHO_METAL_MAX, RHO_SEMIMETAL, RHO_SEMI_MAX, - 1e4, 1e6, RHO_LEAKY_MAX, RHO_MAX] - cb_ρ.ticks = (log10.(edges), string.(edges)) - - # --- μr overlay colorbar (blue→indigo on mid-gray) ------------------------ - μmin, μmax = 1.0, 300.0 - base_mid = RGB(0.5, 0.5, 0.5) - cm_μ = begin - cols = Vector{RGBA}(undef, N) - for i in 1:N - t = (i - 1) / (N - 1) - μ = 10^(log10(μmin) + t * (log10(μmax) - log10(μmin))) - o = _mu_overlay(base_mid, μ) - out = _overlay(RGBA(base_mid.r, base_mid.g, base_mid.b, 1.0), o) - cols[i] = out - end - cols - end - - cb_μ = Colorbar(fig[2, 1]; - colormap = cm_μ, - limits = (μmin, μmax), - vertical = false, - label = "Magnetic overlay μᵣ (blue→indigo)", - ) - cb_μ.ticks = ( - [1, 2, 5, 10, 20, 50, 100, 200, 300], - string.([1, 2, 5, 10, 20, 50, 100, 200, 300]), - ) - - # --- εr overlay colorbar (teal→cyan on dark base) ------------------------- - εmin, εmax = 1.0, 1000.0 - base_dark = RGB(0.10, 0.10, 0.10) - cm_ε = begin - cols = Vector{RGBA}(undef, N) - for i in 1:N - t = (i - 1) / (N - 1) - ε = 10^(log10(εmin) + t * (log10(εmax) - log10(εmin))) - o = _eps_overlay(base_dark, ε, RHO_MAX + 1) # treat as strong insulator - out = _overlay(RGBA(base_dark.r, base_dark.g, base_dark.b, 1.0), o) - cols[i] = out - end - cols - end - - cb_ε = Colorbar(fig[3, 1]; - colormap = cm_ε, - limits = (εmin, εmax), - vertical = false, - label = "Permittivity overlay εᵣ (teal→cyan)", - ) - cb_ε.ticks = ([1, 2, 5, 10, 20, 50, 100, 200, 500, 1000], - string.([1, 2, 5, 10, 20, 50, 100, 200, 500, 1000])) - - renderfig(fig) - return fig -end - - -################################# -# Geometry helpers (polygons) # -################################# -# polygons (Float32 points; filter non-finite) -function _annulus_poly(rin::Real, rex::Real, x0::Real, y0::Real; N::Int = 256) - N ≥ 32 || throw(ArgumentError("N too small for a smooth annulus")) - θo = range(0, 2π; length = N); - θi = reverse(θo) - xo = x0 .+ rex .* cos.(θo); - yo = y0 .+ rex .* sin.(θo) - xi = x0 .+ rin .* cos.(θi); - yi = y0 .+ rin .* sin.(θi) - px = vcat(xo, xi, xo[1]); - py = vcat(yo, yi, yo[1]) - pts = Makie.Point2f.(px, py) - filter(p -> _valid_finite(p[1], p[2]), pts) -end - -function _circle_poly(r::Real, x0::Real, y0::Real; N::Int = 128) - θ = range(0, 2π; length = N) - x = x0 .+ r .* cos.(θ); - y = y0 .+ r .* sin.(θ) - pts = Makie.Point2f.(vcat(x, x[1]), vcat(y, y[1])) - filter(p -> _valid_finite(p[1], p[2]), pts) -end - -############################# -# Layer -> Makie primitives # -############################# -function _plot_layer_makie!(ax, layer, label::String; - x0::Real = 0.0, y0::Real = 0.0, display_legend::Bool = true, - legend_sink::Union{Nothing, Tuple} = nothing, -) - - if layer isa WireArray - rwire = to_nominal(layer.radius_wire) - nW = layer.num_wires - lay_r = nW == 1 ? 0.0 : to_nominal(layer.radius_in) - color = get_material_color_makie(layer.material_props) - - coords = calc_wirearray_coords(nW, rwire, to_nominal(lay_r), C = (x0, y0)) - - plots = Any[] - handle = nothing - for (i, (x, y)) in enumerate(coords) - poly = Makie.poly!(ax, _circle_poly(rwire, x, y); - color = color, - strokecolor = :black, - strokewidth = 0.5, - label = (i==1 && display_legend) ? label : "") - push!(plots, poly) - if i==1 && display_legend - handle = poly - end - end - - # Legend sink: push one entry per layer. If sink has 3rd slot, store the group. - if legend_sink !== nothing && display_legend && handle !== nothing - push!(legend_sink[1], handle) - push!(legend_sink[2], label) - if length(legend_sink) >= 3 - push!(legend_sink[3], plots) # group = all wires in this layer - end - if length(legend_sink) >= 4 - push!(legend_sink[4], to_nominal(layer.material_props.rho)) # <-- rho key - end - end - return plots - end - - if layer isa Strip || layer isa Tubular || layer isa Semicon || layer isa Insulator - rin = to_nominal(layer.radius_in) - rex = to_nominal(layer.radius_ext) - color = get_material_color_makie(layer.material_props) - - poly = Makie.poly!(ax, _annulus_poly(rin, rex, x0, y0); - color = color, - label = display_legend ? label : "") - - if legend_sink !== nothing && display_legend - push!(legend_sink[1], poly) - push!(legend_sink[2], label) - if length(legend_sink) >= 3 - push!(legend_sink[3], [poly]) - end - if length(legend_sink) >= 4 - push!(legend_sink[4], NaN) # not a wirearray - end - end - return (poly,) - end - - if layer isa ConductorGroup - plots = Any[] - first_label = true - for sub in layer.layers - append!( - plots, - _plot_layer_makie!(ax, sub, - first_label ? lowercase(string(nameof(typeof(layer)))) : ""; - x0 = x0, y0 = y0, display_legend = display_legend, - legend_sink = legend_sink), - ) - first_label = false - end - return plots - end - - @warn "Unknown layer type $(typeof(layer)); skipping" - return () -end - -function apply_default_theme!() - bg = _is_static_backend() ? :white : :gray90 - set_theme!(backgroundcolor = bg, fonts = (; icons = ICON_TTF)) -end - - - -############################################### -# CableDesign cross-section (Makie version) # -############################################### -function preview(design::CableDesign; - x_offset::Real = 0.0, - y_offset::Real = 0.0, - backend::Union{Nothing, Symbol} = nothing, - size::Tuple{Int, Int} = (800, 600), - display_plot::Bool = true, - display_legend::Bool = true, - display_id::Bool = false, - axis = nothing, - legend_sink::Union{Nothing, Tuple{Vector{Any}, Vector{String}}} = nothing, - display_colorbars::Bool = true, - side_frac::Real = 0.26, # ~26% right column -) - - ensure_backend!(backend) - - # backgroundcolor = (_is_static_backend() ? :white : :gray90) - # set_theme!(backgroundcolor = backgroundcolor) - apply_default_theme!() - - fig = - isnothing(axis) ? Makie.Figure(size = size, figure_padding = (10, 10, 10, 10)) : - nothing - - # ── 2 columns: left = main axis, right = container (button + legend + bars) - local ax - local side - if isnothing(axis) - ax = Makie.Axis(fig[1, 1], aspect = Makie.DataAspect()) - side = fig[1, 2] = Makie.GridLayout() # single container on the right - Makie.colsize!(fig.layout, 1, Makie.Relative(1 - side_frac)) - Makie.colsize!(fig.layout, 2, Makie.Relative(side_frac)) - Makie.rowsize!(fig.layout, 1, Makie.Relative(1.0)) - - ax.xlabel = "y [m]" - ax.ylabel = "z [m]" - - ax.title = - display_id ? "Cable design preview: $(design.cable_id)" : - "Cable design preview" - - avail_w = size[1] * (1 - side_frac) - avail_h = size[2] - s = floor(Int, min(avail_w, avail_h)*0.9) - Makie.colsize!(fig.layout, 1, Makie.Fixed(s)) - Makie.rowsize!(fig.layout, 1, Makie.Fixed(s)) - else - ax = axis - side = nothing - end - - # legend sink - local own_legend = false - local sink = legend_sink - if sink === nothing && display_legend - sink = (Any[], String[], Vector{Vector{Any}}(), Float64[]) # handles, labels, groups, rho_keys - own_legend = true - end - - let r = try - to_nominal(design.components[end].insulator_group.radius_ext) - catch - NaN - end - if isfinite(r) && r > 0 - Makie.poly!(ax, _circle_poly(r, x_offset, y_offset); - color = :white, - strokecolor = :transparent) - end - end - - # draw layers - for comp in design.components - for layer in comp.conductor_group.layers - _plot_layer_makie!(ax, layer, lowercase(string(nameof(typeof(layer)))); - x0 = x_offset, y0 = y_offset, - display_legend = display_legend, legend_sink = sink) - end - for layer in comp.insulator_group.layers - _plot_layer_makie!(ax, layer, lowercase(string(nameof(typeof(layer)))); - x0 = x_offset, y0 = y_offset, - display_legend = display_legend, legend_sink = sink) - end - end - - - # Right column: stack button, legend, colorbars - if isnothing(axis) - row_idx = 1 - - if _is_interactive_backend() - # Reset button at top - _add_reset_button!(side[row_idx, 1], ax, fig) - row_idx += 1 - _add_save_svg_button!( - side[row_idx, 1], design; - display_id = display_id, - display_legend = display_legend, - display_colorbars = display_colorbars, - side_frac = side_frac, - size = size, - base = design.cable_id, - ) - row_idx += 1 - end - - # Legend (optional) - if display_legend && own_legend - handles = sink[1] - labels = sink[2] - groups = length(sink) >= 3 ? sink[3] : [[h] for h in handles] - rhos = length(sink) >= 4 ? sink[4] : fill(NaN, length(handles)) - - # Merge consecutive wirearray entries with equal rho - merged_handles = Any[] - merged_labels = String[] - merged_groups = Vector{Any}[] # Vector{Vector{Any}} - - i = 1 - while i <= length(handles) - h = handles[i]; - l = labels[i]; - g = groups[i]; - ρ = rhos[i] - if l == "wirearray" && isfinite(ρ) - j = i + 1 - merged_g = Vector{Any}(g) - while j <= length(handles) && - labels[j] == "wirearray" && - isfinite(rhos[j]) && - isapprox(ρ, rhos[j]; rtol = 1e-6, atol = 0.0) - append!(merged_g, groups[j]) - j += 1 - end - push!(merged_handles, h) # keep first handle for the group - push!(merged_labels, l) # keep label "wirearray" - push!(merged_groups, merged_g) # all wires across merged layers - i = j - else - push!(merged_handles, h) - push!(merged_labels, l) - push!(merged_groups, g) - i += 1 - end - end - - # Build legend with merged entries - leg = Makie.Legend( - side[row_idx, 1], - merged_handles, - merged_labels, - padding = (6, 6, 6, 6), - halign = :center, - valign = :top, - ) - - # Clicking one entry toggles its whole merged group - for (h, grp) in zip(merged_handles, merged_groups) - Makie.on(h.visible) do v - for p in grp - p === h && continue - p.visible[] = v - end - end - end - - row_idx += 1 - end - - # Colorbars (optional) - if display_colorbars - # read actual ranges (helper you already have) - ρmin, ρmax, μmin, μmax, εmin, εmax = _collect_material_ranges(design) - - cbgrid = side[row_idx, 1] = Makie.GridLayout() - _build_colorbars!(cbgrid; ρmin, ρmax, μmin, μmax, εmin, εmax) - - end - end - - if display_plot && isnothing(axis) && !is_in_testset() - resize_to_layout!(fig) - n = next_fignum() - scr = - _is_gl_backend() ? - gl_screen("Fig. $(n) – CableDesign preview: $(design.cable_id)") : - nothing - if scr === nothing - renderfig(fig) - else - display(scr, fig) - end - - end - return fig, ax -end - -function preview(system::LineCableSystem; - earth_model = nothing, - zoom_factor = nothing, - backend::Union{Nothing, Symbol} = nothing, - size::Tuple{Int, Int} = (800, 600), - display_plot::Bool = true, - display_id::Bool = false, - axis = nothing, - display_legend::Bool = true, - display_colorbars::Bool = true, - side_frac::Real = 0.26, -) - - ensure_backend!(backend) - # backgroundcolor = (_is_static_backend() ? :white : :gray90) - - # set_theme!(backgroundcolor = backgroundcolor) - apply_default_theme!() - - fig = - isnothing(axis) ? Makie.Figure(size = size, figure_padding = (10, 10, 10, 10)) : - nothing - - # Layout: left = main axis, right = legend/colorbars (only if we own the axis) - local ax - local side - if isnothing(axis) - ax = Makie.Axis(fig[1, 1], aspect = Makie.DataAspect()) - side = fig[1, 2] = Makie.GridLayout() - Makie.colsize!(fig.layout, 1, Makie.Relative(1 - side_frac)) - Makie.colsize!(fig.layout, 2, Makie.Relative(side_frac)) - Makie.rowsize!(fig.layout, 1, Makie.Relative(1.0)) - - ax.xlabel = "y [m]" - ax.ylabel = "z [m]" - - ax.title = - display_id ? "Cable system cross-section: $(system.system_id)" : - "Cable system cross-section" - - # Make the plotting canvas square if we own the axis - avail_w = size[1] * (1 - side_frac) - avail_h = size[2] - s = floor(Int, min(avail_w, avail_h)*0.9) - Makie.colsize!(fig.layout, 1, Makie.Fixed(s)) - Makie.rowsize!(fig.layout, 1, Makie.Fixed(s)) - else - ax = axis - side = nothing - end - - # Air/earth interface - Makie.hlines!(ax, [0.0], color = :black, linewidth = 1.5) - - # Compute barycentered, square view from cable bounding box - x0s = Float64[to_nominal(c.horz) for c in system.cables] - y0s = Float64[to_nominal(c.vert) for c in system.cables] - radii = Float64[ - (comp = last(c.design_data.components); - max(to_nominal(comp.conductor_group.radius_ext), - to_nominal(comp.insulator_group.radius_ext))) - for c in system.cables - ] - cx = isempty(x0s) ? 0.0 : mean(x0s) - cy = isempty(y0s) ? -1.0 : mean(y0s) - x_min = isempty(x0s) ? -1.0 : minimum(x0s .- radii) - x_max = isempty(x0s) ? 1.0 : maximum(x0s .+ radii) - y_min = isempty(y0s) ? -1.0 : minimum(y0s .- radii) - y_max = isempty(y0s) ? 1.0 : maximum(y0s .+ radii) - half_x = max(x_max - cx, cx - x_min) - half_y = max(y_max - cy, cy - y_min) - base_halfspan = max(half_x, half_y) - base_halfspan = base_halfspan > 0 ? base_halfspan : 1.0 - pad_factor = 1.05 - zf = zoom_factor === nothing ? 1.5 : Float64(zoom_factor) - halfspan = base_halfspan * pad_factor * zf - x_limits = (cx - halfspan, cx + halfspan) - y_limits = (cy - halfspan, cy + halfspan) - - # Expanded fill extents beyond visible region - BUFFER_FILL = 5.0 - x_fill = - (x_limits[1] - 0.5*halfspan - BUFFER_FILL, x_limits[2] + 0.5*halfspan + BUFFER_FILL) - y_fill_min = y_limits[1] - 0.5*halfspan - BUFFER_FILL - - # Build legend entries only for earth layers - earth_handles = Any[] - earth_labels = String[] - - # Plot earth layers if provided and horizontal (vertical_layers == false) - if !isnothing(earth_model) && getfield(earth_model, :vertical_layers) == false - cumulative_depth = 0.0 - # Skip air layer (index 1). Iterate finite-thickness layers; stop on Inf. - for (i, layer) in enumerate(earth_model.layers[2:end]) - # Compute color using the same material convention - # Adapt EarthLayer base_* fields to material_props (rho, eps_r, mu_r) - mat = (; - rho = layer.base_rho_g, - eps_r = layer.base_epsr_g, - mu_r = layer.base_mur_g, - ) - fillcol = get_material_color_makie(mat) - # Slight transparency for fill - fillcol = Makie.RGBA(fillcol.r, fillcol.g, fillcol.b, 0.25) - - if isinf(layer.t) - # Semi-infinite: fill from current depth down to far below visible - ytop = cumulative_depth # bottom of previous finite layer - ybot = y_fill_min # push well below visible range - else - # Finite thickness: update cumulative and compute band extents - t = to_nominal(layer.t) - ytop = cumulative_depth - ybot = cumulative_depth - t - cumulative_depth = ybot - end - - xs = (x_fill[1], x_fill[2], x_fill[2], x_fill[1]) - ys = (ytop, ytop, ybot, ybot) - - # Filled band and a colored interface line - poly = Makie.poly!( - ax, - collect(Makie.Point2f.(xs, ys)), # ensure a Vector, not a Tuple - color = fillcol, - strokecolor = :transparent, - label = "", - ) - Makie.hlines!(ax, [ybot], color = fillcol, linewidth = 1.0) - - if display_legend && isnothing(axis) - push!(earth_handles, poly) - push!(earth_labels, "Earth layer $(i)") - end - end - end - - # Draw each cable onto the same axis (no legend for cable components) - for cable in system.cables - x0 = to_nominal(cable.horz) - y0 = to_nominal(cable.vert) - # Reuse the design-level preview on our axis - preview( - cable.design_data; - x_offset = x0, - y_offset = y0, - backend = backend, - size = size, - display_plot = false, - display_legend = false, - axis = ax, - ) - end - - # Set limits only when we own the axis (square extents) - if isnothing(axis) - Makie.xlims!(ax, x_limits...) - Makie.ylims!(ax, y_limits...) - end - - # Right-column: buttons, earth-only legend and optional colorbars - if isnothing(axis) - row_idx = 1 - if _is_interactive_backend() - _add_reset_button!(side[row_idx, 1], ax, fig) - row_idx += 1 - _add_save_svg_button!( - side[row_idx, 1], system; - earth_model = earth_model, - zoom_factor = zoom_factor, - display_legend = display_legend, - display_colorbars = display_colorbars, - side_frac = side_frac, - display_id = display_id, - size = size, - base = system.system_id, - ) - row_idx += 1 - end - - if display_legend && !isempty(earth_handles) - Makie.Legend( - side[row_idx, 1], - earth_handles, - earth_labels, - padding = (6, 6, 6, 6), - halign = :center, - valign = :top, - ) - row_idx += 1 - end - - if display_colorbars - ρmin, ρmax, μmin, μmax, εmin, εmax = _collect_earth_ranges(earth_model) - cbgrid = side[row_idx, 1] = Makie.GridLayout() - _build_colorbars!( - cbgrid; - ρmin, - ρmax, - μmin, - μmax, - εmin, - εmax, - alpha_global = 0.25, - showμminmax = false, - showεminmax = false, - ) - end - end - - - if display_plot && isnothing(axis) && !is_in_testset() - resize_to_layout!(fig) - n = next_fignum() - scr = - _is_gl_backend() ? - gl_screen("Fig. $(n) – LineCableSystem preview: $(system.system_id)") : - nothing - if scr === nothing - renderfig(fig) - else - display(scr, fig) - end - - end - - return fig, ax -end - -# Add a save-to-SVG button to a grid cell, re-rendering with Cairo backend -function _add_save_svg_button!(parent_cell, system; - earth_model = nothing, - zoom_factor = nothing, - display_id::Bool, - display_legend::Bool, - display_colorbars::Bool, - side_frac::Real, - size::Tuple{Int, Int}, - base::String = "preview", - save_dir::AbstractString = pwd(), -) - btn = Makie.Button( - parent_cell, - label = with_icon(MI_SAVE; text = "Save SVG"), - halign = :center, - valign = :top, - width = Makie.Auto(), - ) - Makie.on(btn.clicks) do _ - @async begin - orig_label = btn.label[] - btn.label[] = "Saving…" - orig_color = hasproperty(btn, :buttoncolor) ? btn.buttoncolor[] : nothing - try - # _use_makie_backend(:cairo) - ensure_backend!(:cairo) - if system isa CableDesign - fig, _ = preview( - system; - display_legend = display_legend, - display_colorbars = display_colorbars, - display_plot = false, - size = size, - display_id = display_id, - backend = :cairo, - side_frac = side_frac, - ) - elseif system isa LineCableSystem - fig, _ = preview( - system; - earth_model = earth_model, - zoom_factor = zoom_factor, - display_id = display_id, - display_legend = display_legend, - display_colorbars = display_colorbars, - display_plot = false, - size = size, - backend = :cairo, - side_frac = side_frac, - ) - end - ts = Dates.format(Dates.now(), "yyyymmdd-HHMMSS") - file = joinpath(save_dir, "$(base)_$ts.svg") - Makie.save(file, fig) - btn.label[] = "Saved ✓" - @info "Saved figure to $(file)" - hasproperty(btn, :buttoncolor) && - (btn.buttoncolor[] = Makie.RGBA(0.15, 0.65, 0.25, 1.0)) - sleep(1.2) - catch e - @error "Save failed: $(typeof(e)): $(e)" - btn.label[] = "Failed ✗" - hasproperty(btn, :buttoncolor) && - (btn.buttoncolor[] = Makie.RGBA(0.80, 0.20, 0.20, 1.0)) - sleep(1.6) - finally - if orig_color !== nothing - btn.buttoncolor[] = orig_color - end - btn.label[] = orig_label - end - end - end - return btn -end - -# Add a reset button to a grid cell, wired to reset axis limits -function _add_reset_button!(parent_cell, ax, fig) - btn = Makie.Button( - parent_cell, - label = with_icon(MI_REFRESH; text = "Reset view"), - halign = :center, - valign = :top, - width = Makie.Relative(1.0), - ) - Makie.on(btn.clicks) do _ - reset_limits!(ax) - # resize_to_layout!(fig) - - end - return btn -end - -function _build_colorbars!(cbgrid::Makie.GridLayout; - ρmin::Real, ρmax::Real, μmin::Real, μmax::Real, εmin::Real, εmax::Real, - cb_bar_h::Int = 12, alpha_global::Real = 1.0, showρminmax::Bool = true, - showμminmax::Bool = true, showεminmax::Bool = true, -) - Makie.colsize!(cbgrid, 1, Makie.Fixed(2)) - - function _nice(x) - axv = abs(x) - axv == 0 && return "0" - (axv ≥ 1e-3 && axv < 1e4) ? @sprintf("%.4g", x) : @sprintf("%.1e", x) - end - - N = 256 - idx=1 - - # ρ bar sampled in log-space between actual min/max - if showρminmax - cm_ρ = let cols = Vector{Makie.RGBA}(undef, N) - lo, hi = log10(ρmin), log10(ρmax) - for i in 1:N - t = (i-1)/(N-1) - ρ = 10^(lo + t*(hi - lo)) - c = _base_color_from_rho(ρ) - cols[i] = Makie.RGBA(c.r, c.g, c.b, 1*alpha_global) - end; - cols - end - Makie.Label(cbgrid[idx, 1], L"\rho"; halign = :left, fontsize = 16) - Makie.Colorbar(cbgrid[idx, 2]; colormap = cm_ρ, limits = (0.0, 1.0), - vertical = false, - ticks = ([0.0, 1.0], [_nice(ρmin), _nice(ρmax)]), - labelvisible = false, height = cb_bar_h) - idx += 1 - end - - # μr bar overlay on mid-gray - if showμminmax - base_mid = Makie.RGB(0.5, 0.5, 0.5) - cm_μ = let cols = Vector{Makie.RGBA}(undef, N) - lo, hi = log10(μmin), log10(μmax) - for i in 1:N - t = (i-1)/(N-1) - μ = 10^(lo + t*(hi - lo)) - o = _mu_overlay(base_mid, μ) - cols[i] = _overlay( - Makie.RGBA(base_mid.r, base_mid.g, base_mid.b, 1), - o*alpha_global, - ) - end; - cols - end - Makie.Label(cbgrid[idx, 1], L"\mu_{r}"; halign = :left, fontsize = 16) - Makie.Colorbar(cbgrid[idx, 2]; colormap = cm_μ, limits = (0.0, 1.0), - vertical = false, - ticks = ([0.0, 1.0], [_nice(μmin), _nice(μmax)]), - labelvisible = false, height = cb_bar_h) - idx += 1 - end - - if showεminmax - # εr bar overlay on dark - base_dark = Makie.RGB(0.10, 0.10, 0.10) - cm_ε = let cols = Vector{Makie.RGBA}(undef, N) - lo, hi = log10(εmin), log10(εmax) - for i in 1:N - t = (i-1)/(N-1) - ε = 10^(lo + t*(hi - lo)) - o = _eps_overlay(base_dark, ε, RHO_MAX + 1) - cols[i] = _overlay( - Makie.RGBA(base_dark.r, base_dark.g, base_dark.b, 1), - o*alpha_global, - ) - end; - cols - end - Makie.Label(cbgrid[idx, 1], L"\varepsilon_{r}"; halign = :left, fontsize = 16) - Makie.Colorbar(cbgrid[idx, 2]; colormap = cm_ε, limits = (0.0, 1.0), - vertical = false, - ticks = ([0.0, 1.0], [_nice(εmin), _nice(εmax)]), - labelvisible = false, height = cb_bar_h) - end - - return cbgrid -end - -# collect actual property ranges from the design (finite values only) -function _collect_material_ranges(design::CableDesign) - rhos = Float64[] - mus = Float64[] - epses = Float64[] - - _push_props!(layer) = begin - ρ = try - to_nominal(layer.material_props.rho) - catch - NaN - end - μr = try - to_nominal(layer.material_props.mu_r) - catch - NaN - end - εr = try - to_nominal(layer.material_props.eps_r) - catch - NaN - end - isfinite(ρ) && push!(rhos, ρ) - isfinite(μr) && push!(mus, μr) - isfinite(εr) && push!(epses, εr) - nothing - end - - for comp in design.components - for L in comp.conductor_group.layers - if L isa ConductorGroup - for s in L.layers - _push_props!(s); - end - else - _push_props!(L) - end - end - for L in comp.insulator_group.layers - _push_props!(L) - end - end - - ρmin = isempty(rhos) ? RHO_MIN : minimum(rhos) - ρmax = isempty(rhos) ? RHO_MAX : maximum(rhos) - μmin = isempty(mus) ? 1.0 : max(1.0, minimum(mus)) - μmax = isempty(mus) ? 300.0 : maximum(mus) - εmin = isempty(epses) ? 1.0 : max(1.0, minimum(epses)) - εmax = isempty(epses) ? 1000.0 : maximum(epses) - ρmax == ρmin && (ρmax = nextfloat(ρmax)) - μmax == μmin && (μmax += 1e-6) - εmax == εmin && (εmax += 1e-6) - - return ρmin, ρmax, μmin, μmax, εmin, εmax -end - -function _collect_earth_ranges(earth_model) - rhos = Float64[]; - mus = Float64[]; - epses = Float64[] - if !isnothing(earth_model) - for layer in earth_model.layers[2:end] - ρ = try - to_nominal(layer.base_rho_g) - catch - NaN - end - μr = try - to_nominal(layer.base_mur_g) - catch - NaN - end - εr = try - to_nominal(layer.base_epsr_g) - catch - NaN - end - isfinite(ρ) && push!(rhos, ρ) - isfinite(μr) && push!(mus, μr) - isfinite(εr) && push!(epses, εr) - end - end - ρmin = isempty(rhos) ? RHO_MIN : minimum(rhos) - ρmax = isempty(rhos) ? RHO_MAX : maximum(rhos) - μmin = isempty(mus) ? 1.0 : max(1.0, minimum(mus)) - μmax = isempty(mus) ? 300.0 : maximum(mus) - εmin = isempty(epses) ? 1.0 : max(1.0, minimum(epses)) - εmax = isempty(epses) ? 1000.0 : maximum(epses) - return ρmin, ρmax, μmin, μmax, εmin, εmax -end +using Makie, Colors +using Printf +using Dates +using Statistics + + +# _is_interactive_backend() = nameof(Makie.current_backend()) in (:GLMakie, :WGLMakie) +_is_interactive_backend() = current_backend_symbol() in (:gl, :wgl) +_is_static_backend() = current_backend_symbol() == :cairo +_is_gl_backend() = current_backend_symbol() == :gl + + +# finite & nonnegative +_valid_finite(x, y) = isfinite(x) && isfinite(y) + + +# Tunables (bands & palettes) +# ---------------------------- +const RHO_MIN = 1e-9 # for legend floor +const RHO_METAL_MAX = 1e-6 +const RHO_SEMIMETAL = 1e-4 +const RHO_SEMI_MAX = 1e3 +const RHO_LEAKY_MAX = 1e8 +const RHO_MAX = 1e10 # for legend ceiling + +const METAL_GRADIENT = [ + RGB(0.92, 0.90, 0.86), # warm-silver (copper-ish) + RGB(0.89, 0.89, 0.89), # neutral silver + RGB(0.86, 0.89, 0.92), # cool-silver (aluminium-ish) + RGB(0.70, 0.72, 0.75), # slightly darker metal +] + +const SEMIMETAL_GRADIENT = [ + RGB(0.70, 0.72, 0.75), # gray + RGB(0.80, 0.75, 0.65), # sand/bronze hint +] + +const SEMICON_GRADIENT = [ + RGB(1.00, 0.83, 0.40), # light amber + RGB(0.85, 0.55, 0.18), # dark amber-brown +] + +const LEAKY_GRADIENT = [ + RGB(0.42, 0.55, 0.15), # olive/earthy + RGB(0.13, 0.13, 0.13), # charcoal +] + +const INSULATOR_GRADIENT = [ + RGB(0.07, 0.07, 0.07), # near-black (keep >0 so overlays remain visible) + RGB(0.00, 0.00, 0.00), +] + +# Overlays +const MU_OVERLAY_GRADIENT = [RGB(0.20, 0.50, 0.95), RGB(0.56, 0.00, 0.91)] # blue → indigo +const EPS_OVERLAY_GRADIENT = [RGB(0.00, 0.85, 0.70), RGB(0.00, 0.55, 0.90)] # teal → cyan + + +# Linear interpolation across a list of colors in [0,1] +# robust gradient (no reinterpret) +_interpolate_gradient(colors::Vector{<:Colorant}, t::Real) = begin + n = length(colors); + n >= 2 || throw(ArgumentError("Need ≥ 2 colors")) + tc = clamp(Float64(t), 0, 1) + x = tc * (n - 1) + i = clamp(floor(Int, x) + 1, 1, n - 1) + f = x - (i - 1) + c1 = RGB(colors[i]); + c2 = RGB(colors[i+1]) + RGB( + (1 - f) * red(c1) + f * red(c2), + (1 - f) * green(c1) + f * green(c2), + (1 - f) * blue(c1) + f * blue(c2), + ) +end + +# Log normalization helper: map v∈[a,b] (log10) → t∈[0,1] +_lognorm(v, a, b) = begin + va = clamp(v, min(a, b), max(a, b)) + (log10(va) - log10(a)) / (log10(b) - log10(a)) +end + +_overlay(a::Colors.RGBA, b::Colors.RGBA) = begin + a1, a2 = alpha(a), alpha(b) + out_a = a2 + a1*(1 - a2) + out_a == 0 && return Colors.RGBA(0, 0, 0, 0) + r = (red(b)*a2 + red(a)*a1*(1 - a2)) / out_a + g = (green(b)*a2 + green(a)*a1*(1 - a2)) / out_a + b_ = (blue(b)*a2 + blue(a)*a1*(1 - a2)) / out_a + Colors.RGBA(r, g, b_, out_a) +end + +# Clamp lightness to keep overlays visible on "black" +function _ensure_min_lightness(c::RGB, Lmin::Float64 = 0.07) + hsl = HSL(c) + L = max(hsl.l, Lmin) + rgb = RGB(HSL(hsl.h, hsl.s, L)) + return rgb +end + +# ---------------------------- +# Base color controlled by ρ +# ---------------------------- +function _base_color_from_rho(ρ::Real)::RGB + if !isfinite(ρ) + return INSULATOR_GRADIENT[end] + elseif ρ ≤ RHO_METAL_MAX + t = _lognorm(ρ, 1e-8, RHO_METAL_MAX) + return _interpolate_gradient(METAL_GRADIENT, t) + elseif ρ ≤ RHO_SEMIMETAL + t = _lognorm(ρ, RHO_METAL_MAX, RHO_SEMIMETAL) + return _interpolate_gradient(SEMIMETAL_GRADIENT, t) + elseif ρ ≤ RHO_SEMI_MAX + t = _lognorm(ρ, RHO_SEMIMETAL, RHO_SEMI_MAX) + return _interpolate_gradient(SEMICON_GRADIENT, t) + elseif ρ ≤ RHO_LEAKY_MAX + t = _lognorm(ρ, RHO_SEMI_MAX, RHO_LEAKY_MAX) + return _interpolate_gradient(LEAKY_GRADIENT, t) + else + t = _lognorm(min(ρ, RHO_MAX), RHO_LEAKY_MAX, RHO_MAX) + return _ensure_min_lightness(_interpolate_gradient(INSULATOR_GRADIENT, t), 0.07) + end +end + +# ---------------------------- +# Overlays (μr & εr) +# ---------------------------- +# μr in [1, 300] → alpha up to ~0.5, stronger on dark bases +function _mu_overlay(base::RGB, μr::Real)::Colors.RGBA + μn = clamp((_lognorm(max(μr, 1.0), 1.0, 300.0)), 0, 1) + tint = _interpolate_gradient(MU_OVERLAY_GRADIENT, μn) + L = HSL(base).l + α = 0.50 * μn * (0.6 + 0.4*(1 - L)) # reduce on bright silver, boost on dark + Colors.RGBA(tint.r, tint.g, tint.b, α) +end + +# εr in [1, 1000] → alpha up to ~0.6 on insulators, ~0.2 on metals +function _eps_overlay(base::RGB, εr::Real, ρ::Real)::Colors.RGBA + εn = clamp((_lognorm(max(εr, 1.0), 1.0, 1000.0)), 0, 1) + tint = _interpolate_gradient(EPS_OVERLAY_GRADIENT, εn) + # weight more if it's an insulator/leaky (so it shows on dark) + band_weight = ρ > RHO_SEMI_MAX ? 1.0 : (ρ > RHO_METAL_MAX ? 0.6 : 0.35) + L = HSL(base).l + α = (0.20 + 0.40*band_weight) * εn * (0.55 + 0.45*(1 - L)) + Colors.RGBA(tint.r, tint.g, tint.b, α) +end + +""" + get_material_color_makie(material_props; mu_scale=1.0, eps_scale=1.0) + +Piecewise ρ→base color (metals→silver, semiconductors→amber, etc.) with +blue/purple magnetic overlay (μr) and teal/cyan permittivity overlay (εr). +`mu_scale` and `eps_scale` scale overlay strength (1.0 = default). +""" +function get_material_color_makie(material_props; mu_scale = 1.0, eps_scale = 1.0) + ρ = to_nominal(material_props.rho) + εr = to_nominal(material_props.eps_r) + μr = to_nominal(material_props.mu_r) + + base = _base_color_from_rho(ρ) |> c -> _ensure_min_lightness(c, 0.07) + + # Compose overlays + mu = _mu_overlay(base, μr); + mu = Colors.RGBA(mu.r, mu.g, mu.b, clamp(alpha(mu)*mu_scale, 0, 1)) + eps = _eps_overlay(base, εr, ρ); + eps = Colors.RGBA(eps.r, eps.g, eps.b, clamp(alpha(eps)*eps_scale, 0, 1)) + + out = _overlay(Colors.RGBA(base.r, base.g, base.b, 1.0), mu) + out = _overlay(out, eps) + return out +end + +function show_material_scale(; size = (800, 400), backend = nothing) + # if backend !== nothing + # _use_makie_backend(backend) + # end + ensure_backend!(backend === nothing ? :cairo : backend) + + fig = Figure(size = size) + + # sampling density for smooth bars + N = 1024 + + # --- ρ colorbar (log scale by ticks/limits) ------------------------------- + ρmin_log, ρmax_log = log10(RHO_MIN), log10(RHO_MAX) + # sample uniformly in log(ρ) so the bar matches your piecewise mapping + cm_ρ = begin + cols = Vector{RGBA}(undef, N) + for i in 1:N + t = (i - 1) / (N - 1) + ρ = 10^(ρmin_log + t * (ρmax_log - ρmin_log)) + c = _base_color_from_rho(ρ) + cols[i] = RGBA(c.r, c.g, c.b, 1.0) + end + cols + end + + cb_ρ = Colorbar(fig[1, 1]; + colormap = cm_ρ, + limits = (ρmin_log, ρmax_log), # we encode log(ρ) in limits/ticks + vertical = false, + label = "Base color by resistivity ρ [Ω·m] (log scale)", + ) + + # label ticks at meaningful boundaries + edges = [RHO_MIN, 1e-8, 1e-7, RHO_METAL_MAX, RHO_SEMIMETAL, RHO_SEMI_MAX, + 1e4, 1e6, RHO_LEAKY_MAX, RHO_MAX] + cb_ρ.ticks = (log10.(edges), string.(edges)) + + # --- μr overlay colorbar (blue→indigo on mid-gray) ------------------------ + μmin, μmax = 1.0, 300.0 + base_mid = RGB(0.5, 0.5, 0.5) + cm_μ = begin + cols = Vector{RGBA}(undef, N) + for i in 1:N + t = (i - 1) / (N - 1) + μ = 10^(log10(μmin) + t * (log10(μmax) - log10(μmin))) + o = _mu_overlay(base_mid, μ) + out = _overlay(RGBA(base_mid.r, base_mid.g, base_mid.b, 1.0), o) + cols[i] = out + end + cols + end + + cb_μ = Colorbar(fig[2, 1]; + colormap = cm_μ, + limits = (μmin, μmax), + vertical = false, + label = "Magnetic overlay μᵣ (blue→indigo)", + ) + cb_μ.ticks = ( + [1, 2, 5, 10, 20, 50, 100, 200, 300], + string.([1, 2, 5, 10, 20, 50, 100, 200, 300]), + ) + + # --- εr overlay colorbar (teal→cyan on dark base) ------------------------- + εmin, εmax = 1.0, 1000.0 + base_dark = RGB(0.10, 0.10, 0.10) + cm_ε = begin + cols = Vector{RGBA}(undef, N) + for i in 1:N + t = (i - 1) / (N - 1) + ε = 10^(log10(εmin) + t * (log10(εmax) - log10(εmin))) + o = _eps_overlay(base_dark, ε, RHO_MAX + 1) # treat as strong insulator + out = _overlay(RGBA(base_dark.r, base_dark.g, base_dark.b, 1.0), o) + cols[i] = out + end + cols + end + + cb_ε = Colorbar(fig[3, 1]; + colormap = cm_ε, + limits = (εmin, εmax), + vertical = false, + label = "Permittivity overlay εᵣ (teal→cyan)", + ) + cb_ε.ticks = ([1, 2, 5, 10, 20, 50, 100, 200, 500, 1000], + string.([1, 2, 5, 10, 20, 50, 100, 200, 500, 1000])) + + renderfig(fig) + return fig +end + + +################################# +# Geometry helpers (polygons) # +################################# +# polygons (Float32 points; filter non-finite) +function _annulus_poly(rin::Real, rex::Real, x0::Real, y0::Real; N::Int = 256) + N ≥ 32 || throw(ArgumentError("N too small for a smooth annulus")) + θo = range(0, 2π; length = N); + θi = reverse(θo) + xo = x0 .+ rex .* cos.(θo); + yo = y0 .+ rex .* sin.(θo) + xi = x0 .+ rin .* cos.(θi); + yi = y0 .+ rin .* sin.(θi) + px = vcat(xo, xi, xo[1]); + py = vcat(yo, yi, yo[1]) + pts = Makie.Point2f.(px, py) + filter(p -> _valid_finite(p[1], p[2]), pts) +end + +function _circle_poly(r::Real, x0::Real, y0::Real; N::Int = 128) + θ = range(0, 2π; length = N) + x = x0 .+ r .* cos.(θ); + y = y0 .+ r .* sin.(θ) + pts = Makie.Point2f.(vcat(x, x[1]), vcat(y, y[1])) + filter(p -> _valid_finite(p[1], p[2]), pts) +end + +############################# +# Layer -> Makie primitives # +############################# +function _plot_layer_makie!(ax, layer, label::String; + x0::Real = 0.0, y0::Real = 0.0, display_legend::Bool = true, + legend_sink::Union{Nothing, Tuple} = nothing, +) + + if layer isa WireArray + rwire = to_nominal(layer.radius_wire) + nW = layer.num_wires + lay_r = nW == 1 ? 0.0 : to_nominal(layer.radius_in) + color = get_material_color_makie(layer.material_props) + + coords = calc_wirearray_coords(nW, rwire, to_nominal(lay_r), C = (x0, y0)) + + plots = Any[] + handle = nothing + for (i, (x, y)) in enumerate(coords) + poly = Makie.poly!(ax, _circle_poly(rwire, x, y); + color = color, + strokecolor = :black, + strokewidth = 0.5, + label = (i==1 && display_legend) ? label : "") + push!(plots, poly) + if i==1 && display_legend + handle = poly + end + end + + # Legend sink: push one entry per layer. If sink has 3rd slot, store the group. + if legend_sink !== nothing && display_legend && handle !== nothing + push!(legend_sink[1], handle) + push!(legend_sink[2], label) + if length(legend_sink) >= 3 + push!(legend_sink[3], plots) # group = all wires in this layer + end + if length(legend_sink) >= 4 + push!(legend_sink[4], to_nominal(layer.material_props.rho)) # <-- rho key + end + end + return plots + end + + if layer isa Strip || layer isa Tubular || layer isa Semicon || layer isa Insulator + rin = to_nominal(layer.radius_in) + rex = to_nominal(layer.radius_ext) + color = get_material_color_makie(layer.material_props) + + poly = Makie.poly!(ax, _annulus_poly(rin, rex, x0, y0); + color = color, + label = display_legend ? label : "") + + if legend_sink !== nothing && display_legend + push!(legend_sink[1], poly) + push!(legend_sink[2], label) + if length(legend_sink) >= 3 + push!(legend_sink[3], [poly]) + end + if length(legend_sink) >= 4 + push!(legend_sink[4], NaN) # not a wirearray + end + end + return (poly,) + end + + if layer isa ConductorGroup + plots = Any[] + first_label = true + for sub in layer.layers + append!( + plots, + _plot_layer_makie!(ax, sub, + first_label ? lowercase(string(nameof(typeof(layer)))) : ""; + x0 = x0, y0 = y0, display_legend = display_legend, + legend_sink = legend_sink), + ) + first_label = false + end + return plots + end + + @warn "Unknown layer type $(typeof(layer)); skipping" + return () +end + +function apply_default_theme!() + bg = _is_static_backend() ? :white : :gray90 + set_theme!(backgroundcolor = bg, fonts = (; icons = ICON_TTF)) +end + + + +############################################### +# CableDesign cross-section (Makie version) # +############################################### +function preview(design::CableDesign; + x_offset::Real = 0.0, + y_offset::Real = 0.0, + backend::Union{Nothing, Symbol} = nothing, + size::Tuple{Int, Int} = (800, 600), + display_plot::Bool = true, + display_legend::Bool = true, + display_id::Bool = false, + axis = nothing, + legend_sink::Union{Nothing, Tuple{Vector{Any}, Vector{String}}} = nothing, + display_colorbars::Bool = true, + side_frac::Real = 0.26, # ~26% right column +) + + ensure_backend!(backend) + + # backgroundcolor = (_is_static_backend() ? :white : :gray90) + # set_theme!(backgroundcolor = backgroundcolor) + apply_default_theme!() + + fig = + isnothing(axis) ? Makie.Figure(size = size, figure_padding = (10, 10, 10, 10)) : + nothing + + # ── 2 columns: left = main axis, right = container (button + legend + bars) + local ax + local side + if isnothing(axis) + ax = Makie.Axis(fig[1, 1], aspect = Makie.DataAspect()) + side = fig[1, 2] = Makie.GridLayout() # single container on the right + Makie.colsize!(fig.layout, 1, Makie.Relative(1 - side_frac)) + Makie.colsize!(fig.layout, 2, Makie.Relative(side_frac)) + Makie.rowsize!(fig.layout, 1, Makie.Relative(1.0)) + + ax.xlabel = "y [m]" + ax.ylabel = "z [m]" + + ax.title = + display_id ? "Cable design preview: $(design.cable_id)" : + "Cable design preview" + + avail_w = size[1] * (1 - side_frac) + avail_h = size[2] + s = floor(Int, min(avail_w, avail_h)*0.9) + Makie.colsize!(fig.layout, 1, Makie.Fixed(s)) + Makie.rowsize!(fig.layout, 1, Makie.Fixed(s)) + else + ax = axis + side = nothing + end + + # legend sink + local own_legend = false + local sink = legend_sink + if sink === nothing && display_legend + sink = (Any[], String[], Vector{Vector{Any}}(), Float64[]) # handles, labels, groups, rho_keys + own_legend = true + end + + let r = try + to_nominal(design.components[end].insulator_group.radius_ext) + catch + NaN + end + if isfinite(r) && r > 0 + Makie.poly!(ax, _circle_poly(r, x_offset, y_offset); + color = :white, + strokecolor = :transparent) + end + end + + # draw layers + for comp in design.components + for layer in comp.conductor_group.layers + _plot_layer_makie!(ax, layer, lowercase(string(nameof(typeof(layer)))); + x0 = x_offset, y0 = y_offset, + display_legend = display_legend, legend_sink = sink) + end + for layer in comp.insulator_group.layers + _plot_layer_makie!(ax, layer, lowercase(string(nameof(typeof(layer)))); + x0 = x_offset, y0 = y_offset, + display_legend = display_legend, legend_sink = sink) + end + end + + + # Right column: stack button, legend, colorbars + if isnothing(axis) + row_idx = 1 + + if _is_interactive_backend() + # Reset button at top + _add_reset_button!(side[row_idx, 1], ax, fig) + row_idx += 1 + _add_save_svg_button!( + side[row_idx, 1], design; + display_id = display_id, + display_legend = display_legend, + display_colorbars = display_colorbars, + side_frac = side_frac, + size = size, + base = design.cable_id, + ) + row_idx += 1 + end + + # Legend (optional) + if display_legend && own_legend + handles = sink[1] + labels = sink[2] + groups = length(sink) >= 3 ? sink[3] : [[h] for h in handles] + rhos = length(sink) >= 4 ? sink[4] : fill(NaN, length(handles)) + + # Merge consecutive wirearray entries with equal rho + merged_handles = Any[] + merged_labels = String[] + merged_groups = Vector{Any}[] # Vector{Vector{Any}} + + i = 1 + while i <= length(handles) + h = handles[i]; + l = labels[i]; + g = groups[i]; + ρ = rhos[i] + if l == "wirearray" && isfinite(ρ) + j = i + 1 + merged_g = Vector{Any}(g) + while j <= length(handles) && + labels[j] == "wirearray" && + isfinite(rhos[j]) && + isapprox(ρ, rhos[j]; rtol = 1e-6, atol = 0.0) + append!(merged_g, groups[j]) + j += 1 + end + push!(merged_handles, h) # keep first handle for the group + push!(merged_labels, l) # keep label "wirearray" + push!(merged_groups, merged_g) # all wires across merged layers + i = j + else + push!(merged_handles, h) + push!(merged_labels, l) + push!(merged_groups, g) + i += 1 + end + end + + # Build legend with merged entries + leg = Makie.Legend( + side[row_idx, 1], + merged_handles, + merged_labels, + padding = (6, 6, 6, 6), + halign = :center, + valign = :top, + ) + + # Clicking one entry toggles its whole merged group + for (h, grp) in zip(merged_handles, merged_groups) + Makie.on(h.visible) do v + for p in grp + p === h && continue + p.visible[] = v + end + end + end + + row_idx += 1 + end + + # Colorbars (optional) + if display_colorbars + # read actual ranges (helper you already have) + ρmin, ρmax, μmin, μmax, εmin, εmax = _collect_material_ranges(design) + + cbgrid = side[row_idx, 1] = Makie.GridLayout() + _build_colorbars!(cbgrid; ρmin, ρmax, μmin, μmax, εmin, εmax) + + end + end + + if display_plot && isnothing(axis) && !is_in_testset() + resize_to_layout!(fig) + n = next_fignum() + scr = + _is_gl_backend() ? + gl_screen("Fig. $(n) – CableDesign preview: $(design.cable_id)") : + nothing + if scr === nothing + renderfig(fig) + else + display(scr, fig) + end + + end + return fig, ax +end + +function preview(system::LineCableSystem; + earth_model = nothing, + zoom_factor = nothing, + backend::Union{Nothing, Symbol} = nothing, + size::Tuple{Int, Int} = (800, 600), + display_plot::Bool = true, + display_id::Bool = false, + axis = nothing, + display_legend::Bool = true, + display_colorbars::Bool = true, + side_frac::Real = 0.26, +) + + ensure_backend!(backend) + # backgroundcolor = (_is_static_backend() ? :white : :gray90) + + # set_theme!(backgroundcolor = backgroundcolor) + apply_default_theme!() + + fig = + isnothing(axis) ? Makie.Figure(size = size, figure_padding = (10, 10, 10, 10)) : + nothing + + # Layout: left = main axis, right = legend/colorbars (only if we own the axis) + local ax + local side + if isnothing(axis) + ax = Makie.Axis(fig[1, 1], aspect = Makie.DataAspect()) + side = fig[1, 2] = Makie.GridLayout() + Makie.colsize!(fig.layout, 1, Makie.Relative(1 - side_frac)) + Makie.colsize!(fig.layout, 2, Makie.Relative(side_frac)) + Makie.rowsize!(fig.layout, 1, Makie.Relative(1.0)) + + ax.xlabel = "y [m]" + ax.ylabel = "z [m]" + + ax.title = + display_id ? "Cable system cross-section: $(system.system_id)" : + "Cable system cross-section" + + # Make the plotting canvas square if we own the axis + avail_w = size[1] * (1 - side_frac) + avail_h = size[2] + s = floor(Int, min(avail_w, avail_h)*0.9) + Makie.colsize!(fig.layout, 1, Makie.Fixed(s)) + Makie.rowsize!(fig.layout, 1, Makie.Fixed(s)) + else + ax = axis + side = nothing + end + + # Air/earth interface + Makie.hlines!(ax, [0.0], color = :black, linewidth = 1.5) + + # Compute barycentered, square view from cable bounding box + x0s = Float64[to_nominal(c.horz) for c in system.cables] + y0s = Float64[to_nominal(c.vert) for c in system.cables] + radii = Float64[ + (comp = last(c.design_data.components); + max(to_nominal(comp.conductor_group.radius_ext), + to_nominal(comp.insulator_group.radius_ext))) + for c in system.cables + ] + cx = isempty(x0s) ? 0.0 : mean(x0s) + cy = isempty(y0s) ? -1.0 : mean(y0s) + x_min = isempty(x0s) ? -1.0 : minimum(x0s .- radii) + x_max = isempty(x0s) ? 1.0 : maximum(x0s .+ radii) + y_min = isempty(y0s) ? -1.0 : minimum(y0s .- radii) + y_max = isempty(y0s) ? 1.0 : maximum(y0s .+ radii) + half_x = max(x_max - cx, cx - x_min) + half_y = max(y_max - cy, cy - y_min) + base_halfspan = max(half_x, half_y) + base_halfspan = base_halfspan > 0 ? base_halfspan : 1.0 + pad_factor = 1.05 + zf = zoom_factor === nothing ? 1.5 : Float64(zoom_factor) + halfspan = base_halfspan * pad_factor * zf + x_limits = (cx - halfspan, cx + halfspan) + y_limits = (cy - halfspan, cy + halfspan) + + # Expanded fill extents beyond visible region + BUFFER_FILL = 5.0 + x_fill = + (x_limits[1] - 0.5*halfspan - BUFFER_FILL, x_limits[2] + 0.5*halfspan + BUFFER_FILL) + y_fill_min = y_limits[1] - 0.5*halfspan - BUFFER_FILL + + # Build legend entries only for earth layers + earth_handles = Any[] + earth_labels = String[] + + # Plot earth layers if provided and horizontal (vertical_layers == false) + if !isnothing(earth_model) && getfield(earth_model, :vertical_layers) == false + cumulative_depth = 0.0 + # Skip air layer (index 1). Iterate finite-thickness layers; stop on Inf. + for (i, layer) in enumerate(earth_model.layers[2:end]) + # Compute color using the same material convention + # Adapt EarthLayer base_* fields to material_props (rho, eps_r, mu_r) + mat = (; + rho = layer.base_rho_g, + eps_r = layer.base_epsr_g, + mu_r = layer.base_mur_g, + ) + fillcol = get_material_color_makie(mat) + # Slight transparency for fill + fillcol = Makie.RGBA(fillcol.r, fillcol.g, fillcol.b, 0.25) + + if isinf(layer.t) + # Semi-infinite: fill from current depth down to far below visible + ytop = cumulative_depth # bottom of previous finite layer + ybot = y_fill_min # push well below visible range + else + # Finite thickness: update cumulative and compute band extents + t = to_nominal(layer.t) + ytop = cumulative_depth + ybot = cumulative_depth - t + cumulative_depth = ybot + end + + xs = (x_fill[1], x_fill[2], x_fill[2], x_fill[1]) + ys = (ytop, ytop, ybot, ybot) + + # Filled band and a colored interface line + poly = Makie.poly!( + ax, + collect(Makie.Point2f.(xs, ys)), # ensure a Vector, not a Tuple + color = fillcol, + strokecolor = :transparent, + label = "", + ) + Makie.hlines!(ax, [ybot], color = fillcol, linewidth = 1.0) + + if display_legend && isnothing(axis) + push!(earth_handles, poly) + push!(earth_labels, "Earth layer $(i)") + end + end + end + + # Draw each cable onto the same axis (no legend for cable components) + for cable in system.cables + x0 = to_nominal(cable.horz) + y0 = to_nominal(cable.vert) + # Reuse the design-level preview on our axis + preview( + cable.design_data; + x_offset = x0, + y_offset = y0, + backend = backend, + size = size, + display_plot = false, + display_legend = false, + axis = ax, + ) + end + + # Set limits only when we own the axis (square extents) + if isnothing(axis) + Makie.xlims!(ax, x_limits...) + Makie.ylims!(ax, y_limits...) + end + + # Right-column: buttons, earth-only legend and optional colorbars + if isnothing(axis) + row_idx = 1 + if _is_interactive_backend() + _add_reset_button!(side[row_idx, 1], ax, fig) + row_idx += 1 + _add_save_svg_button!( + side[row_idx, 1], system; + earth_model = earth_model, + zoom_factor = zoom_factor, + display_legend = display_legend, + display_colorbars = display_colorbars, + side_frac = side_frac, + display_id = display_id, + size = size, + base = system.system_id, + ) + row_idx += 1 + end + + if display_legend && !isempty(earth_handles) + Makie.Legend( + side[row_idx, 1], + earth_handles, + earth_labels, + padding = (6, 6, 6, 6), + halign = :center, + valign = :top, + ) + row_idx += 1 + end + + if display_colorbars + ρmin, ρmax, μmin, μmax, εmin, εmax = _collect_earth_ranges(earth_model) + cbgrid = side[row_idx, 1] = Makie.GridLayout() + _build_colorbars!( + cbgrid; + ρmin, + ρmax, + μmin, + μmax, + εmin, + εmax, + alpha_global = 0.25, + showμminmax = false, + showεminmax = false, + ) + end + end + + + if display_plot && isnothing(axis) && !is_in_testset() + resize_to_layout!(fig) + n = next_fignum() + scr = + _is_gl_backend() ? + gl_screen("Fig. $(n) – LineCableSystem preview: $(system.system_id)") : + nothing + if scr === nothing + renderfig(fig) + else + display(scr, fig) + end + + end + + return fig, ax +end + +# Add a save-to-SVG button to a grid cell, re-rendering with Cairo backend +function _add_save_svg_button!(parent_cell, system; + earth_model = nothing, + zoom_factor = nothing, + display_id::Bool, + display_legend::Bool, + display_colorbars::Bool, + side_frac::Real, + size::Tuple{Int, Int}, + base::String = "preview", + save_dir::AbstractString = pwd(), +) + btn = Makie.Button( + parent_cell, + label = with_icon(MI_SAVE; text = "Save SVG"), + halign = :center, + valign = :top, + width = Makie.Auto(), + ) + Makie.on(btn.clicks) do _ + @async begin + orig_label = btn.label[] + btn.label[] = "Saving…" + orig_color = hasproperty(btn, :buttoncolor) ? btn.buttoncolor[] : nothing + try + # _use_makie_backend(:cairo) + ensure_backend!(:cairo) + if system isa CableDesign + fig, _ = preview( + system; + display_legend = display_legend, + display_colorbars = display_colorbars, + display_plot = false, + size = size, + display_id = display_id, + backend = :cairo, + side_frac = side_frac, + ) + elseif system isa LineCableSystem + fig, _ = preview( + system; + earth_model = earth_model, + zoom_factor = zoom_factor, + display_id = display_id, + display_legend = display_legend, + display_colorbars = display_colorbars, + display_plot = false, + size = size, + backend = :cairo, + side_frac = side_frac, + ) + end + ts = Dates.format(Dates.now(), "yyyymmdd-HHMMSS") + file = joinpath(save_dir, "$(base)_$ts.svg") + Makie.save(file, fig) + btn.label[] = "Saved ✓" + @info "Saved figure to $(file)" + hasproperty(btn, :buttoncolor) && + (btn.buttoncolor[] = Makie.RGBA(0.15, 0.65, 0.25, 1.0)) + sleep(1.2) + catch e + @error "Save failed: $(typeof(e)): $(e)" + btn.label[] = "Failed ✗" + hasproperty(btn, :buttoncolor) && + (btn.buttoncolor[] = Makie.RGBA(0.80, 0.20, 0.20, 1.0)) + sleep(1.6) + finally + if orig_color !== nothing + btn.buttoncolor[] = orig_color + end + btn.label[] = orig_label + end + end + end + return btn +end + +# Add a reset button to a grid cell, wired to reset axis limits +function _add_reset_button!(parent_cell, ax, fig) + btn = Makie.Button( + parent_cell, + label = with_icon(MI_REFRESH; text = "Reset view"), + halign = :center, + valign = :top, + width = Makie.Relative(1.0), + ) + Makie.on(btn.clicks) do _ + reset_limits!(ax) + # resize_to_layout!(fig) + + end + return btn +end + +function _build_colorbars!(cbgrid::Makie.GridLayout; + ρmin::Real, ρmax::Real, μmin::Real, μmax::Real, εmin::Real, εmax::Real, + cb_bar_h::Int = 12, alpha_global::Real = 1.0, showρminmax::Bool = true, + showμminmax::Bool = true, showεminmax::Bool = true, +) + Makie.colsize!(cbgrid, 1, Makie.Fixed(2)) + + function _nice(x) + axv = abs(x) + axv == 0 && return "0" + (axv ≥ 1e-3 && axv < 1e4) ? @sprintf("%.4g", x) : @sprintf("%.1e", x) + end + + N = 256 + idx=1 + + # ρ bar sampled in log-space between actual min/max + if showρminmax + cm_ρ = let cols = Vector{Makie.RGBA}(undef, N) + lo, hi = log10(ρmin), log10(ρmax) + for i in 1:N + t = (i-1)/(N-1) + ρ = 10^(lo + t*(hi - lo)) + c = _base_color_from_rho(ρ) + cols[i] = Makie.RGBA(c.r, c.g, c.b, 1*alpha_global) + end; + cols + end + Makie.Label(cbgrid[idx, 1], L"\rho"; halign = :left, fontsize = 16) + Makie.Colorbar(cbgrid[idx, 2]; colormap = cm_ρ, limits = (0.0, 1.0), + vertical = false, + ticks = ([0.0, 1.0], [_nice(ρmin), _nice(ρmax)]), + labelvisible = false, height = cb_bar_h) + idx += 1 + end + + # μr bar overlay on mid-gray + if showμminmax + base_mid = Makie.RGB(0.5, 0.5, 0.5) + cm_μ = let cols = Vector{Makie.RGBA}(undef, N) + lo, hi = log10(μmin), log10(μmax) + for i in 1:N + t = (i-1)/(N-1) + μ = 10^(lo + t*(hi - lo)) + o = _mu_overlay(base_mid, μ) + cols[i] = _overlay( + Makie.RGBA(base_mid.r, base_mid.g, base_mid.b, 1), + o*alpha_global, + ) + end; + cols + end + Makie.Label(cbgrid[idx, 1], L"\mu_{r}"; halign = :left, fontsize = 16) + Makie.Colorbar(cbgrid[idx, 2]; colormap = cm_μ, limits = (0.0, 1.0), + vertical = false, + ticks = ([0.0, 1.0], [_nice(μmin), _nice(μmax)]), + labelvisible = false, height = cb_bar_h) + idx += 1 + end + + if showεminmax + # εr bar overlay on dark + base_dark = Makie.RGB(0.10, 0.10, 0.10) + cm_ε = let cols = Vector{Makie.RGBA}(undef, N) + lo, hi = log10(εmin), log10(εmax) + for i in 1:N + t = (i-1)/(N-1) + ε = 10^(lo + t*(hi - lo)) + o = _eps_overlay(base_dark, ε, RHO_MAX + 1) + cols[i] = _overlay( + Makie.RGBA(base_dark.r, base_dark.g, base_dark.b, 1), + o*alpha_global, + ) + end; + cols + end + Makie.Label(cbgrid[idx, 1], L"\varepsilon_{r}"; halign = :left, fontsize = 16) + Makie.Colorbar(cbgrid[idx, 2]; colormap = cm_ε, limits = (0.0, 1.0), + vertical = false, + ticks = ([0.0, 1.0], [_nice(εmin), _nice(εmax)]), + labelvisible = false, height = cb_bar_h) + end + + return cbgrid +end + +# collect actual property ranges from the design (finite values only) +function _collect_material_ranges(design::CableDesign) + rhos = Float64[] + mus = Float64[] + epses = Float64[] + + _push_props!(layer) = begin + ρ = try + to_nominal(layer.material_props.rho) + catch + NaN + end + μr = try + to_nominal(layer.material_props.mu_r) + catch + NaN + end + εr = try + to_nominal(layer.material_props.eps_r) + catch + NaN + end + isfinite(ρ) && push!(rhos, ρ) + isfinite(μr) && push!(mus, μr) + isfinite(εr) && push!(epses, εr) + nothing + end + + for comp in design.components + for L in comp.conductor_group.layers + if L isa ConductorGroup + for s in L.layers + _push_props!(s); + end + else + _push_props!(L) + end + end + for L in comp.insulator_group.layers + _push_props!(L) + end + end + + ρmin = isempty(rhos) ? RHO_MIN : minimum(rhos) + ρmax = isempty(rhos) ? RHO_MAX : maximum(rhos) + μmin = isempty(mus) ? 1.0 : max(1.0, minimum(mus)) + μmax = isempty(mus) ? 300.0 : maximum(mus) + εmin = isempty(epses) ? 1.0 : max(1.0, minimum(epses)) + εmax = isempty(epses) ? 1000.0 : maximum(epses) + ρmax == ρmin && (ρmax = nextfloat(ρmax)) + μmax == μmin && (μmax += 1e-6) + εmax == εmin && (εmax += 1e-6) + + return ρmin, ρmax, μmin, μmax, εmin, εmax +end + +function _collect_earth_ranges(earth_model) + rhos = Float64[]; + mus = Float64[]; + epses = Float64[] + if !isnothing(earth_model) + for layer in earth_model.layers[2:end] + ρ = try + to_nominal(layer.base_rho_g) + catch + NaN + end + μr = try + to_nominal(layer.base_mur_g) + catch + NaN + end + εr = try + to_nominal(layer.base_epsr_g) + catch + NaN + end + isfinite(ρ) && push!(rhos, ρ) + isfinite(μr) && push!(mus, μr) + isfinite(εr) && push!(epses, εr) + end + end + ρmin = isempty(rhos) ? RHO_MIN : minimum(rhos) + ρmax = isempty(rhos) ? RHO_MAX : maximum(rhos) + μmin = isempty(mus) ? 1.0 : max(1.0, minimum(mus)) + μmax = isempty(mus) ? 300.0 : maximum(mus) + εmin = isempty(epses) ? 1.0 : max(1.0, minimum(epses)) + εmax = isempty(epses) ? 1000.0 : maximum(epses) + return ρmin, ρmax, μmin, μmax, εmin, εmax +end diff --git a/src/datamodel/radii.jl b/src/datamodel/radii.jl index ae98b7f5..46c4d601 100644 --- a/src/datamodel/radii.jl +++ b/src/datamodel/radii.jl @@ -1,104 +1,104 @@ -""" -$(TYPEDSIGNATURES) - -Resolves radius parameters for cable components, converting from various input formats to standardized inner radius, outer radius, and thickness values. - -This function serves as a high-level interface to the radius resolution system. It processes inputs through a two-stage pipeline: -1. First normalizes input parameters to consistent forms using [`_parse_radius_operand`](@ref). -2. Then delegates to specialized implementations via [`_do_resolve_radius`](@ref) based on the component type. - -# Arguments - -- `param_in`: Inner boundary parameter (defaults to radius) \\[m\\]. - Can be a number, a [`Diameter`](@ref) , a [`Thickness`](@ref), or an [`AbstractCablePart`](@ref). -- `param_ext`: Outer boundary parameter (defaults to radius) \\[m\\]. - Can be a number, a [`Diameter`](@ref) , a [`Thickness`](@ref), or an [`AbstractCablePart`](@ref). -- `object_type`: Type associated to the constructor of the new [`AbstractCablePart`](@ref). - -# Returns - -- `radius_in`: Normalized inner radius \\[m\\]. -- `radius_ext`: Normalized outer radius \\[m\\]. -- `thickness`: Computed thickness or specialized dimension depending on the method \\[m\\]. - For [`WireArray`](@ref) components, this value represents the wire radius instead of thickness. - -# See also - -- [`Diameter`](@ref) -- [`Thickness`](@ref) -- [`AbstractCablePart`](@ref) -""" -@inline _normalize_radii(::Type{T}, rin, rex) where {T} = - _do_normalize_radii(_parse_radius_operand(rin, T), _parse_radius_operand(rex, T), T) - -""" -$(TYPEDSIGNATURES) - -Parses input values into radius representation based on object type and input type. - -# Arguments - -- `x`: Input value that can be a raw number, a [`Diameter`](@ref), a [`Thickness`](@ref), or other convertible type \\[m\\]. -- `object_type`: Type parameter used for dispatch. - -# Returns - -- Parsed radius value in appropriate units \\[m\\]. - -# Examples - -```julia -radius = $(FUNCTIONNAME)(10.0, ...) # Direct radius value -radius = $(FUNCTIONNAME)(Diameter(20.0), ...) # From diameter object -radius = $(FUNCTIONNAME)(Thickness(5.0), ...) # From thickness object -``` - -# Methods - -$(METHODLIST) - -# See also - -- [`Diameter`](@ref) -- [`Thickness`](@ref) -""" -function _parse_radius_operand end - -# ------------ Input parsing -@inline _parse_radius_operand(x::Number, ::Type{T}) where {T} = x -@inline _parse_radius_operand(d::Diameter, ::Type{T}) where {T} = d.value / 2 -@inline _parse_radius_operand(p::Thickness, ::Type{T}) where {T} = p -@inline function _parse_radius_operand(p::AbstractCablePart, ::Type{T}) where {T} - r = getfield(p, :radius_ext) # outer radius of prior layer - return (typeof(p) == T) ? r : to_certain(r) -end -@inline _parse_radius_operand(x::AbstractString, ::Type{T}) where {T} = - throw(ArgumentError("[$(nameof(T))] radius parameter must be numeric, not String: $(repr(x))")) -@inline _parse_radius_operand(x, ::Type{T}) where {T} = - throw(ArgumentError("[$(nameof(T))] unsupported radius parameter $(typeof(x)): $(repr(x))")) - - - -# ------------ Input parsing -@inline function _do_normalize_radii(radius_in::Number, radius_ext::Number, ::Type{T}) where {T} - return radius_in, radius_ext -end - -@inline function _do_normalize_radii(radius_in::Number, thickness::Thickness, ::Type{T}) where {T} - return radius_in, (radius_in + thickness.value) -end - -@inline function _do_normalize_radii(radius_in::Number, radius_wire::Number, ::Type{AbstractWireArray}) - return radius_in, radius_in + (2 * radius_wire) -end - -@inline function _do_normalize_radii(t::Thickness, rex::Number, ::Type{T}) where {T} - rin = rex - t.value - rin >= 0 || throw(ArgumentError("[$(nameof(T))] thickness $(t.value) exceeds outer radius $(rex).")) - return rin, rex -end - -# NEW: reject thickness on BOTH ends -@inline function _do_normalize_radii(::Thickness, ::Thickness, ::Type{T}) where {T} - throw(ArgumentError("[$(nameof(T))] cannot specify thickness for both inner and outer radii.")) +""" +$(TYPEDSIGNATURES) + +Resolves radius parameters for cable components, converting from various input formats to standardized inner radius, outer radius, and thickness values. + +This function serves as a high-level interface to the radius resolution system. It processes inputs through a two-stage pipeline: +1. First normalizes input parameters to consistent forms using [`_parse_radius_operand`](@ref). +2. Then delegates to specialized implementations via [`_do_resolve_radius`](@ref) based on the component type. + +# Arguments + +- `param_in`: Inner boundary parameter (defaults to radius) \\[m\\]. + Can be a number, a [`Diameter`](@ref) , a [`Thickness`](@ref), or an [`AbstractCablePart`](@ref). +- `param_ext`: Outer boundary parameter (defaults to radius) \\[m\\]. + Can be a number, a [`Diameter`](@ref) , a [`Thickness`](@ref), or an [`AbstractCablePart`](@ref). +- `object_type`: Type associated to the constructor of the new [`AbstractCablePart`](@ref). + +# Returns + +- `radius_in`: Normalized inner radius \\[m\\]. +- `radius_ext`: Normalized outer radius \\[m\\]. +- `thickness`: Computed thickness or specialized dimension depending on the method \\[m\\]. + For [`WireArray`](@ref) components, this value represents the wire radius instead of thickness. + +# See also + +- [`Diameter`](@ref) +- [`Thickness`](@ref) +- [`AbstractCablePart`](@ref) +""" +@inline _normalize_radii(::Type{T}, rin, rex) where {T} = + _do_normalize_radii(_parse_radius_operand(rin, T), _parse_radius_operand(rex, T), T) + +""" +$(TYPEDSIGNATURES) + +Parses input values into radius representation based on object type and input type. + +# Arguments + +- `x`: Input value that can be a raw number, a [`Diameter`](@ref), a [`Thickness`](@ref), or other convertible type \\[m\\]. +- `object_type`: Type parameter used for dispatch. + +# Returns + +- Parsed radius value in appropriate units \\[m\\]. + +# Examples + +```julia +radius = $(FUNCTIONNAME)(10.0, ...) # Direct radius value +radius = $(FUNCTIONNAME)(Diameter(20.0), ...) # From diameter object +radius = $(FUNCTIONNAME)(Thickness(5.0), ...) # From thickness object +``` + +# Methods + +$(METHODLIST) + +# See also + +- [`Diameter`](@ref) +- [`Thickness`](@ref) +""" +function _parse_radius_operand end + +# ------------ Input parsing +@inline _parse_radius_operand(x::Number, ::Type{T}) where {T} = x +@inline _parse_radius_operand(d::Diameter, ::Type{T}) where {T} = d.value / 2 +@inline _parse_radius_operand(p::Thickness, ::Type{T}) where {T} = p +@inline function _parse_radius_operand(p::AbstractCablePart, ::Type{T}) where {T} + r = getfield(p, :radius_ext) # outer radius of prior layer + return (typeof(p) == T) ? r : to_certain(r) +end +@inline _parse_radius_operand(x::AbstractString, ::Type{T}) where {T} = + throw(ArgumentError("[$(nameof(T))] radius parameter must be numeric, not String: $(repr(x))")) +@inline _parse_radius_operand(x, ::Type{T}) where {T} = + throw(ArgumentError("[$(nameof(T))] unsupported radius parameter $(typeof(x)): $(repr(x))")) + + + +# ------------ Input parsing +@inline function _do_normalize_radii(radius_in::Number, radius_ext::Number, ::Type{T}) where {T} + return radius_in, radius_ext +end + +@inline function _do_normalize_radii(radius_in::Number, thickness::Thickness, ::Type{T}) where {T} + return radius_in, (radius_in + thickness.value) +end + +@inline function _do_normalize_radii(radius_in::Number, radius_wire::Number, ::Type{AbstractWireArray}) + return radius_in, radius_in + (2 * radius_wire) +end + +@inline function _do_normalize_radii(t::Thickness, rex::Number, ::Type{T}) where {T} + rin = rex - t.value + rin >= 0 || throw(ArgumentError("[$(nameof(T))] thickness $(t.value) exceeds outer radius $(rex).")) + return rin, rex +end + +# NEW: reject thickness on BOTH ends +@inline function _do_normalize_radii(::Thickness, ::Thickness, ::Type{T}) where {T} + throw(ArgumentError("[$(nameof(T))] cannot specify thickness for both inner and outer radii.")) end \ No newline at end of file diff --git a/src/datamodel/semicon.jl b/src/datamodel/semicon.jl index 110544d7..9de8ad4c 100644 --- a/src/datamodel/semicon.jl +++ b/src/datamodel/semicon.jl @@ -1,116 +1,116 @@ -""" -$(TYPEDEF) - -Represents a semiconducting layer with defined geometric, material, and electrical properties given by the attributes: - -$(TYPEDFIELDS) -""" -struct Semicon{T <: REALSCALAR} <: AbstractInsulatorPart{T} - "Internal radius of the semiconducting layer \\[m\\]." - radius_in::T - "External radius of the semiconducting layer \\[m\\]." - radius_ext::T - "Material properties of the semiconductor." - material_props::Material{T} - "Operating temperature of the semiconductor \\[°C\\]." - temperature::T - "Cross-sectional area of the semiconducting layer \\[m²\\]." - cross_section::T - "Electrical resistance of the semiconducting layer \\[Ω/m\\]." - resistance::T - "Geometric mean radius of the semiconducting layer \\[m\\]." - gmr::T - "Shunt capacitance per unit length of the semiconducting layer \\[F/m\\]." - shunt_capacitance::T - "Shunt conductance per unit length of the semiconducting layer \\[S·m\\]." - shunt_conductance::T -end - -""" -$(TYPEDSIGNATURES) - -Constructs a [`Semicon`](@ref) instance with calculated electrical and geometric properties. - -# Arguments - -- `radius_in`: Internal radius of the semiconducting layer \\[m\\]. -- `radius_ext`: External radius or thickness of the layer \\[m\\]. -- `material_props`: Material properties of the semiconducting material. -- `temperature`: Operating temperature of the layer \\[°C\\] (default: T₀). - -# Returns - -- A [`Semicon`](@ref) object with initialized properties. - -# Examples - -```julia -material_props = Material(1e6, 2.3, 1.0, 20.0, 0.00393) -semicon_layer = $(FUNCTIONNAME)(0.01, Thickness(0.002), material_props, temperature=25) -println(semicon_layer.cross_section) # Expected output: ~6.28e-5 [m²] -println(semicon_layer.resistance) # Expected output: Resistance in [Ω/m] -println(semicon_layer.gmr) # Expected output: GMR in [m] -println(semicon_layer.shunt_capacitance) # Expected output: Capacitance in [F/m] -println(semicon_layer.shunt_conductance) # Expected output: Conductance in [S·m] -``` -""" -function Semicon( - radius_in::T, - radius_ext::T, - material_props::Material{T}, - temperature::T, -) where {T <: REALSCALAR} - - rho = material_props.rho - T0 = material_props.T0 - alpha = material_props.alpha - epsr_r = material_props.eps_r - - cross_section = π * (radius_ext^2 - radius_in^2) - - resistance = - calc_tubular_resistance(radius_in, radius_ext, rho, alpha, T0, temperature) - gmr = calc_tubular_gmr(radius_ext, radius_in, material_props.mu_r) - shunt_capacitance = calc_shunt_capacitance(radius_in, radius_ext, epsr_r) - shunt_conductance = calc_shunt_conductance(radius_in, radius_ext, rho) - - # Initialize object - return Semicon( - radius_in, - radius_ext, - material_props, - temperature, - cross_section, - resistance, - gmr, - shunt_capacitance, - shunt_conductance, - ) -end - -const _REQ_SEMICON = (:radius_in, :radius_ext, :material_props) -const _OPT_SEMICON = (:temperature,) -const _DEFS_SEMICON = (T₀,) - -Validation.has_radii(::Type{Semicon}) = true -Validation.has_temperature(::Type{Semicon}) = true -Validation.required_fields(::Type{Semicon}) = _REQ_SEMICON -Validation.keyword_fields(::Type{Semicon}) = _OPT_SEMICON -Validation.keyword_defaults(::Type{Semicon}) = _DEFS_SEMICON - -# accept proxies for radii -Validation.is_radius_input(::Type{Semicon}, ::Val{:radius_in}, x::AbstractCablePart) = true -Validation.is_radius_input(::Type{Semicon}, ::Val{:radius_in}, x::Thickness) = true -Validation.is_radius_input(::Type{Semicon}, ::Val{:radius_ext}, x::Thickness) = true -Validation.is_radius_input(::Type{Semicon}, ::Val{:radius_ext}, x::Diameter) = true - -Validation.extra_rules(::Type{Semicon}) = (IsA{Material}(:material_props),) - -# normalize proxies -> numbers -Validation.parse(::Type{Semicon}, nt) = begin - rin, rex = _normalize_radii(Semicon, nt.radius_in, nt.radius_ext) - (; nt..., radius_in = rin, radius_ext = rex) -end - -# This macro expands to a weakly-typed constructor for Semicon -@construct Semicon _REQ_SEMICON _OPT_SEMICON _DEFS_SEMICON +""" +$(TYPEDEF) + +Represents a semiconducting layer with defined geometric, material, and electrical properties given by the attributes: + +$(TYPEDFIELDS) +""" +struct Semicon{T <: REALSCALAR} <: AbstractInsulatorPart{T} + "Internal radius of the semiconducting layer \\[m\\]." + radius_in::T + "External radius of the semiconducting layer \\[m\\]." + radius_ext::T + "Material properties of the semiconductor." + material_props::Material{T} + "Operating temperature of the semiconductor \\[°C\\]." + temperature::T + "Cross-sectional area of the semiconducting layer \\[m²\\]." + cross_section::T + "Electrical resistance of the semiconducting layer \\[Ω/m\\]." + resistance::T + "Geometric mean radius of the semiconducting layer \\[m\\]." + gmr::T + "Shunt capacitance per unit length of the semiconducting layer \\[F/m\\]." + shunt_capacitance::T + "Shunt conductance per unit length of the semiconducting layer \\[S·m\\]." + shunt_conductance::T +end + +""" +$(TYPEDSIGNATURES) + +Constructs a [`Semicon`](@ref) instance with calculated electrical and geometric properties. + +# Arguments + +- `radius_in`: Internal radius of the semiconducting layer \\[m\\]. +- `radius_ext`: External radius or thickness of the layer \\[m\\]. +- `material_props`: Material properties of the semiconducting material. +- `temperature`: Operating temperature of the layer \\[°C\\] (default: T₀). + +# Returns + +- A [`Semicon`](@ref) object with initialized properties. + +# Examples + +```julia +material_props = Material(1e6, 2.3, 1.0, 20.0, 0.00393) +semicon_layer = $(FUNCTIONNAME)(0.01, Thickness(0.002), material_props, temperature=25) +println(semicon_layer.cross_section) # Expected output: ~6.28e-5 [m²] +println(semicon_layer.resistance) # Expected output: Resistance in [Ω/m] +println(semicon_layer.gmr) # Expected output: GMR in [m] +println(semicon_layer.shunt_capacitance) # Expected output: Capacitance in [F/m] +println(semicon_layer.shunt_conductance) # Expected output: Conductance in [S·m] +``` +""" +function Semicon( + radius_in::T, + radius_ext::T, + material_props::Material{T}, + temperature::T, +) where {T <: REALSCALAR} + + rho = material_props.rho + T0 = material_props.T0 + alpha = material_props.alpha + epsr_r = material_props.eps_r + + cross_section = π * (radius_ext^2 - radius_in^2) + + resistance = + calc_tubular_resistance(radius_in, radius_ext, rho, alpha, T0, temperature) + gmr = calc_tubular_gmr(radius_ext, radius_in, material_props.mu_r) + shunt_capacitance = calc_shunt_capacitance(radius_in, radius_ext, epsr_r) + shunt_conductance = calc_shunt_conductance(radius_in, radius_ext, rho) + + # Initialize object + return Semicon( + radius_in, + radius_ext, + material_props, + temperature, + cross_section, + resistance, + gmr, + shunt_capacitance, + shunt_conductance, + ) +end + +const _REQ_SEMICON = (:radius_in, :radius_ext, :material_props) +const _OPT_SEMICON = (:temperature,) +const _DEFS_SEMICON = (T₀,) + +Validation.has_radii(::Type{Semicon}) = true +Validation.has_temperature(::Type{Semicon}) = true +Validation.required_fields(::Type{Semicon}) = _REQ_SEMICON +Validation.keyword_fields(::Type{Semicon}) = _OPT_SEMICON +Validation.keyword_defaults(::Type{Semicon}) = _DEFS_SEMICON + +# accept proxies for radii +Validation.is_radius_input(::Type{Semicon}, ::Val{:radius_in}, x::AbstractCablePart) = true +Validation.is_radius_input(::Type{Semicon}, ::Val{:radius_in}, x::Thickness) = true +Validation.is_radius_input(::Type{Semicon}, ::Val{:radius_ext}, x::Thickness) = true +Validation.is_radius_input(::Type{Semicon}, ::Val{:radius_ext}, x::Diameter) = true + +Validation.extra_rules(::Type{Semicon}) = (IsA{Material}(:material_props),) + +# normalize proxies -> numbers +Validation.parse(::Type{Semicon}, nt) = begin + rin, rex = _normalize_radii(Semicon, nt.radius_in, nt.radius_ext) + (; nt..., radius_in = rin, radius_ext = rex) +end + +# This macro expands to a weakly-typed constructor for Semicon +@construct Semicon _REQ_SEMICON _OPT_SEMICON _DEFS_SEMICON diff --git a/src/datamodel/strip.jl b/src/datamodel/strip.jl index 6738981a..7d341d84 100644 --- a/src/datamodel/strip.jl +++ b/src/datamodel/strip.jl @@ -1,155 +1,155 @@ -""" -$(TYPEDEF) - -Represents a flat conductive strip with defined geometric and material properties given by the attributes: - -$(TYPEDFIELDS) -""" -struct Strip{T <: REALSCALAR} <: AbstractConductorPart{T} - "Internal radius of the strip \\[m\\]." - radius_in::T - "External radius of the strip \\[m\\]." - radius_ext::T - "Thickness of the strip \\[m\\]." - thickness::T - "Width of the strip \\[m\\]." - width::T - "Ratio defining the lay length of the strip (twisting factor) \\[dimensionless\\]." - lay_ratio::T - "Mean diameter of the strip's helical path \\[m\\]." - mean_diameter::T - "Pitch length of the strip's helical path \\[m\\]." - pitch_length::T - "Twisting direction of the strip (1 = unilay, -1 = contralay) \\[dimensionless\\]." - lay_direction::Int - "Material properties of the strip." - material_props::Material{T} - "Temperature at which the properties are evaluated \\[°C\\]." - temperature::T - "Cross-sectional area of the strip \\[m²\\]." - cross_section::T - "Electrical resistance of the strip \\[Ω/m\\]." - resistance::T - "Geometric mean radius of the strip \\[m\\]." - gmr::T -end - -""" -$(TYPEDSIGNATURES) - -Constructs a [`Strip`](@ref) object with specified geometric and material parameters. - -# Arguments - -- `radius_in`: Internal radius of the strip \\[m\\]. -- `radius_ext`: External radius or thickness of the strip \\[m\\]. -- `width`: Width of the strip \\[m\\]. -- `lay_ratio`: Ratio defining the lay length of the strip \\[dimensionless\\]. -- `material_props`: Material properties of the strip. -- `temperature`: Temperature at which the properties are evaluated \\[°C\\]. Defaults to [`T₀`](@ref). -- `lay_direction`: Twisting direction of the strip (1 = unilay, -1 = contralay) \\[dimensionless\\]. Defaults to 1. - -# Returns - -- A [`Strip`](@ref) object with calculated geometric and electrical properties. - -# Examples - -```julia -material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) -strip = $(FUNCTIONNAME)(0.01, Thickness(0.002), 0.05, 10, material_props, temperature=25) -println(strip.cross_section) # Output: 0.0001 [m²] -println(strip.resistance) # Output: Resistance value [Ω/m] -``` - -# See also - -- [`Material`](@ref) -- [`ConductorGroup`](@ref) -- [`calc_strip_resistance`](@ref) -- [`calc_tubular_gmr`](@ref) -- [`calc_helical_params`](@ref) -""" -function Strip( - radius_in::T, - radius_ext::T, - width::T, - lay_ratio::T, - material_props::Material{T}, - temperature::T, - lay_direction::Int, -) where {T <: REALSCALAR} - - thickness = radius_ext - radius_in - rho = material_props.rho - T0 = material_props.T0 - alpha = material_props.alpha - - mean_diameter, pitch_length, overlength = calc_helical_params( - radius_in, - radius_ext, - lay_ratio, - ) - - cross_section = thickness * width - - R_strip = - calc_strip_resistance(thickness, width, rho, alpha, T0, temperature) * - overlength - - gmr = calc_tubular_gmr(radius_ext, radius_in, material_props.mu_r) - - # Initialize object - return Strip( - radius_in, - radius_ext, - thickness, - width, - lay_ratio, - mean_diameter, - pitch_length, - lay_direction, - material_props, - temperature, - cross_section, - R_strip, - gmr, - ) -end - -const _REQ_STRIP = (:radius_in, :radius_ext, :width, :lay_ratio, :material_props) -const _OPT_STRIP = (:temperature, :lay_direction) -const _DEFS_STRIP = (T₀, 1) - -Validation.has_radii(::Type{Strip}) = true -Validation.has_temperature(::Type{Strip}) = true -Validation.required_fields(::Type{Strip}) = _REQ_STRIP -Validation.keyword_fields(::Type{Strip}) = _OPT_STRIP -Validation.keyword_defaults(::Type{Strip}) = _DEFS_STRIP - -Validation.coercive_fields(::Type{Strip}) = - (:radius_in, :radius_ext, :width, :lay_ratio, :material_props, :temperature) # not :lay_direction -# accept proxies for radii - -Validation.is_radius_input(::Type{Strip}, ::Val{:radius_in}, x::AbstractCablePart) = true -Validation.is_radius_input(::Type{Strip}, ::Val{:radius_in}, x::Thickness) = true -Validation.is_radius_input(::Type{Strip}, ::Val{:radius_ext}, x::Thickness) = true -Validation.is_radius_input(::Type{Strip}, ::Val{:radius_ext}, x::Diameter) = true - -Validation.extra_rules(::Type{Strip}) = ( - IsA{Material}(:material_props), - OneOf(:lay_direction, (-1, 1)), - Finite(:lay_ratio), - Nonneg(:lay_ratio), - Finite(:width), - Positive(:width), -) - -# normalize proxies -> numbers -Validation.parse(::Type{Strip}, nt) = begin - rin, rex = _normalize_radii(Strip, nt.radius_in, nt.radius_ext) - (; nt..., radius_in = rin, radius_ext = rex) -end - -# This macro expands to a weakly-typed constructor for Strip -@construct Strip _REQ_STRIP _OPT_STRIP _DEFS_STRIP +""" +$(TYPEDEF) + +Represents a flat conductive strip with defined geometric and material properties given by the attributes: + +$(TYPEDFIELDS) +""" +struct Strip{T <: REALSCALAR} <: AbstractConductorPart{T} + "Internal radius of the strip \\[m\\]." + radius_in::T + "External radius of the strip \\[m\\]." + radius_ext::T + "Thickness of the strip \\[m\\]." + thickness::T + "Width of the strip \\[m\\]." + width::T + "Ratio defining the lay length of the strip (twisting factor) \\[dimensionless\\]." + lay_ratio::T + "Mean diameter of the strip's helical path \\[m\\]." + mean_diameter::T + "Pitch length of the strip's helical path \\[m\\]." + pitch_length::T + "Twisting direction of the strip (1 = unilay, -1 = contralay) \\[dimensionless\\]." + lay_direction::Int + "Material properties of the strip." + material_props::Material{T} + "Temperature at which the properties are evaluated \\[°C\\]." + temperature::T + "Cross-sectional area of the strip \\[m²\\]." + cross_section::T + "Electrical resistance of the strip \\[Ω/m\\]." + resistance::T + "Geometric mean radius of the strip \\[m\\]." + gmr::T +end + +""" +$(TYPEDSIGNATURES) + +Constructs a [`Strip`](@ref) object with specified geometric and material parameters. + +# Arguments + +- `radius_in`: Internal radius of the strip \\[m\\]. +- `radius_ext`: External radius or thickness of the strip \\[m\\]. +- `width`: Width of the strip \\[m\\]. +- `lay_ratio`: Ratio defining the lay length of the strip \\[dimensionless\\]. +- `material_props`: Material properties of the strip. +- `temperature`: Temperature at which the properties are evaluated \\[°C\\]. Defaults to [`T₀`](@ref). +- `lay_direction`: Twisting direction of the strip (1 = unilay, -1 = contralay) \\[dimensionless\\]. Defaults to 1. + +# Returns + +- A [`Strip`](@ref) object with calculated geometric and electrical properties. + +# Examples + +```julia +material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) +strip = $(FUNCTIONNAME)(0.01, Thickness(0.002), 0.05, 10, material_props, temperature=25) +println(strip.cross_section) # Output: 0.0001 [m²] +println(strip.resistance) # Output: Resistance value [Ω/m] +``` + +# See also + +- [`Material`](@ref) +- [`ConductorGroup`](@ref) +- [`calc_strip_resistance`](@ref) +- [`calc_tubular_gmr`](@ref) +- [`calc_helical_params`](@ref) +""" +function Strip( + radius_in::T, + radius_ext::T, + width::T, + lay_ratio::T, + material_props::Material{T}, + temperature::T, + lay_direction::Int, +) where {T <: REALSCALAR} + + thickness = radius_ext - radius_in + rho = material_props.rho + T0 = material_props.T0 + alpha = material_props.alpha + + mean_diameter, pitch_length, overlength = calc_helical_params( + radius_in, + radius_ext, + lay_ratio, + ) + + cross_section = thickness * width + + R_strip = + calc_strip_resistance(thickness, width, rho, alpha, T0, temperature) * + overlength + + gmr = calc_tubular_gmr(radius_ext, radius_in, material_props.mu_r) + + # Initialize object + return Strip( + radius_in, + radius_ext, + thickness, + width, + lay_ratio, + mean_diameter, + pitch_length, + lay_direction, + material_props, + temperature, + cross_section, + R_strip, + gmr, + ) +end + +const _REQ_STRIP = (:radius_in, :radius_ext, :width, :lay_ratio, :material_props) +const _OPT_STRIP = (:temperature, :lay_direction) +const _DEFS_STRIP = (T₀, 1) + +Validation.has_radii(::Type{Strip}) = true +Validation.has_temperature(::Type{Strip}) = true +Validation.required_fields(::Type{Strip}) = _REQ_STRIP +Validation.keyword_fields(::Type{Strip}) = _OPT_STRIP +Validation.keyword_defaults(::Type{Strip}) = _DEFS_STRIP + +Validation.coercive_fields(::Type{Strip}) = + (:radius_in, :radius_ext, :width, :lay_ratio, :material_props, :temperature) # not :lay_direction +# accept proxies for radii + +Validation.is_radius_input(::Type{Strip}, ::Val{:radius_in}, x::AbstractCablePart) = true +Validation.is_radius_input(::Type{Strip}, ::Val{:radius_in}, x::Thickness) = true +Validation.is_radius_input(::Type{Strip}, ::Val{:radius_ext}, x::Thickness) = true +Validation.is_radius_input(::Type{Strip}, ::Val{:radius_ext}, x::Diameter) = true + +Validation.extra_rules(::Type{Strip}) = ( + IsA{Material}(:material_props), + OneOf(:lay_direction, (-1, 1)), + Finite(:lay_ratio), + Nonneg(:lay_ratio), + Finite(:width), + Positive(:width), +) + +# normalize proxies -> numbers +Validation.parse(::Type{Strip}, nt) = begin + rin, rex = _normalize_radii(Strip, nt.radius_in, nt.radius_ext) + (; nt..., radius_in = rin, radius_ext = rex) +end + +# This macro expands to a weakly-typed constructor for Strip +@construct Strip _REQ_STRIP _OPT_STRIP _DEFS_STRIP diff --git a/src/datamodel/tubular.jl b/src/datamodel/tubular.jl index c728b9fb..71f24159 100644 --- a/src/datamodel/tubular.jl +++ b/src/datamodel/tubular.jl @@ -1,112 +1,112 @@ -""" -$(TYPEDEF) - -Represents a tubular or solid (`radius_in=0`) conductor with geometric and material properties defined as: - -$(TYPEDFIELDS) -""" -struct Tubular{T <: REALSCALAR} <: AbstractConductorPart{T} - "Internal radius of the tubular conductor \\[m\\]." - radius_in::T - "External radius of the tubular conductor \\[m\\]." - radius_ext::T - "A [`Material`](@ref) object representing the physical properties of the conductor material." - material_props::Material{T} - "Temperature at which the properties are evaluated \\[°C\\]." - temperature::T - "Cross-sectional area of the tubular conductor \\[m²\\]." - cross_section::T - "Electrical resistance (DC) of the tubular conductor \\[Ω/m\\]." - resistance::T - "Geometric mean radius of the tubular conductor \\[m\\]." - gmr::T -end - -""" -$(TYPEDSIGNATURES) - -Initializes a [`Tubular`](@ref) object with specified geometric and material parameters. - -# Arguments - -- `radius_in`: Internal radius of the tubular conductor \\[m\\]. -- `radius_ext`: External radius of the tubular conductor \\[m\\]. -- `material_props`: A [`Material`](@ref) object representing the physical properties of the conductor material. -- `temperature`: Temperature at which the properties are evaluated \\[°C\\]. Defaults to [`T₀`](@ref). - -# Returns - -- An instance of [`Tubular`](@ref) initialized with calculated geometric and electrical properties. - -# Examples - -```julia -material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) -tubular = $(FUNCTIONNAME)(0.01, 0.02, material_props, temperature=25) -println(tubular.cross_section) # Output: 0.000942 [m²] -println(tubular.resistance) # Output: Resistance value [Ω/m] -``` - -# See also - -- [`Material`](@ref) -- [`calc_tubular_resistance`](@ref) -- [`calc_tubular_gmr`](@ref) -""" -function Tubular( - radius_in::T, - radius_ext::T, - material_props::Material{T}, - temperature::T, -) where {T <: REALSCALAR} - - rho = material_props.rho - T0 = material_props.T0 - alpha = material_props.alpha - - cross_section = π * (radius_ext^2 - radius_in^2) - - R0 = calc_tubular_resistance(radius_in, radius_ext, rho, alpha, T0, temperature) - - gmr = calc_tubular_gmr(radius_ext, radius_in, material_props.mu_r) - - # Initialize object - return Tubular( - radius_in, - radius_ext, - material_props, - temperature, - cross_section, - R0, - gmr, - ) -end - -const _REQ_TUBULAR = (:radius_in, :radius_ext, :material_props) -const _OPT_TUBULAR = (:temperature,) -const _DEFS_TUBULAR = (T₀,) - -Validation.has_radii(::Type{Tubular}) = true -Validation.has_temperature(::Type{Tubular}) = true -Validation.required_fields(::Type{Tubular}) = _REQ_TUBULAR -Validation.keyword_fields(::Type{Tubular}) = _OPT_TUBULAR -Validation.keyword_defaults(::Type{Tubular}) = _DEFS_TUBULAR - -# accept proxies for radii -Validation.is_radius_input(::Type{Tubular}, ::Val{:radius_in}, x::AbstractCablePart) = true -Validation.is_radius_input(::Type{Tubular}, ::Val{:radius_in}, x::Thickness) = true -Validation.is_radius_input(::Type{Tubular}, ::Val{:radius_ext}, x::Thickness) = true -Validation.is_radius_input(::Type{Tubular}, ::Val{:radius_ext}, x::Diameter) = true - -Validation.extra_rules(::Type{Tubular}) = (IsA{Material}(:material_props),) - -# normalize proxies -> numbers -Validation.parse(::Type{Tubular}, nt) = begin - rin, rex = _normalize_radii(Tubular, nt.radius_in, nt.radius_ext) - (; nt..., radius_in = rin, radius_ext = rex) -end - -# This macro expands to a weakly-typed constructor for Tubular -@construct Tubular _REQ_TUBULAR _OPT_TUBULAR _DEFS_TUBULAR - - +""" +$(TYPEDEF) + +Represents a tubular or solid (`radius_in=0`) conductor with geometric and material properties defined as: + +$(TYPEDFIELDS) +""" +struct Tubular{T <: REALSCALAR} <: AbstractConductorPart{T} + "Internal radius of the tubular conductor \\[m\\]." + radius_in::T + "External radius of the tubular conductor \\[m\\]." + radius_ext::T + "A [`Material`](@ref) object representing the physical properties of the conductor material." + material_props::Material{T} + "Temperature at which the properties are evaluated \\[°C\\]." + temperature::T + "Cross-sectional area of the tubular conductor \\[m²\\]." + cross_section::T + "Electrical resistance (DC) of the tubular conductor \\[Ω/m\\]." + resistance::T + "Geometric mean radius of the tubular conductor \\[m\\]." + gmr::T +end + +""" +$(TYPEDSIGNATURES) + +Initializes a [`Tubular`](@ref) object with specified geometric and material parameters. + +# Arguments + +- `radius_in`: Internal radius of the tubular conductor \\[m\\]. +- `radius_ext`: External radius of the tubular conductor \\[m\\]. +- `material_props`: A [`Material`](@ref) object representing the physical properties of the conductor material. +- `temperature`: Temperature at which the properties are evaluated \\[°C\\]. Defaults to [`T₀`](@ref). + +# Returns + +- An instance of [`Tubular`](@ref) initialized with calculated geometric and electrical properties. + +# Examples + +```julia +material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) +tubular = $(FUNCTIONNAME)(0.01, 0.02, material_props, temperature=25) +println(tubular.cross_section) # Output: 0.000942 [m²] +println(tubular.resistance) # Output: Resistance value [Ω/m] +``` + +# See also + +- [`Material`](@ref) +- [`calc_tubular_resistance`](@ref) +- [`calc_tubular_gmr`](@ref) +""" +function Tubular( + radius_in::T, + radius_ext::T, + material_props::Material{T}, + temperature::T, +) where {T <: REALSCALAR} + + rho = material_props.rho + T0 = material_props.T0 + alpha = material_props.alpha + + cross_section = π * (radius_ext^2 - radius_in^2) + + R0 = calc_tubular_resistance(radius_in, radius_ext, rho, alpha, T0, temperature) + + gmr = calc_tubular_gmr(radius_ext, radius_in, material_props.mu_r) + + # Initialize object + return Tubular( + radius_in, + radius_ext, + material_props, + temperature, + cross_section, + R0, + gmr, + ) +end + +const _REQ_TUBULAR = (:radius_in, :radius_ext, :material_props) +const _OPT_TUBULAR = (:temperature,) +const _DEFS_TUBULAR = (T₀,) + +Validation.has_radii(::Type{Tubular}) = true +Validation.has_temperature(::Type{Tubular}) = true +Validation.required_fields(::Type{Tubular}) = _REQ_TUBULAR +Validation.keyword_fields(::Type{Tubular}) = _OPT_TUBULAR +Validation.keyword_defaults(::Type{Tubular}) = _DEFS_TUBULAR + +# accept proxies for radii +Validation.is_radius_input(::Type{Tubular}, ::Val{:radius_in}, x::AbstractCablePart) = true +Validation.is_radius_input(::Type{Tubular}, ::Val{:radius_in}, x::Thickness) = true +Validation.is_radius_input(::Type{Tubular}, ::Val{:radius_ext}, x::Thickness) = true +Validation.is_radius_input(::Type{Tubular}, ::Val{:radius_ext}, x::Diameter) = true + +Validation.extra_rules(::Type{Tubular}) = (IsA{Material}(:material_props),) + +# normalize proxies -> numbers +Validation.parse(::Type{Tubular}, nt) = begin + rin, rex = _normalize_radii(Tubular, nt.radius_in, nt.radius_ext) + (; nt..., radius_in = rin, radius_ext = rex) +end + +# This macro expands to a weakly-typed constructor for Tubular +@construct Tubular _REQ_TUBULAR _OPT_TUBULAR _DEFS_TUBULAR + + diff --git a/src/datamodel/typecoercion.jl b/src/datamodel/typecoercion.jl index c4dd3781..7fb8f518 100644 --- a/src/datamodel/typecoercion.jl +++ b/src/datamodel/typecoercion.jl @@ -1,111 +1,111 @@ -@inline function _rebuild_part_typed_core(p, ::Type{T}) where {T} - C0 = typeof(p).name.wrapper # concrete parametric type (e.g., WireArray) - order = (required_fields(C0)..., keyword_fields(C0)...) # positional order for tight kernel - coer = coercive_fields(C0) # only these get coerced to T - - argsT = ntuple(i -> begin - s = order[i] - v = getfield(p, s) - (s in coer) ? coerce_to_T(v, T) : v # preserve Int/categorical fields - end, length(order)) - - return C0(argsT...) # call the tight numeric constructor -end - -# Identity when already at T (no rebuild, preserves ===) -coerce_to_T(p::AbstractConductorPart{T}, ::Type{T}) where {T} = p -# Cross-T rebuild via your existing tight numeric-core helper -coerce_to_T(p::AbstractConductorPart{S}, ::Type{T}) where {S,T} = - _rebuild_part_typed_core(p, T) -coerce_to_T(g::ConductorGroup{T}, ::Type{T}) where {T} = g -# Cross-T: fieldwise coerce + layer coercion (no recompute) -@inline function coerce_to_T(g::ConductorGroup{S}, ::Type{T}) where {S,T} - n = length(g.layers) - layersT = Vector{AbstractConductorPart{T}}(undef, n) - @inbounds for i in 1:n - layersT[i] = coerce_to_T(g.layers[i], T) # uses your part-level coercers - end - return ConductorGroup{T}( - coerce_to_T(g.radius_in, T), - coerce_to_T(g.radius_ext, T), - coerce_to_T(g.cross_section, T), - g.num_wires, # keep Int as-is - coerce_to_T(g.num_turns, T), - coerce_to_T(g.resistance, T), - coerce_to_T(g.alpha, T), - coerce_to_T(g.gmr, T), - layersT, - ) -end - -@inline coerce_to_T(p::AbstractInsulatorPart{T}, ::Type{T}) where {T} = p -@inline coerce_to_T(p::AbstractInsulatorPart{S}, ::Type{T}) where {S,T} = - _rebuild_part_typed_core(p, T) -@inline coerce_to_T(g::InsulatorGroup{T}, ::Type{T}) where {T} = g -@inline function coerce_to_T(g::InsulatorGroup{S}, ::Type{T}) where {S,T} - n = length(g.layers) - layersT = Vector{AbstractInsulatorPart{T}}(undef, n) - @inbounds for i in 1:n - layersT[i] = coerce_to_T(g.layers[i], T) # uses the part-level coercers above - end - return InsulatorGroup{T}( - coerce_to_T(g.radius_in, T), - coerce_to_T(g.radius_ext, T), - coerce_to_T(g.cross_section, T), - coerce_to_T(g.shunt_capacitance, T), - coerce_to_T(g.shunt_conductance, T), - layersT, - ) -end - -@inline coerce_to_T(c::CableComponent{T}, ::Type{T}) where {T} = c -@inline function coerce_to_T(c::CableComponent{S}, ::Type{T}) where {S,T} - CableComponent{T}( - c.id, - coerce_to_T(c.conductor_group, T), - coerce_to_T(c.insulator_group, T), - ) -end - -"Identity: no allocation when already at `T`." -@inline coerce_to_T(n::NominalData{T}, ::Type{T}) where {T} = n -# Cross-T rebuild: fieldwise coercion, preserving `nothing` -@inline function coerce_to_T(n::NominalData{S}, ::Type{T}) where {S,T} - names = fieldnames(typeof(n)) # e.g. (:designation_code, :U0, :U, ...) - vals = map(names) do k # map over tuple of names → returns a tuple - v = getfield(n, k) - v === nothing ? nothing : coerce_to_T(v, T) - end - NT = NamedTuple{names}(vals) # correct: pass a SINGLE tuple, not varargs - return NominalData{T}(; NT...) # call typed kernel via keyword splat -end - -@inline coerce_to_T(d::CableDesign{T}, ::Type{T}) where {T} = d -@inline function coerce_to_T(d::CableDesign{S}, ::Type{T}) where {S,T} - compsT = Vector{CableComponent{T}}(undef, length(d.components)) - @inbounds for i in eachindex(d.components) - compsT[i] = coerce_to_T(d.components[i], T) - end - ndT = isnothing(d.nominal_data) ? nothing : coerce_to_T(d.nominal_data, T) - CableDesign{T}(d.cable_id, compsT; nominal_data=ndT) -end - -@inline coerce_to_T(p::CablePosition{T}, ::Type{T}) where {T} = p -@inline function coerce_to_T(p::CablePosition{S}, ::Type{T}) where {S,T} - CablePosition{T}( - coerce_to_T(p.design_data, T), - coerce_to_T(p.horz, T), - coerce_to_T(p.vert, T), - p.conn, # keep Int mapping as-is - ) -end - -@inline coerce_to_T(sys::LineCableSystem{T}, ::Type{T}) where {T} = sys -@inline function coerce_to_T(sys::LineCableSystem{S}, ::Type{T}) where {S,T} - cablesT = Vector{CablePosition{T}}(undef, length(sys.cables)) - @inbounds for i in eachindex(sys.cables) - cablesT[i] = coerce_to_T(sys.cables[i], T) - end - # counts will be recomputed once positions are populated; preserve them now - LineCableSystem{T}(sys.system_id, coerce_to_T(sys.line_length, T), cablesT) +@inline function _rebuild_part_typed_core(p, ::Type{T}) where {T} + C0 = typeof(p).name.wrapper # concrete parametric type (e.g., WireArray) + order = (required_fields(C0)..., keyword_fields(C0)...) # positional order for tight kernel + coer = coercive_fields(C0) # only these get coerced to T + + argsT = ntuple(i -> begin + s = order[i] + v = getfield(p, s) + (s in coer) ? coerce_to_T(v, T) : v # preserve Int/categorical fields + end, length(order)) + + return C0(argsT...) # call the tight numeric constructor +end + +# Identity when already at T (no rebuild, preserves ===) +coerce_to_T(p::AbstractConductorPart{T}, ::Type{T}) where {T} = p +# Cross-T rebuild via your existing tight numeric-core helper +coerce_to_T(p::AbstractConductorPart{S}, ::Type{T}) where {S,T} = + _rebuild_part_typed_core(p, T) +coerce_to_T(g::ConductorGroup{T}, ::Type{T}) where {T} = g +# Cross-T: fieldwise coerce + layer coercion (no recompute) +@inline function coerce_to_T(g::ConductorGroup{S}, ::Type{T}) where {S,T} + n = length(g.layers) + layersT = Vector{AbstractConductorPart{T}}(undef, n) + @inbounds for i in 1:n + layersT[i] = coerce_to_T(g.layers[i], T) # uses your part-level coercers + end + return ConductorGroup{T}( + coerce_to_T(g.radius_in, T), + coerce_to_T(g.radius_ext, T), + coerce_to_T(g.cross_section, T), + g.num_wires, # keep Int as-is + coerce_to_T(g.num_turns, T), + coerce_to_T(g.resistance, T), + coerce_to_T(g.alpha, T), + coerce_to_T(g.gmr, T), + layersT, + ) +end + +@inline coerce_to_T(p::AbstractInsulatorPart{T}, ::Type{T}) where {T} = p +@inline coerce_to_T(p::AbstractInsulatorPart{S}, ::Type{T}) where {S,T} = + _rebuild_part_typed_core(p, T) +@inline coerce_to_T(g::InsulatorGroup{T}, ::Type{T}) where {T} = g +@inline function coerce_to_T(g::InsulatorGroup{S}, ::Type{T}) where {S,T} + n = length(g.layers) + layersT = Vector{AbstractInsulatorPart{T}}(undef, n) + @inbounds for i in 1:n + layersT[i] = coerce_to_T(g.layers[i], T) # uses the part-level coercers above + end + return InsulatorGroup{T}( + coerce_to_T(g.radius_in, T), + coerce_to_T(g.radius_ext, T), + coerce_to_T(g.cross_section, T), + coerce_to_T(g.shunt_capacitance, T), + coerce_to_T(g.shunt_conductance, T), + layersT, + ) +end + +@inline coerce_to_T(c::CableComponent{T}, ::Type{T}) where {T} = c +@inline function coerce_to_T(c::CableComponent{S}, ::Type{T}) where {S,T} + CableComponent{T}( + c.id, + coerce_to_T(c.conductor_group, T), + coerce_to_T(c.insulator_group, T), + ) +end + +"Identity: no allocation when already at `T`." +@inline coerce_to_T(n::NominalData{T}, ::Type{T}) where {T} = n +# Cross-T rebuild: fieldwise coercion, preserving `nothing` +@inline function coerce_to_T(n::NominalData{S}, ::Type{T}) where {S,T} + names = fieldnames(typeof(n)) # e.g. (:designation_code, :U0, :U, ...) + vals = map(names) do k # map over tuple of names → returns a tuple + v = getfield(n, k) + v === nothing ? nothing : coerce_to_T(v, T) + end + NT = NamedTuple{names}(vals) # correct: pass a SINGLE tuple, not varargs + return NominalData{T}(; NT...) # call typed kernel via keyword splat +end + +@inline coerce_to_T(d::CableDesign{T}, ::Type{T}) where {T} = d +@inline function coerce_to_T(d::CableDesign{S}, ::Type{T}) where {S,T} + compsT = Vector{CableComponent{T}}(undef, length(d.components)) + @inbounds for i in eachindex(d.components) + compsT[i] = coerce_to_T(d.components[i], T) + end + ndT = isnothing(d.nominal_data) ? nothing : coerce_to_T(d.nominal_data, T) + CableDesign{T}(d.cable_id, compsT; nominal_data=ndT) +end + +@inline coerce_to_T(p::CablePosition{T}, ::Type{T}) where {T} = p +@inline function coerce_to_T(p::CablePosition{S}, ::Type{T}) where {S,T} + CablePosition{T}( + coerce_to_T(p.design_data, T), + coerce_to_T(p.horz, T), + coerce_to_T(p.vert, T), + p.conn, # keep Int mapping as-is + ) +end + +@inline coerce_to_T(sys::LineCableSystem{T}, ::Type{T}) where {T} = sys +@inline function coerce_to_T(sys::LineCableSystem{S}, ::Type{T}) where {S,T} + cablesT = Vector{CablePosition{T}}(undef, length(sys.cables)) + @inbounds for i in eachindex(sys.cables) + cablesT[i] = coerce_to_T(sys.cables[i], T) + end + # counts will be recomputed once positions are populated; preserve them now + LineCableSystem{T}(sys.system_id, coerce_to_T(sys.line_length, T), cablesT) end \ No newline at end of file diff --git a/src/datamodel/types.jl b/src/datamodel/types.jl index 0f4c9201..d449a466 100644 --- a/src/datamodel/types.jl +++ b/src/datamodel/types.jl @@ -1,71 +1,71 @@ -# To handle radius-related operations -abstract type AbstractRadius <: Number end - -""" -$(TYPEDEF) - -Represents the thickness of a cable component. - -$(TYPEDFIELDS) -""" -struct Thickness{T<:Real} <: AbstractRadius - "Numerical value of the thickness \\[m\\]." - value::T - function Thickness(value::T) where {T<:Real} - value >= 0 || throw(ArgumentError("Thickness must be a non-negative number.")) - new{T}(value) - end -end - -""" -$(TYPEDEF) - -Represents the diameter of a cable component. - -$(TYPEDFIELDS) -""" -struct Diameter{T<:Real} <: AbstractRadius - "Numerical value of the diameter \\[m\\]." - value::T - function Diameter(value::T) where {T<:Real} - value > 0 || throw(ArgumentError("Diameter must be a positive number.")) - new{T}(value) - end -end - -""" -$(TYPEDEF) - -Abstract type representing a generic cable part. -""" -abstract type AbstractCablePart{T} end - -""" -$(TYPEDEF) - -Abstract type representing a conductive part of a cable. - -Subtypes implement specific configurations: -- [`WireArray`](@ref) -- [`Tubular`](@ref) -- [`Strip`](@ref) -""" -abstract type AbstractConductorPart{T} <: AbstractCablePart{T} end -abstract type AbstractWireArray{T} <: AbstractConductorPart{T} end - -""" -$(TYPEDEF) - -Abstract type representing an insulating part of a cable. - -Subtypes implement specific configurations: -- [`Insulator`](@ref) -- [`Semicon`](@ref) -""" -abstract type AbstractInsulatorPart{T} <: AbstractCablePart{T} end - - -# If a correct ctor exists, Julia will pick it; this runs only when arity is wrong. -function (::Type{T})(args::Vararg{Any,N}; kwargs...) where {T<:AbstractCablePart,N} - throw(ArgumentError("[$(nameof(T))] constructor: invalid number of positional args ($N).")) +# To handle radius-related operations +abstract type AbstractRadius <: Number end + +""" +$(TYPEDEF) + +Represents the thickness of a cable component. + +$(TYPEDFIELDS) +""" +struct Thickness{T<:Real} <: AbstractRadius + "Numerical value of the thickness \\[m\\]." + value::T + function Thickness(value::T) where {T<:Real} + value >= 0 || throw(ArgumentError("Thickness must be a non-negative number.")) + new{T}(value) + end +end + +""" +$(TYPEDEF) + +Represents the diameter of a cable component. + +$(TYPEDFIELDS) +""" +struct Diameter{T<:Real} <: AbstractRadius + "Numerical value of the diameter \\[m\\]." + value::T + function Diameter(value::T) where {T<:Real} + value > 0 || throw(ArgumentError("Diameter must be a positive number.")) + new{T}(value) + end +end + +""" +$(TYPEDEF) + +Abstract type representing a generic cable part. +""" +abstract type AbstractCablePart{T} end + +""" +$(TYPEDEF) + +Abstract type representing a conductive part of a cable. + +Subtypes implement specific configurations: +- [`WireArray`](@ref) +- [`Tubular`](@ref) +- [`Strip`](@ref) +""" +abstract type AbstractConductorPart{T} <: AbstractCablePart{T} end +abstract type AbstractWireArray{T} <: AbstractConductorPart{T} end + +""" +$(TYPEDEF) + +Abstract type representing an insulating part of a cable. + +Subtypes implement specific configurations: +- [`Insulator`](@ref) +- [`Semicon`](@ref) +""" +abstract type AbstractInsulatorPart{T} <: AbstractCablePart{T} end + + +# If a correct ctor exists, Julia will pick it; this runs only when arity is wrong. +function (::Type{T})(args::Vararg{Any,N}; kwargs...) where {T<:AbstractCablePart,N} + throw(ArgumentError("[$(nameof(T))] constructor: invalid number of positional args ($N).")) end \ No newline at end of file diff --git a/src/datamodel/validation.jl b/src/datamodel/validation.jl index b1793c0f..1b7f560e 100644 --- a/src/datamodel/validation.jl +++ b/src/datamodel/validation.jl @@ -1,85 +1,85 @@ -""" -$(TYPEDSIGNATURES) - -Default policy for **inner** radius raw inputs: accept proxies that expose an outer radius. This permits stacking by hijacking `p.radius_ext` during parsing. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. -- `::Val{:radius_in}`: Field tag for the inner radius \\[dimensionless\\]. -- `p::AbstractCablePart`: Proxy object \\[dimensionless\\]. - -# Returns - -- `Bool` indicating acceptance (`true` if `hasproperty(p, :radius_ext)`). - -# Examples - -```julia -Validation.is_radius_input(Tubular, Val(:radius_in), prev_layer) # true if prev_layer has :radius_ext -``` -""" -is_radius_input(::Type{T}, ::Val{:radius_in}, p::AbstractCablePart) where {T} = hasproperty(p, :radius_ext) - -""" -$(TYPEDSIGNATURES) - -Default policy for **outer** radius raw inputs (annular shells): reject `AbstractCablePart` proxies. Outer radius must be numeric or a `Thickness` wrapper to avoid creating zero‑thickness layers. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. -- `::Val{:radius_ext}`: Field tag for the outer radius \\[dimensionless\\]. -- `::AbstractCablePart`: Proxy object \\[dimensionless\\]. - -# Returns - -- `false` always. - -# Examples - -```julia -Validation.is_radius_input(Tubular, Val(:radius_ext), prev_layer) # false -``` -""" -is_radius_input(::Type{T}, ::Val{:radius_ext}, ::AbstractCablePart) where {T} = false - -""" -$(TYPEDSIGNATURES) - -Default policy for **outer** radius raw inputs (annular shells): accept `Thickness` as a convenience wrapper. The thickness is expanded to an outer radius during parsing. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. -- `::Val{:radius_ext}`: Field tag for the outer radius \\[dimensionless\\]. -- `::Thickness`: Thickness wrapper \\[dimensionless\\]. - -# Returns - -- `Bool` indicating acceptance (`true`). - -# Examples - -```julia -Validation.is_radius_input(Tubular, Val(:radius_ext), Thickness(1e-3)) # true -``` -""" -is_radius_input(::Type{T}, ::Val{:radius_ext}, ::Thickness) where {T} = true - -""" -$(TYPEDSIGNATURES) - -Merge per-part keyword defaults declared via `Validation.keyword_defaults` with -user-provided kwargs and return a **NamedTuple** suitable for forwarding. - -Defaults may be a `NamedTuple` or a `Tuple` zipped against `Validation.keyword_fields(::Type{C})`. -User keys always win. -""" -@inline function _with_kwdefaults(::Type{C}, kwargs::NamedTuple) where {C} - defs = Validation.keyword_defaults(C) - defs === () && return kwargs - nt = defs isa NamedTuple ? defs : - NamedTuple{Validation.keyword_fields(C)}(defs) - return merge(nt, kwargs) +""" +$(TYPEDSIGNATURES) + +Default policy for **inner** radius raw inputs: accept proxies that expose an outer radius. This permits stacking by hijacking `p.radius_ext` during parsing. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. +- `::Val{:radius_in}`: Field tag for the inner radius \\[dimensionless\\]. +- `p::AbstractCablePart`: Proxy object \\[dimensionless\\]. + +# Returns + +- `Bool` indicating acceptance (`true` if `hasproperty(p, :radius_ext)`). + +# Examples + +```julia +Validation.is_radius_input(Tubular, Val(:radius_in), prev_layer) # true if prev_layer has :radius_ext +``` +""" +is_radius_input(::Type{T}, ::Val{:radius_in}, p::AbstractCablePart) where {T} = hasproperty(p, :radius_ext) + +""" +$(TYPEDSIGNATURES) + +Default policy for **outer** radius raw inputs (annular shells): reject `AbstractCablePart` proxies. Outer radius must be numeric or a `Thickness` wrapper to avoid creating zero‑thickness layers. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. +- `::Val{:radius_ext}`: Field tag for the outer radius \\[dimensionless\\]. +- `::AbstractCablePart`: Proxy object \\[dimensionless\\]. + +# Returns + +- `false` always. + +# Examples + +```julia +Validation.is_radius_input(Tubular, Val(:radius_ext), prev_layer) # false +``` +""" +is_radius_input(::Type{T}, ::Val{:radius_ext}, ::AbstractCablePart) where {T} = false + +""" +$(TYPEDSIGNATURES) + +Default policy for **outer** radius raw inputs (annular shells): accept `Thickness` as a convenience wrapper. The thickness is expanded to an outer radius during parsing. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. +- `::Val{:radius_ext}`: Field tag for the outer radius \\[dimensionless\\]. +- `::Thickness`: Thickness wrapper \\[dimensionless\\]. + +# Returns + +- `Bool` indicating acceptance (`true`). + +# Examples + +```julia +Validation.is_radius_input(Tubular, Val(:radius_ext), Thickness(1e-3)) # true +``` +""" +is_radius_input(::Type{T}, ::Val{:radius_ext}, ::Thickness) where {T} = true + +""" +$(TYPEDSIGNATURES) + +Merge per-part keyword defaults declared via `Validation.keyword_defaults` with +user-provided kwargs and return a **NamedTuple** suitable for forwarding. + +Defaults may be a `NamedTuple` or a `Tuple` zipped against `Validation.keyword_fields(::Type{C})`. +User keys always win. +""" +@inline function _with_kwdefaults(::Type{C}, kwargs::NamedTuple) where {C} + defs = Validation.keyword_defaults(C) + defs === () && return kwargs + nt = defs isa NamedTuple ? defs : + NamedTuple{Validation.keyword_fields(C)}(defs) + return merge(nt, kwargs) end \ No newline at end of file diff --git a/src/datamodel/wirearray.jl b/src/datamodel/wirearray.jl index 6f667787..60ef19a1 100644 --- a/src/datamodel/wirearray.jl +++ b/src/datamodel/wirearray.jl @@ -1,168 +1,168 @@ -""" -$(TYPEDEF) - -Represents an array of wires equally spaced around a circumference of arbitrary radius, with attributes: - -$(TYPEDFIELDS) -""" -struct WireArray{T <: REALSCALAR, U <: Int} <: AbstractWireArray{T} - "Internal radius of the wire array \\[m\\]." - radius_in::T - "External radius of the wire array \\[m\\]." - radius_ext::T - "Radius of each individual wire \\[m\\]." - radius_wire::T - "Number of wires in the array \\[dimensionless\\]." - num_wires::U - "Ratio defining the lay length of the wires (twisting factor) \\[dimensionless\\]." - lay_ratio::T - "Mean diameter of the wire array \\[m\\]." - mean_diameter::T - "Pitch length of the wire array \\[m\\]." - pitch_length::T - "Twisting direction of the strands (1 = unilay, -1 = contralay) \\[dimensionless\\]." - lay_direction::U - "Material object representing the physical properties of the wire material." - material_props::Material{T} - "Temperature at which the properties are evaluated \\[°C\\]." - temperature::T - "Cross-sectional area of all wires in the array \\[m²\\]." - cross_section::T - "Electrical resistance per wire in the array \\[Ω/m\\]." - resistance::T - "Geometric mean radius of the wire array \\[m\\]." - gmr::T -end - -""" -$(TYPEDSIGNATURES) - -Constructs a [`WireArray`](@ref) instance based on specified geometric and material parameters. - -# Arguments - -- `radius_in`: Internal radius of the wire array \\[m\\]. -- `radius_wire`: Radius of each individual wire \\[m\\]. -- `num_wires`: Number of wires in the array \\[dimensionless\\]. -- `lay_ratio`: Ratio defining the lay length of the wires (twisting factor) \\[dimensionless\\]. -- `material_props`: A [`Material`](@ref) object representing the material properties. -- `temperature`: Temperature at which the properties are evaluated \\[°C\\]. -- `lay_direction`: Twisting direction of the strands (1 = unilay, -1 = contralay) \\[dimensionless\\]. - -# Returns - -- A [`WireArray`](@ref) object with calculated geometric and electrical properties. - -# Examples - -```julia -material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) -wire_array = $(FUNCTIONNAME)(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) -println(wire_array.mean_diameter) # Outputs mean diameter in m -println(wire_array.resistance) # Outputs resistance in Ω/m -``` - -# See also - -- [`Material`](@ref) -- [`ConductorGroup`](@ref) -- [`calc_tubular_resistance`](@ref) -- [`calc_wirearray_gmr`](@ref) -- [`calc_helical_params`](@ref) -""" -function WireArray( - radius_in::T, - radius_wire::T, - num_wires::U, - lay_ratio::T, - material_props::Material{T}, - temperature::T, - lay_direction::U, -) where {T <: REALSCALAR, U <: Int} - - rho = material_props.rho - T0 = material_props.T0 - alpha = material_props.alpha - radius_ext = num_wires == 1 ? radius_wire : radius_in + 2 * radius_wire - - mean_diameter, pitch_length, overlength = calc_helical_params( - radius_in, - radius_ext, - lay_ratio, - ) - - cross_section = num_wires * (π * radius_wire^2) - - R_wire = - calc_tubular_resistance(0.0, radius_wire, rho, alpha, T0, temperature) * - overlength - R_all_wires = R_wire / num_wires - - gmr = calc_wirearray_gmr( - radius_in + radius_wire, - num_wires, - radius_wire, - material_props.mu_r, - ) - - # Initialize object - return WireArray( - radius_in, - radius_ext, - radius_wire, - num_wires, - lay_ratio, - mean_diameter, - pitch_length, - lay_direction, - material_props, - temperature, - cross_section, - R_all_wires, - gmr, - ) -end - -const _REQ_WIREARRAY = (:radius_in, :radius_wire, :num_wires, :lay_ratio, :material_props) -const _OPT_WIREARRAY = (:temperature, :lay_direction) -const _DEFS_WIREARRAY = (T₀, 1) - -Validation.has_radii(::Type{WireArray}) = false -Validation.has_temperature(::Type{WireArray}) = true -Validation.required_fields(::Type{WireArray}) = _REQ_WIREARRAY -Validation.keyword_fields(::Type{WireArray}) = _OPT_WIREARRAY -Validation.keyword_defaults(::Type{WireArray}) = _DEFS_WIREARRAY - -Validation.coercive_fields(::Type{WireArray}) = - (:radius_in, :radius_wire, :lay_ratio, :material_props, :temperature) # not :num_wires, :lay_direction -# accept proxies for radii -Validation.is_radius_input(::Type{WireArray}, ::Val{:radius_in}, - x::AbstractCablePart) = true -Validation.is_radius_input(::Type{WireArray}, ::Val{:radius_ext}, - x::Diameter) = true - -Validation.extra_rules(::Type{WireArray}) = ( - # radii (post-parse they must be numeric) - Normalized(:radius_in), Finite(:radius_in), Nonneg(:radius_in), - Normalized(:radius_wire), Finite(:radius_wire), Positive(:radius_wire), - - # counts and geometry params - IntegerField(:num_wires), Positive(:num_wires), - Finite(:lay_ratio), Nonneg(:lay_ratio), - - # material type - IsA{Material}(:material_props), - - # lay direction constraint (pin to -1 or +1) - OneOf(:lay_direction, (-1, 1)), -) - -# normalize proxies -> numbers -Validation.parse(::Type{WireArray}, nt) = begin - rin, rw = _normalize_radii(WireArray, nt.radius_in, nt.radius_wire) - (; nt..., radius_in = rin, radius_wire = rw) -end - -# This macro expands to a weakly-typed constructor for WireArray -@construct WireArray _REQ_WIREARRAY _OPT_WIREARRAY _DEFS_WIREARRAY - +""" +$(TYPEDEF) + +Represents an array of wires equally spaced around a circumference of arbitrary radius, with attributes: + +$(TYPEDFIELDS) +""" +struct WireArray{T <: REALSCALAR, U <: Int} <: AbstractWireArray{T} + "Internal radius of the wire array \\[m\\]." + radius_in::T + "External radius of the wire array \\[m\\]." + radius_ext::T + "Radius of each individual wire \\[m\\]." + radius_wire::T + "Number of wires in the array \\[dimensionless\\]." + num_wires::U + "Ratio defining the lay length of the wires (twisting factor) \\[dimensionless\\]." + lay_ratio::T + "Mean diameter of the wire array \\[m\\]." + mean_diameter::T + "Pitch length of the wire array \\[m\\]." + pitch_length::T + "Twisting direction of the strands (1 = unilay, -1 = contralay) \\[dimensionless\\]." + lay_direction::U + "Material object representing the physical properties of the wire material." + material_props::Material{T} + "Temperature at which the properties are evaluated \\[°C\\]." + temperature::T + "Cross-sectional area of all wires in the array \\[m²\\]." + cross_section::T + "Electrical resistance per wire in the array \\[Ω/m\\]." + resistance::T + "Geometric mean radius of the wire array \\[m\\]." + gmr::T +end + +""" +$(TYPEDSIGNATURES) + +Constructs a [`WireArray`](@ref) instance based on specified geometric and material parameters. + +# Arguments + +- `radius_in`: Internal radius of the wire array \\[m\\]. +- `radius_wire`: Radius of each individual wire \\[m\\]. +- `num_wires`: Number of wires in the array \\[dimensionless\\]. +- `lay_ratio`: Ratio defining the lay length of the wires (twisting factor) \\[dimensionless\\]. +- `material_props`: A [`Material`](@ref) object representing the material properties. +- `temperature`: Temperature at which the properties are evaluated \\[°C\\]. +- `lay_direction`: Twisting direction of the strands (1 = unilay, -1 = contralay) \\[dimensionless\\]. + +# Returns + +- A [`WireArray`](@ref) object with calculated geometric and electrical properties. + +# Examples + +```julia +material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) +wire_array = $(FUNCTIONNAME)(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) +println(wire_array.mean_diameter) # Outputs mean diameter in m +println(wire_array.resistance) # Outputs resistance in Ω/m +``` + +# See also + +- [`Material`](@ref) +- [`ConductorGroup`](@ref) +- [`calc_tubular_resistance`](@ref) +- [`calc_wirearray_gmr`](@ref) +- [`calc_helical_params`](@ref) +""" +function WireArray( + radius_in::T, + radius_wire::T, + num_wires::U, + lay_ratio::T, + material_props::Material{T}, + temperature::T, + lay_direction::U, +) where {T <: REALSCALAR, U <: Int} + + rho = material_props.rho + T0 = material_props.T0 + alpha = material_props.alpha + radius_ext = num_wires == 1 ? radius_wire : radius_in + 2 * radius_wire + + mean_diameter, pitch_length, overlength = calc_helical_params( + radius_in, + radius_ext, + lay_ratio, + ) + + cross_section = num_wires * (π * radius_wire^2) + + R_wire = + calc_tubular_resistance(0.0, radius_wire, rho, alpha, T0, temperature) * + overlength + R_all_wires = R_wire / num_wires + + gmr = calc_wirearray_gmr( + radius_in + radius_wire, + num_wires, + radius_wire, + material_props.mu_r, + ) + + # Initialize object + return WireArray( + radius_in, + radius_ext, + radius_wire, + num_wires, + lay_ratio, + mean_diameter, + pitch_length, + lay_direction, + material_props, + temperature, + cross_section, + R_all_wires, + gmr, + ) +end + +const _REQ_WIREARRAY = (:radius_in, :radius_wire, :num_wires, :lay_ratio, :material_props) +const _OPT_WIREARRAY = (:temperature, :lay_direction) +const _DEFS_WIREARRAY = (T₀, 1) + +Validation.has_radii(::Type{WireArray}) = false +Validation.has_temperature(::Type{WireArray}) = true +Validation.required_fields(::Type{WireArray}) = _REQ_WIREARRAY +Validation.keyword_fields(::Type{WireArray}) = _OPT_WIREARRAY +Validation.keyword_defaults(::Type{WireArray}) = _DEFS_WIREARRAY + +Validation.coercive_fields(::Type{WireArray}) = + (:radius_in, :radius_wire, :lay_ratio, :material_props, :temperature) # not :num_wires, :lay_direction +# accept proxies for radii +Validation.is_radius_input(::Type{WireArray}, ::Val{:radius_in}, + x::AbstractCablePart) = true +Validation.is_radius_input(::Type{WireArray}, ::Val{:radius_ext}, + x::Diameter) = true + +Validation.extra_rules(::Type{WireArray}) = ( + # radii (post-parse they must be numeric) + Normalized(:radius_in), Finite(:radius_in), Nonneg(:radius_in), + Normalized(:radius_wire), Finite(:radius_wire), Positive(:radius_wire), + + # counts and geometry params + IntegerField(:num_wires), Positive(:num_wires), + Finite(:lay_ratio), Nonneg(:lay_ratio), + + # material type + IsA{Material}(:material_props), + + # lay direction constraint (pin to -1 or +1) + OneOf(:lay_direction, (-1, 1)), +) + +# normalize proxies -> numbers +Validation.parse(::Type{WireArray}, nt) = begin + rin, rw = _normalize_radii(WireArray, nt.radius_in, nt.radius_wire) + (; nt..., radius_in = rin, radius_wire = rw) +end + +# This macro expands to a weakly-typed constructor for WireArray +@construct WireArray _REQ_WIREARRAY _OPT_WIREARRAY _DEFS_WIREARRAY + diff --git a/src/earthprops/EarthProps.jl b/src/earthprops/EarthProps.jl index bb9e75ca..4da1b2fb 100644 --- a/src/earthprops/EarthProps.jl +++ b/src/earthprops/EarthProps.jl @@ -1,447 +1,466 @@ -""" - LineCableModels.EarthProps - -The [`EarthProps`](@ref) module provides functionality for modeling and computing earth properties within the [`LineCableModels.jl`](index.md) package. This module includes definitions for homogeneous and layered earth models, and formulations for frequency-dependent earth properties, to be used in impedance/admittance calculations. - -# Overview - -- Defines the [`EarthModel`](@ref) object for representing horizontally or vertically multi-layered earth models with frequency-dependent properties (ρ, ε, μ). -- Provides the [`EarthLayer`](@ref) type for representing individual soil layers with electromagnetic properties. -- Implements a multi-dispatch framework to allow different formulations of frequency-dependent earth models with [`AbstractFDEMFormulation`](@ref). -- Contains utility functions for building complex multi-layered earth models and generating data summaries. - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module EarthProps - -# Export public API -export CPEarth, - EarthLayer, - EarthModel - -# Module-specific dependencies -using ..Commons -using ..Utils: resolve_T -import ..Commons: get_description, add! -import ..Utils: coerce_to_T -using Measurements - -include("fdprops.jl") - -""" -$(TYPEDEF) - -Represents one single earth layer in an [`EarthModel`](@ref) object, with base and frequency-dependent properties, and attributes: - -$(TYPEDFIELDS) -""" -struct EarthLayer{T <: REALSCALAR} - "Base (DC) electrical resistivity \\[Ω·m\\]." - base_rho_g::T - "Base (DC) relative permittivity \\[dimensionless\\]." - base_epsr_g::T - "Base (DC) relative permeability \\[dimensionless\\]." - base_mur_g::T - "Thickness of the layer \\[m\\]." - t::T - "Computed resistivity values \\[Ω·m\\] at given frequencies." - rho_g::Vector{T} - "Computed permittivity values \\[F/m\\] at given frequencies." - eps_g::Vector{T} - "Computed permeability values \\[H/m\\] at given frequencies." - mu_g::Vector{T} - - @doc """ - Constructs an [`EarthLayer`](@ref) instance with specified base and frequency-dependent properties. - """ - function EarthLayer{T}(base_rho_g::T, base_epsr_g::T, base_mur_g::T, t::T, - rho_g::Vector{T}, eps_g::Vector{T}, mu_g::Vector{T}) where {T <: REALSCALAR} - new{T}(base_rho_g, base_epsr_g, base_mur_g, t, rho_g, eps_g, mu_g) - end -end - -""" -$(TYPEDSIGNATURES) - -Constructs an [`EarthLayer`](@ref) instance with specified base properties and computes its frequency-dependent values. - -# Arguments - -- `frequencies`: Vector of frequency values \\[Hz\\]. -- `base_rho_g`: Base (DC) electrical resistivity of the layer \\[Ω·m\\]. -- `base_epsr_g`: Base (DC) relative permittivity of the layer \\[dimensionless\\]. -- `base_mur_g`: Base (DC) relative permeability of the layer \\[dimensionless\\]. -- `t`: Thickness of the layer \\[m\\]. -- `freq_dependence`: Instance of a subtype of [`AbstractFDEMFormulation`](@ref) defining the computation method for frequency-dependent properties. - -# Returns - -- An [`EarthLayer`](@ref) instance with computed frequency-dependent properties. - -# Examples - -```julia -frequencies = [1e3, 1e4, 1e5] -layer = $(FUNCTIONNAME)(frequencies, 100, 10, 1, 5, CPEarth()) -println(layer.rho_g) # Output: [100, 100, 100] -println(layer.eps_g) # Output: [8.854e-11, 8.854e-11, 8.854e-11] -println(layer.mu_g) # Output: [1.2566e-6, 1.2566e-6, 1.2566e-6] -``` - -# See also - -- [`CPEarth`](@ref) -""" -function EarthLayer( - frequencies::Vector{T}, - base_rho_g::T, - base_epsr_g::T, - base_mur_g::T, - t::T, - freq_dependence::AbstractFDEMFormulation, -) where {T <: REALSCALAR} - - rho_g, eps_g, mu_g = freq_dependence(frequencies, base_rho_g, base_epsr_g, base_mur_g) - return EarthLayer{T}( - base_rho_g, - base_epsr_g, - base_mur_g, - t, - rho_g, - eps_g, - mu_g, - ) -end - -function EarthLayer( - frequencies::AbstractVector, - base_rho_g, - base_epsr_g, - base_mur_g, - t, - freq_dependence, -) - T = resolve_T(frequencies, base_rho_g, base_epsr_g, base_mur_g, t) - return EarthLayer( - coerce_to_T(frequencies, T), - coerce_to_T(base_rho_g, T), - coerce_to_T(base_epsr_g, T), - coerce_to_T(base_mur_g, T), - coerce_to_T(t, T), - freq_dependence, - ) -end - -""" -$(TYPEDEF) - -Represents a multi-layered earth model with frequency-dependent properties, and attributes: - -$(TYPEDFIELDS) -""" -struct EarthModel{T <: REALSCALAR} - "Selected frequency-dependent formulation for earth properties." - freq_dependence::AbstractFDEMFormulation - "Boolean flag indicating whether the model is treated as vertically layered." - vertical_layers::Bool - "Vector of [`EarthLayer`](@ref) objects, starting with an air layer and the specified first earth layer." - layers::Vector{EarthLayer{T}} - - @doc """ - Constructs an [`EarthModel`](@ref) instance with specified attributes. - """ - function EarthModel{T}(freq_dependence::AbstractFDEMFormulation, - vertical_layers::Bool, - layers::Vector{EarthLayer{T}}) where {T <: REALSCALAR} - new{T}(freq_dependence, vertical_layers, layers) - end -end - -""" -$(TYPEDSIGNATURES) - -Constructs an [`EarthModel`](@ref) instance with a specified first earth layer. A semi-infinite air layer is always added before the first earth layer. - -# Arguments - -- `frequencies`: Vector of frequency values \\[Hz\\]. -- `rho_g`: Base (DC) electrical resistivity of the first earth layer \\[Ω·m\\]. -- `epsr_g`: Base (DC) relative permittivity of the first earth layer \\[dimensionless\\]. -- `mur_g`: Base (DC) relative permeability of the first earth layer \\[dimensionless\\]. -- `t`: Thickness of the first earth layer \\[m\\]. For homogeneous earth models (or the bottommost layer), set `t = Inf`. -- `freq_dependence`: Instance of a subtype of [`AbstractFDEMFormulation`](@ref) defining the computation method for frequency-dependent properties (default: [`CPEarth`](@ref)). -- `vertical_layers`: Boolean flag indicating whether the model should be treated as vertically-layered (default: `false`). -- `air_layer`: optional [`EarthLayer`](@ref) object representing the semi-infinite air layer (default: `EarthLayer(frequencies, Inf, 1.0, 1.0, Inf, freq_dependence)`). - -# Returns - -- An [`EarthModel`](@ref) instance with the specified attributes and computed frequency-dependent properties. - -# Examples - -```julia -frequencies = [1e3, 1e4, 1e5] -earth_model = $(FUNCTIONNAME)(frequencies, 100, 10, 1, t=Inf) -println(length(earth_model.layers)) # Output: 2 (air + top layer) -println(earth_model.rho_eff) # Output: missing -``` - -# See also - -- [`EarthLayer`](@ref) -- [`add!`](@ref) -""" -function EarthModel( - frequencies::Vector{T}, - rho_g::T, - epsr_g::T, - mur_g::T; - t::T = T(Inf), - freq_dependence::AbstractFDEMFormulation = CPEarth(), - vertical_layers::Bool = false, - air_layer::Union{EarthLayer{T}, Nothing} = nothing, -) where {T <: REALSCALAR} - - # Validate inputs - @assert all(f -> f > 0, frequencies) "Frequencies must be positive" - @assert rho_g > 0 "Resistivity must be positive" - @assert epsr_g > 0 "Relative permittivity must be positive" - @assert mur_g > 0 "Relative permeability must be positive" - @assert t > 0 || isinf(t) "Layer thickness must be positive or infinite" - - # Enforce rule for vertical model initialization - if vertical_layers && !isinf(t) - Base.error( - "A vertically-layered model must be initialized with an infinite thickness (t=Inf).", - ) - end - - # Create air layer if not provided - if air_layer === nothing - air_layer = EarthLayer(frequencies, T(Inf), T(1.0), T(1.0), T(Inf), freq_dependence) - end - - # Create top earth layer - top_layer = EarthLayer(frequencies, rho_g, epsr_g, mur_g, t, freq_dependence) - - return EarthModel{T}( - freq_dependence, - vertical_layers, - [air_layer, top_layer], - ) -end - -function EarthModel( - frequencies::AbstractVector, - rho_g, - epsr_g, - mur_g; - t = Inf, - freq_dependence = CPEarth(), - vertical_layers = false, - air_layer = nothing, -) - T = resolve_T( - frequencies, - rho_g, - epsr_g, - mur_g, - t, - freq_dependence, - vertical_layers, - air_layer, - ) - return EarthModel( - coerce_to_T(frequencies, T), - coerce_to_T(rho_g, T), - coerce_to_T(epsr_g, T), - coerce_to_T(mur_g, T); - t = coerce_to_T(t, T), - freq_dependence = freq_dependence, - vertical_layers = vertical_layers, - air_layer = air_layer === nothing ? nothing : coerce_to_T(air_layer, T), - ) -end - -""" -$(TYPEDSIGNATURES) - -Adds a new earth layer to an existing [`EarthModel`](@ref). - -# Arguments - -- `model`: Instance of [`EarthModel`](@ref) to which the new layer will be added. -- `frequencies`: Vector of frequency values \\[Hz\\]. -- `base_rho_g`: Base electrical resistivity of the new earth layer \\[Ω·m\\]. -- `base_epsr_g`: Base relative permittivity of the new earth layer \\[dimensionless\\]. -- `base_mur_g`: Base relative permeability of the new earth layer \\[dimensionless\\]. -- `t`: Thickness of the new earth layer \\[m\\] (default: `Inf`). - -# Returns - -- Modifies `model` in place by appending a new [`EarthLayer`](@ref). - -# Notes - -For **horizontal layering** (`vertical_layers = false`): - -- Layer 1 (air) is always infinite (`t = Inf`). -- Layer 2 (first earth layer) can be infinite if modeling a homogeneous half-space. -- If adding a third layer (`length(EarthModel.layers) == 3`), it can be infinite **only if the previous layer is finite**. -- No two successive earth layers (`length(EarthModel.layers) > 2`) can have infinite thickness. - -For **vertical layering** (`vertical_layers = true`): - -- Layer 1 (air) is always **horizontal** and infinite at `z > 0`. -- Layer 2 (first vertical layer) is always **infinite** in `z < 0` **and** `y < 0`. The first vertical layer is assumed to always end at `y = 0`. -- Layer 3 (second vertical layer) **can be infinite** (establishing a vertical interface at `y = 0`). -- Subsequent layers **can be infinite only if the previous is finite**. -- No two successive vertical layers (`length(EarthModel.layers) > 3`) can both be infinite. - -# Examples - -```julia -frequencies = [1e3, 1e4, 1e5] - -# Define a horizontal model with finite thickness for the first earth layer -horz_earth_model = EarthModel(frequencies, 100, 10, 1, t=5) - -# Add a second horizontal earth layer -$(FUNCTIONNAME)(horz_earth_model, frequencies, 200, 15, 1, t=10) -println(length(horz_earth_model.layers)) # Output: 3 - -# The bottom layer should be set to infinite thickness -$(FUNCTIONNAME)(horz_earth_model, frequencies, 300, 15, 1, t=Inf) -println(length(horz_earth_model.layers)) # Output: 4 - -# Initialize a vertical-layered model with first interface at y = 0. -vert_earth_model = EarthModel(frequencies, 100, 10, 1, t=Inf, vertical_layers=true) - -# Add a second vertical layer at y = 0 (this can also be infinite) -$(FUNCTIONNAME)(vert_earth_model, frequencies, 150, 12, 1, t=Inf) -println(length(vert_earth_model.layers)) # Output: 3 - -# Attempt to add a third infinite layer (invalid case) -try - $(FUNCTIONNAME)(vert_earth_model, frequencies, 120, 12, 1, t=Inf) -catch e - println(e) # Error: Cannot add consecutive vertical layers with infinite thickness. -end - -# Fix: Set a finite thickness to the currently rightmost layer -vert_earth_model.layers[end].t = 3 - -# Add the third layer with infinite thickness now -$(FUNCTIONNAME)(vert_earth_model, frequencies, 120, 12, 1, t=Inf) -println(length(vert_earth_model.layers)) # Output: 4 -``` - -# See also - -- [`EarthLayer`](@ref) -""" -function add!( - model::EarthModel{T}, - frequencies::Vector{T}, - base_rho_g::T, - base_epsr_g::T, - base_mur_g::T; - t::T = T(Inf), -) where {T <: REALSCALAR} - - num_layers = length(model.layers) - - # Validate inputs following established pattern - @assert all(f -> f > 0, frequencies) "Frequencies must be positive" - @assert base_rho_g > 0 "Resistivity must be positive" - @assert base_epsr_g > 0 "Relative permittivity must be positive" - @assert base_mur_g > 0 "Relative permeability must be positive" - @assert t > 0 || isinf(t) "Layer thickness must be positive or infinite" - @assert eltype(frequencies) === T "frequencies eltype must match model T" - @assert all(x -> x isa T, (base_rho_g, base_epsr_g, base_mur_g)) "scalars must match model T" - - # Enforce thickness rules - if isinf(last(model.layers).t) - # The current last layer is infinite. - if model.vertical_layers && num_layers == 2 - # This is the special case: adding the second earth layer to a vertical model. - # The new layer can be finite or infinite. No error. - else - # For all other cases (horizontal, or vertical with >2 earth layers), - # it's an error to add anything after an infinite layer. - model_type = model.vertical_layers ? "vertical" : "horizontal" - Base.error("Cannot add a $(model_type) layer after an infinite one.") - end - end - - # Create the new earth layer - new_layer = EarthLayer( - frequencies, - base_rho_g, - base_epsr_g, - base_mur_g, - t, - model.freq_dependence, - ) - push!(model.layers, new_layer) - - model -end - -function add!( - model::EarthModel, - frequencies::AbstractVector, - base_rho_g, - base_epsr_g, - base_mur_g; - t = Inf, -) - - # Resolve the required type from ALL inputs (the model + the new layer) - T_new = resolve_T(model, frequencies, base_rho_g, base_epsr_g, base_mur_g, t) - T_old = eltype(model) - - if T_new == T_old - # CASE 1: No promotion needed. The model already has the correct type. - # This is the fast path that mutates the existing model. - return add!( - model, # Pass the original model - coerce_to_T(frequencies, T_new), - coerce_to_T(base_rho_g, T_new), - coerce_to_T(base_epsr_g, T_new), - coerce_to_T(base_mur_g, T_new); - t = coerce_to_T(t, T_new), - ) - else - # CASE 2: Promotion is required (e.g., from Float64 to Measurement). - @warn """ - Adding a `$T_new` layer to a `$T_old` EarthModel created a new object and did NOT modify the original in-place. - You MUST capture the returned value to avoid losing changes, e.g. `earth_model = add!(earth_model, ...)` - """ - - # 1. Create a new model by coercing the original one to the new type. - promoted_model = coerce_to_T(model, T_new) - - # 2. Call the inner add! method on the NEWLY CREATED model. - return add!( - promoted_model, - coerce_to_T(frequencies, T_new), - coerce_to_T(base_rho_g, T_new), - coerce_to_T(base_epsr_g, T_new), - coerce_to_T(base_mur_g, T_new); - t = coerce_to_T(t, T_new), - ) - end -end - -include("typecoercion.jl") -include("dataframe.jl") -include("base.jl") - +""" + LineCableModels.EarthProps + +The [`EarthProps`](@ref) module provides functionality for modeling and computing earth properties within the [`LineCableModels.jl`](index.md) package. This module includes definitions for homogeneous and layered earth models, and formulations for frequency-dependent earth properties, to be used in impedance/admittance calculations. + +# Overview + +- Defines the [`EarthModel`](@ref) object for representing horizontally or vertically multi-layered earth models with frequency-dependent properties (ρ, ε, μ). +- Provides the [`EarthLayer`](@ref) type for representing individual soil layers with electromagnetic properties. +- Implements a multi-dispatch framework to allow different formulations of frequency-dependent earth models with [`AbstractFDEMFormulation`](@ref). +- Contains utility functions for building complex multi-layered earth models and generating data summaries. + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module EarthProps + +# Export public API +export CPEarth, + EarthLayer, + EarthModel + +# Module-specific dependencies +using ..Commons +using ..Utils: resolve_T +import ..Commons: get_description, add! +import ..Utils: coerce_to_T +using Measurements + +include("fdprops.jl") + +""" +$(TYPEDEF) + +Represents one single earth layer in an [`EarthModel`](@ref) object, with base and frequency-dependent properties, and attributes: + +$(TYPEDFIELDS) +""" +struct EarthLayer{T <: REALSCALAR} + "Base (DC) electrical resistivity \\[Ω·m\\]." + base_rho_g::T + "Base (DC) relative permittivity \\[dimensionless\\]." + base_epsr_g::T + "Base (DC) relative permeability \\[dimensionless\\]." + base_mur_g::T + "Thickness of the layer \\[m\\]." + base_kappa_g::T + "Base (DC) thermal conductivity \\[W/(m·K)\\]." + t::T + "Computed resistivity values \\[Ω·m\\] at given frequencies." + rho_g::Vector{T} + "Computed permittivity values \\[F/m\\] at given frequencies." + eps_g::Vector{T} + "Computed permeability values \\[H/m\\] at given frequencies." + mu_g::Vector{T} + "Computed thermal conductivity values \\[W/(m·K)\\] at given frequencies." + kappa_g::Vector{T} + + @doc """ + Constructs an [`EarthLayer`](@ref) instance with specified base and frequency-dependent properties. + """ + function EarthLayer{T}(base_rho_g::T, base_epsr_g::T, base_mur_g::T, base_kappa_g::T, t::T, + rho_g::Vector{T}, eps_g::Vector{T}, mu_g::Vector{T}, kappa_g::Vector{T}) where {T <: REALSCALAR} + new{T}(base_rho_g, base_epsr_g, base_mur_g, base_kappa_g, t, rho_g, eps_g, mu_g, kappa_g) + end +end + +""" +$(TYPEDSIGNATURES) + +Constructs an [`EarthLayer`](@ref) instance with specified base properties and computes its frequency-dependent values. + +# Arguments + +- `frequencies`: Vector of frequency values \\[Hz\\]. +- `base_rho_g`: Base (DC) electrical resistivity of the layer \\[Ω·m\\]. +- `base_epsr_g`: Base (DC) relative permittivity of the layer \\[dimensionless\\]. +- `base_mur_g`: Base (DC) relative permeability of the layer \\[dimensionless\\]. +- `t`: Thickness of the layer \\[m\\]. +- `freq_dependence`: Instance of a subtype of [`AbstractFDEMFormulation`](@ref) defining the computation method for frequency-dependent properties. + +# Returns + +- An [`EarthLayer`](@ref) instance with computed frequency-dependent properties. + +# Examples + +```julia +frequencies = [1e3, 1e4, 1e5] +layer = $(FUNCTIONNAME)(frequencies, 100, 10, 1, 5, CPEarth()) +println(layer.rho_g) # Output: [100, 100, 100] +println(layer.eps_g) # Output: [8.854e-11, 8.854e-11, 8.854e-11] +println(layer.mu_g) # Output: [1.2566e-6, 1.2566e-6, 1.2566e-6] +``` + +# See also + +- [`CPEarth`](@ref) +""" +function EarthLayer( + frequencies::Vector{T}, + base_rho_g::T, + base_epsr_g::T, + base_mur_g::T, + base_kappa_g::T, + t::T, + freq_dependence::AbstractFDEMFormulation, +) where {T <: REALSCALAR} + + rho_g, eps_g, mu_g, kappa_g = freq_dependence(frequencies, base_rho_g, base_epsr_g, base_mur_g, base_kappa_g) + return EarthLayer{T}( + base_rho_g, + base_epsr_g, + base_mur_g, + base_kappa_g, + t, + rho_g, + eps_g, + mu_g, + kappa_g, + ) +end + +function EarthLayer( + frequencies::AbstractVector, + base_rho_g, + base_epsr_g, + base_mur_g, + base_kappa_g, + t, + freq_dependence, +) + T = resolve_T(frequencies, base_rho_g, base_epsr_g, base_mur_g, base_kappa_g, t) + return EarthLayer( + coerce_to_T(frequencies, T), + coerce_to_T(base_rho_g, T), + coerce_to_T(base_epsr_g, T), + coerce_to_T(base_mur_g, T), + coerce_to_T(base_kappa_g, T), + coerce_to_T(t, T), + freq_dependence, + ) +end + +""" +$(TYPEDEF) + +Represents a multi-layered earth model with frequency-dependent properties, and attributes: + +$(TYPEDFIELDS) +""" +struct EarthModel{T <: REALSCALAR} + "Selected frequency-dependent formulation for earth properties." + freq_dependence::AbstractFDEMFormulation + "Boolean flag indicating whether the model is treated as vertically layered." + vertical_layers::Bool + "Vector of [`EarthLayer`](@ref) objects, starting with an air layer and the specified first earth layer." + layers::Vector{EarthLayer{T}} + + @doc """ + Constructs an [`EarthModel`](@ref) instance with specified attributes. + """ + function EarthModel{T}(freq_dependence::AbstractFDEMFormulation, + vertical_layers::Bool, + layers::Vector{EarthLayer{T}}) where {T <: REALSCALAR} + new{T}(freq_dependence, vertical_layers, layers) + end +end + +""" +$(TYPEDSIGNATURES) + +Constructs an [`EarthModel`](@ref) instance with a specified first earth layer. A semi-infinite air layer is always added before the first earth layer. + +# Arguments + +- `frequencies`: Vector of frequency values \\[Hz\\]. +- `rho_g`: Base (DC) electrical resistivity of the first earth layer \\[Ω·m\\]. +- `epsr_g`: Base (DC) relative permittivity of the first earth layer \\[dimensionless\\]. +- `mur_g`: Base (DC) relative permeability of the first earth layer \\[dimensionless\\]. +- `t`: Thickness of the first earth layer \\[m\\]. For homogeneous earth models (or the bottommost layer), set `t = Inf`. +- `freq_dependence`: Instance of a subtype of [`AbstractFDEMFormulation`](@ref) defining the computation method for frequency-dependent properties (default: [`CPEarth`](@ref)). +- `vertical_layers`: Boolean flag indicating whether the model should be treated as vertically-layered (default: `false`). +- `air_layer`: optional [`EarthLayer`](@ref) object representing the semi-infinite air layer (default: `EarthLayer(frequencies, Inf, 1.0, 1.0, Inf, freq_dependence)`). + +# Returns + +- An [`EarthModel`](@ref) instance with the specified attributes and computed frequency-dependent properties. + +# Examples + +```julia +frequencies = [1e3, 1e4, 1e5] +earth_model = $(FUNCTIONNAME)(frequencies, 100, 10, 1, t=Inf) +println(length(earth_model.layers)) # Output: 2 (air + top layer) +println(earth_model.rho_eff) # Output: missing +``` + +# See also + +- [`EarthLayer`](@ref) +- [`add!`](@ref) +""" +function EarthModel( + frequencies::Vector{T}, + rho_g::T, + epsr_g::T, + mur_g::T, + kappa_g::T; + t::T = T(Inf), + freq_dependence::AbstractFDEMFormulation = CPEarth(), + vertical_layers::Bool = false, + air_layer::Union{EarthLayer{T}, Nothing} = nothing, +) where {T <: REALSCALAR} + + # Validate inputs + @assert all(f -> f > 0, frequencies) "Frequencies must be positive" + @assert rho_g > 0 "Resistivity must be positive" + @assert epsr_g > 0 "Relative permittivity must be positive" + @assert mur_g > 0 "Relative permeability must be positive" + @assert kappa_g >= 0 "Thermal conductivity must be positive" + @assert t > 0 || isinf(t) "Layer thickness must be positive or infinite" + + # Enforce rule for vertical model initialization + if vertical_layers && !isinf(t) + Base.error( + "A vertically-layered model must be initialized with an infinite thickness (t=Inf).", + ) + end + + # Create air layer if not provided + if air_layer === nothing + air_layer = EarthLayer(frequencies, T(Inf), T(1.0), T(1.0), 0.024, T(Inf), freq_dependence) + end + + # Create top earth layer + top_layer = EarthLayer(frequencies, rho_g, epsr_g, mur_g, kappa_g, t, freq_dependence) + + return EarthModel{T}( + freq_dependence, + vertical_layers, + [air_layer, top_layer], + ) +end + +function EarthModel( + frequencies::AbstractVector, + rho_g, + epsr_g, + mur_g, + kappa_g; + t = Inf, + freq_dependence = CPEarth(), + vertical_layers = false, + air_layer = nothing, +) + T = resolve_T( + frequencies, + rho_g, + epsr_g, + mur_g, + t, + freq_dependence, + vertical_layers, + air_layer, + ) + return EarthModel( + coerce_to_T(frequencies, T), + coerce_to_T(rho_g, T), + coerce_to_T(epsr_g, T), + coerce_to_T(mur_g, T), + coerce_to_T(kappa_g, T); + t = coerce_to_T(t, T), + freq_dependence = freq_dependence, + vertical_layers = vertical_layers, + air_layer = air_layer === nothing ? nothing : coerce_to_T(air_layer, T), + ) +end + +""" +$(TYPEDSIGNATURES) + +Adds a new earth layer to an existing [`EarthModel`](@ref). + +# Arguments + +- `model`: Instance of [`EarthModel`](@ref) to which the new layer will be added. +- `frequencies`: Vector of frequency values \\[Hz\\]. +- `base_rho_g`: Base electrical resistivity of the new earth layer \\[Ω·m\\]. +- `base_epsr_g`: Base relative permittivity of the new earth layer \\[dimensionless\\]. +- `base_mur_g`: Base relative permeability of the new earth layer \\[dimensionless\\]. +- `t`: Thickness of the new earth layer \\[m\\] (default: `Inf`). + +# Returns + +- Modifies `model` in place by appending a new [`EarthLayer`](@ref). + +# Notes + +For **horizontal layering** (`vertical_layers = false`): + +- Layer 1 (air) is always infinite (`t = Inf`). +- Layer 2 (first earth layer) can be infinite if modeling a homogeneous half-space. +- If adding a third layer (`length(EarthModel.layers) == 3`), it can be infinite **only if the previous layer is finite**. +- No two successive earth layers (`length(EarthModel.layers) > 2`) can have infinite thickness. + +For **vertical layering** (`vertical_layers = true`): + +- Layer 1 (air) is always **horizontal** and infinite at `z > 0`. +- Layer 2 (first vertical layer) is always **infinite** in `z < 0` **and** `y < 0`. The first vertical layer is assumed to always end at `y = 0`. +- Layer 3 (second vertical layer) **can be infinite** (establishing a vertical interface at `y = 0`). +- Subsequent layers **can be infinite only if the previous is finite**. +- No two successive vertical layers (`length(EarthModel.layers) > 3`) can both be infinite. + +# Examples + +```julia +frequencies = [1e3, 1e4, 1e5] + +# Define a horizontal model with finite thickness for the first earth layer +horz_earth_model = EarthModel(frequencies, 100, 10, 1, t=5) + +# Add a second horizontal earth layer +$(FUNCTIONNAME)(horz_earth_model, frequencies, 200, 15, 1, t=10) +println(length(horz_earth_model.layers)) # Output: 3 + +# The bottom layer should be set to infinite thickness +$(FUNCTIONNAME)(horz_earth_model, frequencies, 300, 15, 1, t=Inf) +println(length(horz_earth_model.layers)) # Output: 4 + +# Initialize a vertical-layered model with first interface at y = 0. +vert_earth_model = EarthModel(frequencies, 100, 10, 1, t=Inf, vertical_layers=true) + +# Add a second vertical layer at y = 0 (this can also be infinite) +$(FUNCTIONNAME)(vert_earth_model, frequencies, 150, 12, 1, t=Inf) +println(length(vert_earth_model.layers)) # Output: 3 + +# Attempt to add a third infinite layer (invalid case) +try + $(FUNCTIONNAME)(vert_earth_model, frequencies, 120, 12, 1, t=Inf) +catch e + println(e) # Error: Cannot add consecutive vertical layers with infinite thickness. +end + +# Fix: Set a finite thickness to the currently rightmost layer +vert_earth_model.layers[end].t = 3 + +# Add the third layer with infinite thickness now +$(FUNCTIONNAME)(vert_earth_model, frequencies, 120, 12, 1, t=Inf) +println(length(vert_earth_model.layers)) # Output: 4 +``` + +# See also + +- [`EarthLayer`](@ref) +""" +function add!( + model::EarthModel{T}, + frequencies::Vector{T}, + base_rho_g::T, + base_epsr_g::T, + base_mur_g::T, + base_kappa_g::T; + t::T = T(Inf), +) where {T <: REALSCALAR} + + num_layers = length(model.layers) + + # Validate inputs following established pattern + @assert all(f -> f > 0, frequencies) "Frequencies must be positive" + @assert base_rho_g > 0 "Resistivity must be positive" + @assert base_epsr_g > 0 "Relative permittivity must be positive" + @assert base_mur_g > 0 "Relative permeability must be positive" + @assert base_kappa_g >= 0 "Thermal conductivity must be positive" + @assert t > 0 || isinf(t) "Layer thickness must be positive or infinite" + @assert eltype(frequencies) === T "frequencies eltype must match model T" + @assert all(x -> x isa T, (base_rho_g, base_epsr_g, base_mur_g, base_kappa_g)) "scalars must match model T" + + # Enforce thickness rules + if isinf(last(model.layers).t) + # The current last layer is infinite. + if model.vertical_layers && num_layers == 2 + # This is the special case: adding the second earth layer to a vertical model. + # The new layer can be finite or infinite. No error. + else + # For all other cases (horizontal, or vertical with >2 earth layers), + # it's an error to add anything after an infinite layer. + model_type = model.vertical_layers ? "vertical" : "horizontal" + Base.error("Cannot add a $(model_type) layer after an infinite one.") + end + end + + # Create the new earth layer + new_layer = EarthLayer( + frequencies, + base_rho_g, + base_epsr_g, + base_mur_g, + base_kappa_g, + t, + model.freq_dependence, + ) + push!(model.layers, new_layer) + + model +end + +function add!( + model::EarthModel, + frequencies::AbstractVector, + base_rho_g, + base_epsr_g, + base_mur_g, + base_kappa_g; + t = Inf, +) + + # Resolve the required type from ALL inputs (the model + the new layer) + T_new = resolve_T(model, frequencies, base_rho_g, base_epsr_g, base_mur_g, base_kappa_g, t) + T_old = eltype(model) + + if T_new == T_old + # CASE 1: No promotion needed. The model already has the correct type. + # This is the fast path that mutates the existing model. + return add!( + model, # Pass the original model + coerce_to_T(frequencies, T_new), + coerce_to_T(base_rho_g, T_new), + coerce_to_T(base_epsr_g, T_new), + coerce_to_T(base_mur_g, T_new), + coerce_to_T(base_kappa_g, T_new); + t = coerce_to_T(t, T_new), + ) + else + # CASE 2: Promotion is required (e.g., from Float64 to Measurement). + @warn """ + Adding a `$T_new` layer to a `$T_old` EarthModel created a new object and did NOT modify the original in-place. + You MUST capture the returned value to avoid losing changes, e.g. `earth_model = add!(earth_model, ...)` + """ + + # 1. Create a new model by coercing the original one to the new type. + promoted_model = coerce_to_T(model, T_new) + + # 2. Call the inner add! method on the NEWLY CREATED model. + return add!( + promoted_model, + coerce_to_T(frequencies, T_new), + coerce_to_T(base_rho_g, T_new), + coerce_to_T(base_epsr_g, T_new), + coerce_to_T(base_mur_g, T_new), + coerce_to_T(base_kappa_g, T_new); + t = coerce_to_T(t, T_new), + ) + end +end + +include("typecoercion.jl") +include("dataframe.jl") +include("base.jl") + end # module EarthProps \ No newline at end of file diff --git a/src/earthprops/base.jl b/src/earthprops/base.jl index 6f8a165f..92df307a 100644 --- a/src/earthprops/base.jl +++ b/src/earthprops/base.jl @@ -1,82 +1,83 @@ - -Base.eltype(::EarthModel{T}) where {T} = T -Base.eltype(::EarthLayer{T}) where {T} = T - -function Base.convert(::Type{EarthModel{T}}, model::EarthModel) where {T} - # If the model is already the target type, return it without modification. - model isa EarthModel{T} && return model - - # Delegate the actual conversion logic to the existing coerce_to_T function. - return coerce_to_T(model, T) -end - -function Base.convert(::Type{EarthLayer{T}}, layer::EarthLayer) where {T} - # Avoid unnecessary work if the layer is already the correct type. - layer isa EarthLayer{T} && return layer - - # Delegate the conversion logic to the specialized coerce_to_T function. - return coerce_to_T(layer, T) -end - -""" -$(TYPEDSIGNATURES) - -Defines the display representation of a [`EarthModel`](@ref) object for REPL or text output. - -# Arguments - -- `io`: The output stream to write the representation to \\[IO\\]. -- `mime`: The MIME type for plain text output \\[MIME"text/plain"\\]. -- `model`: The [`EarthModel`](@ref) instance to be displayed. - - -# Returns - -- Nothing. Modifies `io` to format the output. -""" -function Base.show(io::IO, ::MIME"text/plain", model::EarthModel) - # Determine model type based on num_layers and vertical_layers flag - num_layers = length(model.layers) - model_type = num_layers == 2 ? "homogeneous" : "multilayer" - orientation = model.vertical_layers ? "vertical" : "horizontal" - layer_word = (num_layers - 1) == 1 ? "layer" : "layers" - - # Count frequency samples from the first layer's property arrays - num_freq_samples = length(model.layers[1].rho_g) - freq_word = (num_freq_samples) == 1 ? "sample" : "samples" - - # Print header with key information - println( - io, - "EarthModel with $(num_layers-1) $(orientation) earth $(layer_word) ($(model_type)) and $(num_freq_samples) frequency $(freq_word)", - ) - - # Print layers in treeview style - for i in 1:num_layers - layer = model.layers[i] - # Determine prefix based on whether it's the last layer - prefix = i == num_layers ? "└─" : "├─" - - # Format thickness value - thickness_str = isinf(layer.t) ? "Inf" : "$(round(layer.t, sigdigits=4))" - - # Format layer name - layer_name = i == 1 ? "Layer $i (air)" : "Layer $i" - - # Print layer properties with proper formatting - println( - io, - "$prefix $layer_name: [rho_g=$(round(layer.base_rho_g, sigdigits=4)), " * - "epsr_g=$(round(layer.base_epsr_g, sigdigits=4)), " * - "mur_g=$(round(layer.base_mur_g, sigdigits=4)), " * - "t=$thickness_str]", - ) - end - - # Add formulation information as child nodes - if !isnothing(model.freq_dependence) - formulation_tag = get_description(model.freq_dependence) - println(io, " Frequency-dependent model: $(formulation_tag)") - end - + +Base.eltype(::EarthModel{T}) where {T} = T +Base.eltype(::EarthLayer{T}) where {T} = T + +function Base.convert(::Type{EarthModel{T}}, model::EarthModel) where {T} + # If the model is already the target type, return it without modification. + model isa EarthModel{T} && return model + + # Delegate the actual conversion logic to the existing coerce_to_T function. + return coerce_to_T(model, T) +end + +function Base.convert(::Type{EarthLayer{T}}, layer::EarthLayer) where {T} + # Avoid unnecessary work if the layer is already the correct type. + layer isa EarthLayer{T} && return layer + + # Delegate the conversion logic to the specialized coerce_to_T function. + return coerce_to_T(layer, T) +end + +""" +$(TYPEDSIGNATURES) + +Defines the display representation of a [`EarthModel`](@ref) object for REPL or text output. + +# Arguments + +- `io`: The output stream to write the representation to \\[IO\\]. +- `mime`: The MIME type for plain text output \\[MIME"text/plain"\\]. +- `model`: The [`EarthModel`](@ref) instance to be displayed. + + +# Returns + +- Nothing. Modifies `io` to format the output. +""" +function Base.show(io::IO, ::MIME"text/plain", model::EarthModel) + # Determine model type based on num_layers and vertical_layers flag + num_layers = length(model.layers) + model_type = num_layers == 2 ? "homogeneous" : "multilayer" + orientation = model.vertical_layers ? "vertical" : "horizontal" + layer_word = (num_layers - 1) == 1 ? "layer" : "layers" + + # Count frequency samples from the first layer's property arrays + num_freq_samples = length(model.layers[1].rho_g) + freq_word = (num_freq_samples) == 1 ? "sample" : "samples" + + # Print header with key information + println( + io, + "EarthModel with $(num_layers-1) $(orientation) earth $(layer_word) ($(model_type)) and $(num_freq_samples) frequency $(freq_word)", + ) + + # Print layers in treeview style + for i in 1:num_layers + layer = model.layers[i] + # Determine prefix based on whether it's the last layer + prefix = i == num_layers ? "└─" : "├─" + + # Format thickness value + thickness_str = isinf(layer.t) ? "Inf" : "$(round(layer.t, sigdigits=4))" + + # Format layer name + layer_name = i == 1 ? "Layer $i (air)" : "Layer $i" + + # Print layer properties with proper formatting + println( + io, + "$prefix $layer_name: [rho_g=$(round(layer.base_rho_g, sigdigits=4)), " * + "epsr_g=$(round(layer.base_epsr_g, sigdigits=4)), " * + "mur_g=$(round(layer.base_mur_g, sigdigits=4)), " * + "kappa_g=$(round(layer.base_kappa_g, sigdigits=4)), " * + "t=$thickness_str]", + ) + end + + # Add formulation information as child nodes + if !isnothing(model.freq_dependence) + formulation_tag = get_description(model.freq_dependence) + println(io, " Frequency-dependent model: $(formulation_tag)") + end + end \ No newline at end of file diff --git a/src/earthprops/dataframe.jl b/src/earthprops/dataframe.jl index 1b2e3a03..84785f00 100644 --- a/src/earthprops/dataframe.jl +++ b/src/earthprops/dataframe.jl @@ -1,41 +1,43 @@ -import DataFrames: DataFrame - -""" -$(TYPEDSIGNATURES) - -Generates a `DataFrame` summarizing basic properties of earth layers from an [`EarthModel`](@ref). - -# Arguments - -- `earth_model`: Instance of [`EarthModel`](@ref) containing earth layers. - -# Returns - -- A `DataFrame` with columns: - - `rho_g`: Base (DC) resistivity of each layer \\[Ω·m\\]. - - `epsr_g`: Base (DC) relative permittivity of each layer \\[dimensionless\\]. - - `mur_g`: Base (DC) relative permeability of each layer \\[dimensionless\\]. - - `thickness`: Thickness of each layer \\[m\\]. - -# Examples - -```julia -df = $(FUNCTIONNAME)(earth_model) -println(df) -``` -""" -function DataFrame(earth_model::EarthModel) - layers = earth_model.layers - - base_rho_g = [layer.base_rho_g for layer in layers] - base_epsr_g = [layer.base_epsr_g for layer in layers] - base_mur_g = [layer.base_mur_g for layer in layers] - thickness = [layer.t for layer in layers] - - return DataFrame( - rho_g=base_rho_g, - epsr_g=base_epsr_g, - mur_g=base_mur_g, - thickness=thickness, - ) +import DataFrames: DataFrame + +""" +$(TYPEDSIGNATURES) + +Generates a `DataFrame` summarizing basic properties of earth layers from an [`EarthModel`](@ref). + +# Arguments + +- `earth_model`: Instance of [`EarthModel`](@ref) containing earth layers. + +# Returns + +- A `DataFrame` with columns: + - `rho_g`: Base (DC) resistivity of each layer \\[Ω·m\\]. + - `epsr_g`: Base (DC) relative permittivity of each layer \\[dimensionless\\]. + - `mur_g`: Base (DC) relative permeability of each layer \\[dimensionless\\]. + - `thickness`: Thickness of each layer \\[m\\]. + +# Examples + +```julia +df = $(FUNCTIONNAME)(earth_model) +println(df) +``` +""" +function DataFrame(earth_model::EarthModel) + layers = earth_model.layers + + base_rho_g = [layer.base_rho_g for layer in layers] + base_epsr_g = [layer.base_epsr_g for layer in layers] + base_mur_g = [layer.base_mur_g for layer in layers] + base_kappa_g = [layer.base_kappa_g for layer in layers] + thickness = [layer.t for layer in layers] + + return DataFrame( + rho_g=base_rho_g, + epsr_g=base_epsr_g, + mur_g=base_mur_g, + kappa_g=base_kappa_g, + thickness=thickness, + ) end \ No newline at end of file diff --git a/src/earthprops/fdprops.jl b/src/earthprops/fdprops.jl index 61521303..6c21d891 100644 --- a/src/earthprops/fdprops.jl +++ b/src/earthprops/fdprops.jl @@ -1,83 +1,86 @@ -""" -$(TYPEDEF) - -Abstract type representing different frequency-dependent earth models (FDEM). Used in the multi-dispatch implementations in modules [`LineCableModels.EarthProps`](@ref) and [`LineCableModels.Engine`](@ref). - -# Currently available formulations - -- [`CPEarth`](@ref): Constant properties (CP) model. -""" -abstract type AbstractFDEMFormulation end - -""" -$(TYPEDEF) - -Represents an earth model with constant properties (CP), i.e. frequency-invariant electromagnetic properties. -""" -struct CPEarth <: AbstractFDEMFormulation end -get_description(::CPEarth) = "Constant properties (CP)" - - -""" -$(TYPEDSIGNATURES) - -Functor implementation for `CPEarth`. - -Computes frequency-dependent earth properties using the [`CPEarth`](@ref) formulation, which assumes frequency-invariant values for resistivity, permittivity, and permeability. - -# Arguments - -- `frequencies`: Vector of frequency values \\[Hz\\]. -- `base_rho_g`: Base (DC) electrical resistivity of the soil \\[Ω·m\\]. -- `base_epsr_g`: Base (DC) relative permittivity of the soil \\[dimensionless\\]. -- `base_mur_g`: Base (DC) relative permeability of the soil \\[dimensionless\\]. -- `formulation`: Instance of a subtype of [`AbstractFDEMFormulation`](@ref) defining the computation method. - -# Returns - -- `rho`: Vector of resistivity values \\[Ω·m\\] at the given frequencies. -- `epsilon`: Vector of permittivity values \\[F/m\\] at the given frequencies. -- `mu`: Vector of permeability values \\[H/m\\] at the given frequencies. - -# Examples - -```julia -frequencies = [1e3, 1e4, 1e5] - -# Using the CP model -rho, epsilon, mu = $(FUNCTIONNAME)(frequencies, 100, 10, 1, CPEarth()) -println(rho) # Output: [100, 100, 100] -println(epsilon) # Output: [8.854e-11, 8.854e-11, 8.854e-11] -println(mu) # Output: [1.2566e-6, 1.2566e-6, 1.2566e-6] -``` - -# See also - -- [`EarthLayer`](@ref) -""" -function (f::CPEarth)(frequencies::Vector{T}, base_rho_g::T, base_epsr_g::T, - base_mur_g::T) where {T<:REALSCALAR} - - # Preallocate for performance - n_freq = length(frequencies) - rho = Vector{T}(undef, n_freq) - epsilon = Vector{typeof(ε₀ * base_epsr_g)}(undef, n_freq) - mu = Vector{typeof(μ₀ * base_mur_g)}(undef, n_freq) - - # Vectorized assignment - fill!(rho, base_rho_g) - fill!(epsilon, ε₀ * base_epsr_g) - fill!(mu, μ₀ * base_mur_g) - - return rho, epsilon, mu -end - -function (f::CPEarth)(frequencies::AbstractVector, base_rho_g, base_epsr_g, base_mur_g) - T = resolve_T(frequencies, base_rho_g, base_epsr_g, base_mur_g) - return f( - coerce_to_T(frequencies, T), - coerce_to_T(base_rho_g, T), - coerce_to_T(base_epsr_g, T), - coerce_to_T(base_mur_g, T), - ) -end +""" +$(TYPEDEF) + +Abstract type representing different frequency-dependent earth models (FDEM). Used in the multi-dispatch implementations in modules [`LineCableModels.EarthProps`](@ref) and [`LineCableModels.Engine`](@ref). + +# Currently available formulations + +- [`CPEarth`](@ref): Constant properties (CP) model. +""" +abstract type AbstractFDEMFormulation end + +""" +$(TYPEDEF) + +Represents an earth model with constant properties (CP), i.e. frequency-invariant electromagnetic properties. +""" +struct CPEarth <: AbstractFDEMFormulation end +get_description(::CPEarth) = "Constant properties (CP)" + + +""" +$(TYPEDSIGNATURES) + +Functor implementation for `CPEarth`. + +Computes frequency-dependent earth properties using the [`CPEarth`](@ref) formulation, which assumes frequency-invariant values for resistivity, permittivity, and permeability. + +# Arguments + +- `frequencies`: Vector of frequency values \\[Hz\\]. +- `base_rho_g`: Base (DC) electrical resistivity of the soil \\[Ω·m\\]. +- `base_epsr_g`: Base (DC) relative permittivity of the soil \\[dimensionless\\]. +- `base_mur_g`: Base (DC) relative permeability of the soil \\[dimensionless\\]. +- `formulation`: Instance of a subtype of [`AbstractFDEMFormulation`](@ref) defining the computation method. + +# Returns + +- `rho`: Vector of resistivity values \\[Ω·m\\] at the given frequencies. +- `epsilon`: Vector of permittivity values \\[F/m\\] at the given frequencies. +- `mu`: Vector of permeability values \\[H/m\\] at the given frequencies. + +# Examples + +```julia +frequencies = [1e3, 1e4, 1e5] + +# Using the CP model +rho, epsilon, mu, kappa = $(FUNCTIONNAME)(frequencies, 100, 10, 1, 1.0, CPEarth()) +println(rho) # Output: [100, 100, 100] +println(epsilon) # Output: [8.854e-11, 8.854e-11, 8.854e-11] +println(mu) # Output: [1.2566e-6, 1.2566e-6, 1.2566e-6] +println(kappa) # Output: [1.0, 1.0, 1.0] + +# See also + +- [`EarthLayer`](@ref) +""" +function (f::CPEarth)(frequencies::Vector{T}, base_rho_g::T, base_epsr_g::T, + base_mur_g::T, base_kappa_g::T) where {T<:REALSCALAR} + + # Preallocate for performance + n_freq = length(frequencies) + rho = Vector{T}(undef, n_freq) + epsilon = Vector{typeof(ε₀ * base_epsr_g)}(undef, n_freq) + mu = Vector{typeof(μ₀ * base_mur_g)}(undef, n_freq) + kappa = Vector{typeof(base_kappa_g)}(undef, n_freq) + + # Vectorized assignment + fill!(rho, base_rho_g) + fill!(epsilon, ε₀ * base_epsr_g) + fill!(mu, μ₀ * base_mur_g) + fill!(kappa, base_kappa_g) + + return rho, epsilon, mu, kappa +end + +function (f::CPEarth)(frequencies::AbstractVector, base_rho_g, base_epsr_g, base_mur_g, base_kappa_g) + T = resolve_T(frequencies, base_rho_g, base_epsr_g, base_mur_g, base_kappa_g) + return f( + coerce_to_T(frequencies, T), + coerce_to_T(base_rho_g, T), + coerce_to_T(base_epsr_g, T), + coerce_to_T(base_mur_g, T), + coerce_to_T(base_kappa_g, T), + ) +end diff --git a/src/earthprops/typecoercion.jl b/src/earthprops/typecoercion.jl index 3e54f3c5..09bad8c5 100644 --- a/src/earthprops/typecoercion.jl +++ b/src/earthprops/typecoercion.jl @@ -1,85 +1,87 @@ -""" -$(TYPEDSIGNATURES) - -Converts an `EarthModel{S}` to `EarthModel{T}` by reconstructing the model with -all layers coerced to the new scalar type `T`. Layer conversion is delegated to -[`coerce_to_T(::EarthLayer, ::Type)`](@ref), and non-numeric metadata are -forwarded unchanged. - -# Arguments - -- `model`: Source Earth model \\[dimensionless\\]. -- `::Type{T}`: Target element type for numeric fields \\[dimensionless\\]. - -# Returns - -- `EarthModel{T}` rebuilt with each layer and numeric payload converted to `T`. - -# Examples - -```julia -m64 = $(FUNCTIONNAME)(model, Float64) -mM = $(FUNCTIONNAME)(model, Measurement{Float64}) -``` - -# See also - -- [`coerce_to_T`](@ref) -- [`resolve_T`](@ref) -""" -function coerce_to_T(model::EarthModel, ::Type{T}) where {T} - # 1. Coerce all existing layers recursively - new_layers = [coerce_to_T(layer, T) for layer in model.layers] - - # 2. Use the inner constructor to build the new, promoted model - return EarthModel{T}( - model.freq_dependence, - model.vertical_layers, - new_layers - ) -end - -""" -$(TYPEDSIGNATURES) - -Converts an `EarthLayer{S}` to `EarthLayer{T}` by coercing each stored field to -the target element type `T` and rebuilding the layer via its inner constructor. -Scalar and array fields are converted using the generic [`coerce_to_T`](@ref) -machinery. - -# Arguments - -- `layer`: Source Earth layer \\[dimensionless\\]. -- `::Type{T}`: Target element type for numeric fields \\[dimensionless\\]. - -# Returns - -- `EarthLayer{T}` with all numeric state converted to `T`. - -# Examples - -```julia -ℓ64 = $(FUNCTIONNAME)(layer, Float64) -ℓM = $(FUNCTIONNAME)(layer, Measurement{Float64}) -``` - -# See also - -- [`coerce_to_T`](@ref) -- [`resolve_T`](@ref) -""" -function coerce_to_T(layer::EarthLayer, ::Type{T}) where {T} - # Reconstruct the layer using the correct internal constructor. - # The existing coerce_to_T methods for scalars and arrays will be - # dispatched automatically for each field. - return EarthLayer{T}( - coerce_to_T(layer.base_rho_g, T), - coerce_to_T(layer.base_epsr_g, T), - coerce_to_T(layer.base_mur_g, T), - coerce_to_T(layer.t, T), - coerce_to_T(layer.rho_g, T), - coerce_to_T(layer.eps_g, T), - coerce_to_T(layer.mu_g, T) - ) -end - +""" +$(TYPEDSIGNATURES) + +Converts an `EarthModel{S}` to `EarthModel{T}` by reconstructing the model with +all layers coerced to the new scalar type `T`. Layer conversion is delegated to +[`coerce_to_T(::EarthLayer, ::Type)`](@ref), and non-numeric metadata are +forwarded unchanged. + +# Arguments + +- `model`: Source Earth model \\[dimensionless\\]. +- `::Type{T}`: Target element type for numeric fields \\[dimensionless\\]. + +# Returns + +- `EarthModel{T}` rebuilt with each layer and numeric payload converted to `T`. + +# Examples + +```julia +m64 = $(FUNCTIONNAME)(model, Float64) +mM = $(FUNCTIONNAME)(model, Measurement{Float64}) +``` + +# See also + +- [`coerce_to_T`](@ref) +- [`resolve_T`](@ref) +""" +function coerce_to_T(model::EarthModel, ::Type{T}) where {T} + # 1. Coerce all existing layers recursively + new_layers = [coerce_to_T(layer, T) for layer in model.layers] + + # 2. Use the inner constructor to build the new, promoted model + return EarthModel{T}( + model.freq_dependence, + model.vertical_layers, + new_layers + ) +end + +""" +$(TYPEDSIGNATURES) + +Converts an `EarthLayer{S}` to `EarthLayer{T}` by coercing each stored field to +the target element type `T` and rebuilding the layer via its inner constructor. +Scalar and array fields are converted using the generic [`coerce_to_T`](@ref) +machinery. + +# Arguments + +- `layer`: Source Earth layer \\[dimensionless\\]. +- `::Type{T}`: Target element type for numeric fields \\[dimensionless\\]. + +# Returns + +- `EarthLayer{T}` with all numeric state converted to `T`. + +# Examples + +```julia +ℓ64 = $(FUNCTIONNAME)(layer, Float64) +ℓM = $(FUNCTIONNAME)(layer, Measurement{Float64}) +``` + +# See also + +- [`coerce_to_T`](@ref) +- [`resolve_T`](@ref) +""" +function coerce_to_T(layer::EarthLayer, ::Type{T}) where {T} + # Reconstruct the layer using the correct internal constructor. + # The existing coerce_to_T methods for scalars and arrays will be + # dispatched automatically for each field. + return EarthLayer{T}( + coerce_to_T(layer.base_rho_g, T), + coerce_to_T(layer.base_epsr_g, T), + coerce_to_T(layer.base_mur_g, T), + coerce_to_T(layer.base_kappa_g, T), + coerce_to_T(layer.t, T), + coerce_to_T(layer.rho_g, T), + coerce_to_T(layer.eps_g, T), + coerce_to_T(layer.mu_g, T), + coerce_to_T(layer.kappa_g, T) + ) +end + diff --git a/src/engine/Engine.jl b/src/engine/Engine.jl index fd03e499..b2ab0417 100644 --- a/src/engine/Engine.jl +++ b/src/engine/Engine.jl @@ -1,105 +1,105 @@ -""" - LineCableModels.Engine - -The [`Engine`](@ref) module provides the main functionalities of the [`LineCableModels.jl`](index.md) package. This module implements data structures, methods and functions for calculating frequency-dependent electrical parameters (Z/Y matrices) of line and cable systems with uncertainty quantification. - -# Overview - -- Calculation of frequency-dependent series impedance (Z) and shunt admittance (Y) matrices. -- Uncertainty propagation for geometric and material parameters using `Measurements.jl`. -- Internal impedance computation for solid, tubular and multi-layered coaxial conductors. -- Earth return impedances/admittances for overhead lines and underground cables (valid up to 10 MHz). -- Support for frequency-dependent soil properties. -- Handling of arbitrary polyphase systems with multiple conductors per phase. -- Phase and sequence domain calculations with uncertainty quantification. -- Novel N-layer concentric cable formulation with semiconductor modeling. - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module Engine - -# Export public API -export LineParametersProblem, - LineParameters, SeriesImpedance, ShuntAdmittance, per_km, per_m, kronify -export EMTFormulation, FormulationSet, LineParamOptions - -export compute!, plot - -# Module-specific dependencies -using Reexport, ForceImport -using Measurements -using LinearAlgebra -using ..Commons -import ..Commons: get_description - -using ..Utils -using ..Materials -using ..EarthProps: EarthModel -using ..DataModel: LineCableSystem - -include("types.jl") - -# Problem definitions -include("lineparamopts.jl") -include("problemdefs.jl") -include("lineparams.jl") - -# Submodule `InternalImpedance` -include("internalimpedance/InternalImpedance.jl") -using .InternalImpedance: InternalImpedance - -# Submodule `InsulationImpedance` -include("insulationimpedance/InsulationImpedance.jl") -using .InsulationImpedance: InsulationImpedance - -# Submodule `EarthImpedance` -include("earthimpedance/EarthImpedance.jl") -using .EarthImpedance: EarthImpedance - -# Submodule `InsulationAdmittance` -include("insulationadmittance/InsulationAdmittance.jl") -using .InsulationAdmittance: InsulationAdmittance - -# Submodule `EarthAdmittance` -include("earthadmittance/EarthAdmittance.jl") -using .EarthAdmittance: EarthAdmittance - -# Submodule `Transforms` -include("transforms/Transforms.jl") -using .Transforms - -# Submodule `EHEM` -include("ehem/EHEM.jl") -using .EHEM - -# Helpers -include("helpers.jl") - -# Workspace definition -include("workspace.jl") - -# Computation methods -include("solver.jl") -include("reduction.jl") -include("plot.jl") - -# Override I/O methods -include("base.jl") - -# Submodule `FEM` -include("fem/FEM.jl") - -@reexport using .InternalImpedance: InternalImpedance -@reexport using .InsulationImpedance: InsulationImpedance -@reexport using .EarthImpedance: EarthImpedance -@reexport using .InsulationAdmittance: InsulationAdmittance -@reexport using .EarthAdmittance: EarthAdmittance -@reexport using .EHEM, .Transforms - -end # module Engine +""" + LineCableModels.Engine + +The [`Engine`](@ref) module provides the main functionalities of the [`LineCableModels.jl`](index.md) package. This module implements data structures, methods and functions for calculating frequency-dependent electrical parameters (Z/Y matrices) of line and cable systems with uncertainty quantification. + +# Overview + +- Calculation of frequency-dependent series impedance (Z) and shunt admittance (Y) matrices. +- Uncertainty propagation for geometric and material parameters using `Measurements.jl`. +- Internal impedance computation for solid, tubular and multi-layered coaxial conductors. +- Earth return impedances/admittances for overhead lines and underground cables (valid up to 10 MHz). +- Support for frequency-dependent soil properties. +- Handling of arbitrary polyphase systems with multiple conductors per phase. +- Phase and sequence domain calculations with uncertainty quantification. +- Novel N-layer concentric cable formulation with semiconductor modeling. + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module Engine + +# Export public API +export LineParametersProblem, AmpacityProblem, + LineParameters, SeriesImpedance, ShuntAdmittance, per_km, per_m, kronify +export EMTFormulation, FormulationSet, LineParamOptions + +export compute!, plot + +# Module-specific dependencies +using Reexport, ForceImport +using Measurements +using LinearAlgebra +using ..Commons +import ..Commons: get_description + +using ..Utils +using ..Materials +using ..EarthProps: EarthModel +using ..DataModel: LineCableSystem + +include("types.jl") + +# Problem definitions +include("lineparamopts.jl") +include("problemdefs.jl") +include("lineparams.jl") + +# Submodule `InternalImpedance` +include("internalimpedance/InternalImpedance.jl") +using .InternalImpedance: InternalImpedance + +# Submodule `InsulationImpedance` +include("insulationimpedance/InsulationImpedance.jl") +using .InsulationImpedance: InsulationImpedance + +# Submodule `EarthImpedance` +include("earthimpedance/EarthImpedance.jl") +using .EarthImpedance: EarthImpedance + +# Submodule `InsulationAdmittance` +include("insulationadmittance/InsulationAdmittance.jl") +using .InsulationAdmittance: InsulationAdmittance + +# Submodule `EarthAdmittance` +include("earthadmittance/EarthAdmittance.jl") +using .EarthAdmittance: EarthAdmittance + +# Submodule `Transforms` +include("transforms/Transforms.jl") +using .Transforms + +# Submodule `EHEM` +include("ehem/EHEM.jl") +using .EHEM + +# Helpers +include("helpers.jl") + +# Workspace definition +include("workspace.jl") + +# Computation methods +include("solver.jl") +include("reduction.jl") +include("plot.jl") + +# Override I/O methods +include("base.jl") + +# Submodule `FEM` +include("fem/FEM.jl") + +@reexport using .InternalImpedance: InternalImpedance +@reexport using .InsulationImpedance: InsulationImpedance +@reexport using .EarthImpedance: EarthImpedance +@reexport using .InsulationAdmittance: InsulationAdmittance +@reexport using .EarthAdmittance: EarthAdmittance +@reexport using .EHEM, .Transforms + +end # module Engine diff --git a/src/engine/base.jl b/src/engine/base.jl index 59e4d582..77538c45 100644 --- a/src/engine/base.jl +++ b/src/engine/base.jl @@ -1,394 +1,394 @@ - -Base.eltype(::LineParametersProblem{T}) where {T} = T -Base.eltype(::Type{LineParametersProblem{T}}) where {T} = T - -Base.eltype(::LineParameters{T}) where {T} = T -Base.eltype(::Type{LineParameters{T}}) where {T} = T - -Base.eltype(::EMTWorkspace{T}) where {T} = T -Base.eltype(::Type{EMTWorkspace{T}}) where {T} = T - -abstract type UnitLen end -struct PerMeter <: UnitLen end -struct PerKilometer <: UnitLen end -_len_scale(::PerMeter) = 1.0 -_len_scale(::PerKilometer) = 1_000.0 -_len_label(::PerMeter) = "m" -_len_label(::PerKilometer) = "km" - -abstract type DisplayMode end -struct AsZY <: DisplayMode end -struct AsRLCG <: DisplayMode end - -using Printf -using Measurements: value, uncertainty - -""" -ResultsView: pretty, non-mutating renderer with zero-clipping and units. - -- `mode = AsZY()` prints Z [Ω/len], Y [S/len]. -- `mode = AsRLCG()` prints R [Ω/len], L [mH/len], G [S/len], C [µF/len]; - frequency is taken from the `LineParameters.f` vector of the view. -- `tol` clips tiny magnitudes to 0.0 in display only (value & uncertainty). -""" -struct ResultsView{LP <: LineParameters, U <: UnitLen, M <: DisplayMode} - lp::LP - unit::U - mode::M - tol::Float64 -end - -# Builders -resultsview( - lp::LineParameters; - per::Symbol = :km, - mode::Symbol = :ZY, - tol::Real = sqrt(eps(Float64)), -) = ResultsView( - lp, - per === :km ? PerKilometer() : PerMeter(), - mode === :ZY ? AsZY() : AsRLCG(), - float(tol), -) - -# --- Scalar formatting with zero-clipping ------------------------------------- - -# Zero-clip helpers (display only) -_clip(x::Real, tol) = (abs(x) < tol ? 0.0 : x) - -_format_real(io, x::Real, tol) = @printf(io, "%.6g", _clip(x, tol)) - -_format_meas(io, m, tol) = begin - v = _clip(value(m), tol) - u = _clip(uncertainty(m), tol) - @printf(io, "%.6g±%.6g", v, u) -end - -_format_complex(io, z, tol) = begin - # z may be Complex{<:Real} or Complex{<:Measurement} - print(io, "") - if z.re isa Real - _format_real(io, real(z), tol) - else - _format_meas(io, real(z), tol) - end - print(io, "+") - if z.im isa Real - _format_real(io, imag(z), tol) - else - _format_meas(io, imag(z), tol) - end - print(io, "im") -end - -_format_any(io, x, tol) = - x isa Complex ? _format_complex(io, x, tol) : - x isa Measurements.Measurement ? _format_meas(io, x, tol) : - _format_real(io, x, tol) - -# String versions (for aligned, copy-pastable matrix literals) -_repr_real(x::Real, tol) = @sprintf("%.6g", _clip(x, tol)) -_repr_meas(m, tol) = begin - v = _clip(value(m), tol) - u = _clip(uncertainty(m), tol) - @sprintf("%.6g±%.6g", v, u) -end -function _repr_complex(z, tol) - if z.re isa Real - rs = _repr_real(real(z), tol) - else - rs = _repr_meas(real(z), tol) - end - if z.im isa Real - is = _repr_real(imag(z), tol) - else - is = _repr_meas(imag(z), tol) - end - return string(rs, "+", is, "im") -end -_repr_any(x, tol) = - x isa Complex ? _repr_complex(x, tol) : - x isa Measurements.Measurement ? _repr_meas(x, tol) : _repr_real(x, tol) - -# Detect if any element would be clipped by tolerance after mapping -_would_clip(x::Real, tol) = (x != 0 && abs(x) < tol) -_would_clip_meas(m, tol) = _would_clip(value(m), tol) || _would_clip(uncertainty(m), tol) -function _would_clip_complex(z, tol) - (z.re isa Real ? _would_clip(real(z), tol) : _would_clip_meas(real(z), tol)) || - (z.im isa Real ? _would_clip(imag(z), tol) : _would_clip_meas(imag(z), tol)) -end -_would_clip_any(x, tol) = - x isa Complex ? _would_clip_complex(x, tol) : - x isa Measurements.Measurement ? _would_clip_meas(x, tol) : _would_clip(x, tol) - -function _any_clipped(A::AbstractMatrix; tol::Float64, map::Function = identity) - n1, n2 = size(A, 1), size(A, 2) - @inbounds for i in 1:n1, j in 1:n2 - x = map(A[i, j]) - _would_clip_any(x, tol) && return true - end - return false -end - - - -# --- Show methods -------------------------------------------------------------- - -function _show_matrix(io::IO, A::AbstractArray; tol::Float64, map::Function = identity) - n1, n2 = size(A, 1), size(A, 2) - for i in 1:n1 - for j in 1:n2 - j > 1 && print(io, " ") - _format_any(io, map(A[i, j]), tol) - end - i < n1 && print(io, '\n') - end -end - -# Copy-pastable Julia matrix literal with column alignment -function _show_matrix_literal( - io::IO, - A::AbstractMatrix; - tol::Float64, - map::Function = identity, -) - n1, n2 = size(A, 1), size(A, 2) - # Build string table - S = [_repr_any(map(A[i, j]), tol) for i in 1:n1, j in 1:n2] - # Column widths - widths = [maximum(length(S[i, j]) for i in 1:n1) for j in 1:n2] - # Print rows - for i in 1:n1 - print(io, i == 1 ? "[" : " ") - for j in 1:n2 - s = S[i, j] - pad = widths[j] - length(s) - # right align - print(io, " "^pad, s) - if j < n2 - print(io, " ") - end - end - if i < n1 - print(io, ";\n") - else - print(io, "]") - end - end -end - -function Base.show(io::IO, ::MIME"text/plain", rv::ResultsView) - lp = rv.lp - unit = rv.unit - tol = rv.tol - scale = _len_scale(unit) - ulabel = _len_label(unit) - _, _, nf = size(lp.Z) - - # Determine if any value would be clipped across displayed content - any_clipped = false - if rv.mode isa AsZY - @inbounds for k in 1:nf - Zk = lp.Z.values[:, :, k] - Yk = lp.Y.values[:, :, k] - any_clipped |= _any_clipped(Zk; tol = tol, map = x -> scale * x) - any_clipped && break - any_clipped |= _any_clipped(Yk; tol = tol, map = x -> scale * x) - any_clipped && break - end - else - @inbounds for k in 1:nf - Zk = lp.Z.values[:, :, k] - Yk = lp.Y.values[:, :, k] - fk = lp.f[k] - ω = 2 * pi * float(fk) - any_clipped |= - _any_clipped(Zk; tol = tol, map = x -> scale * real(x)) || - _any_clipped(Zk; tol = tol, map = x -> (scale * 1e3 / ω) * imag(x)) || - _any_clipped(Yk; tol = tol, map = x -> scale * real(x)) || - _any_clipped(Yk; tol = tol, map = x -> (scale * 1e6 / ω) * imag(x)) - any_clipped && break - end - end - - # Styled header similar to DataFrame-like formatting - n, _, _ = size(lp.Z) - mode_label = rv.mode isa AsZY ? "ZY" : "RLCG" - tol_str = @sprintf("%.1e", tol) - header_plain = - @sprintf("%dx%dx%d LineParameters | mode = %s | units per %s | tol = %s%s", - n, n, nf, mode_label, ulabel, tol_str, any_clipped ? " (!)" : "") - printstyled(io, @sprintf("%dx%dx%d LineParameters", n, n, nf); bold = true) - print(io, " | mode = ") - printstyled(io, mode_label; bold = true, color = :cyan) - print(io, " | units per ", ulabel, " | tol = ", tol_str) - if any_clipped - print(io, " ") - printstyled(io, "(!)"; bold = true, color = :yellow) - end - print(io, "\n") - print(io, repeat("─", length(header_plain))) - print(io, "\n\n") - - @views for k in 1:nf - # Slice header with frequency of the slice - fk = lp.f[k] - print(io, "\n[:, :, ", k, "] @ f=") - print(io, @sprintf("%.6g", float(fk))) - print(io, " Hz\n") - Zk = lp.Z.values[:, :, k] - Yk = lp.Y.values[:, :, k] - - if rv.mode isa AsZY - println(io, "Z [Ω/", ulabel, "] =") - _show_matrix_literal(io, Zk; tol = tol, map = x -> scale * x) - - print(io, "\n\nY [S/", ulabel, "] =\n") - _show_matrix_literal(io, Yk; tol = tol, map = x -> scale * x) - else - # derive ω from frequency vector for this slice - ω = 2 * pi * float(fk) - - println(io, "R [Ω/", ulabel, "] =") - _show_matrix_literal(io, Zk; tol = tol, map = x -> scale * real(x)) - - print(io, "\n\nL [mH/", ulabel, "] =\n") - _show_matrix_literal(io, Zk; tol = tol, map = x -> (scale * 1e3 / ω) * imag(x)) - - print(io, "\n\nG [S/", ulabel, "] =\n") - _show_matrix_literal(io, Yk; tol = tol, map = x -> scale * real(x)) - - print(io, "\n\nC [µF/", ulabel, "] =\n") - _show_matrix_literal(io, Yk; tol = tol, map = x -> (scale * 1e6 / ω) * imag(x)) - end - - k < nf && print(io, "\n", "---"^10, "\n") - end -end - -function Base.show(io::IO, ::MIME"text/plain", Z::SeriesImpedance) - n, _, nf = size(Z.values) - header_plain = @sprintf("%dx%dx%d SeriesImpedance [Ω/m]", n, n, nf) - printstyled(io, header_plain; bold = true) - print(io, "\n") - print(io, repeat("─", length(header_plain))) - print(io, "\n") - @views _show_matrix(io, Z.values[:, :, 1]; tol = sqrt(eps(Float64))) - size(Z, 3) > 1 && print( - io, - "\n… (", - size(Z, 3) - 1, - " more slice", - size(Z, 3) - 1 == 1 ? "" : "s", - ")", - ) -end - -function Base.show(io::IO, ::MIME"text/plain", Y::ShuntAdmittance) - n, _, nf = size(Y.values) - header_plain = @sprintf("%dx%dx%d ShuntAdmittance [S/m]", n, n, nf) - printstyled(io, header_plain; bold = true) - print(io, "\n") - print(io, repeat("─", length(header_plain))) - print(io, "\n") - @views _show_matrix(io, Y.values[:, :, 1]; tol = sqrt(eps(Float64))) - size(Y, 3) > 1 && print( - io, - "\n… (", - size(Y, 3) - 1, - " more slice", - size(Y, 3) - 1 == 1 ? "" : "s", - ")", - ) -end - - - -# ---- SeriesImpedance array-ish interface ---- -Base.size(Z::SeriesImpedance) = size(Z.values) -Base.size(Z::SeriesImpedance, d::Int) = size(Z.values, d) -Base.axes(Z::SeriesImpedance) = axes(Z.values) -Base.ndims(::Type{SeriesImpedance{T}}) where {T} = 3 -Base.eltype(::Type{SeriesImpedance{T}}) where {T} = T -Base.getindex(Z::SeriesImpedance, I...) = @inbounds Z.values[I...] - -# ---- ShuntAdmittance array-ish interface ---- -Base.size(Y::ShuntAdmittance) = size(Y.values) -Base.size(Y::ShuntAdmittance, d::Int) = size(Y.values, d) -Base.axes(Y::ShuntAdmittance) = axes(Y.values) -Base.ndims(::Type{ShuntAdmittance{T}}) where {T} = 3 -Base.eltype(::Type{ShuntAdmittance{T}}) where {T} = T -Base.getindex(Y::ShuntAdmittance, I...) = @inbounds Y.values[I...] - -# --- Frequency-slice sugar ---------------------------------------------------- -@inline Base.getindex(lp::LineParameters, k::Integer) = LineParameters( - SeriesImpedance(@view lp.Z.values[:, :, k:k]), - ShuntAdmittance(@view lp.Y.values[:, :, k:k]), - lp.f[k:k], -) - -# --- One-argument k, derive ω from freq (or accept ω directly) --------------- -function per_km(lp::LineParameters, k::Integer = 1; - mode::Symbol = :ZY, - tol::Real = sqrt(eps(Float64))) - lpk = lp[k] - return resultsview(lpk; per = :km, mode = mode, tol = tol) -end - -function per_m(lp::LineParameters, k::Integer = 1; - mode::Symbol = :ZY, - tol::Real = sqrt(eps(Float64))) - lpk = lp[k] - return resultsview(lpk; per = :m, mode = mode, tol = tol) -end - -# Helper: detect uncertainties in element type -_has_uncertainty_type(::Type{Complex{S}}) where {S} = S <: Measurement -_has_uncertainty_type(::Type) = false - -# Terse summary (used inside collections) -function Base.show(io::IO, lp::LineParameters) - n, _, nf = size(lp.Z) - T = eltype(lp.Z) - print(io, "LineParameters{$(T)} ", n, "×", n, "×", nf, " [Z:Ω/m, Y:S/m]") - _has_uncertainty_type(T) && print(io, " (±)") -end - -function Base.show(io::IO, ::MIME"text/plain", lp::LineParameters) - n, _, nf = size(lp.Z) - T = eltype(lp.Z) - tol = sqrt(eps(Float64)) - scale = 1_000.0 # per km preview - ulabel = "km" - - # Styled header similar to ResultsView - header_plain = string( - n, "x", n, "x", nf, " LineParameters | eltype = ", T, - _has_uncertainty_type(T) ? " | uncertainties: yes" : "", - ) - - printstyled(io, string(n, "x", n, "x", nf, " LineParameters"); bold = true) - print(io, " | eltype = ", T) - _has_uncertainty_type(T) && print(io, " | uncertainties: yes") - print(io, "\n") - print(io, repeat("─", length(header_plain))) - print(io, "\n\n") - - # Preview: slice 1, per km, Z then Y - @views begin - Z1 = view(lp.Z.values,:,:,1) - Y1 = view(lp.Y.values,:,:,1) - - println(io, "Preview (slice 1/", nf, ") per ", ulabel) - println(io, "Z [Ω/", ulabel, "] =") - _show_matrix(io, Z1; tol = tol, map = x -> scale * x) - - print(io, "\n\nY [S/", ulabel, "] =\n") - _show_matrix(io, Y1; tol = tol, map = x -> scale * x) - end - - if nf > 1 - print(io, "\n\n… (", nf - 1, " more frequency slice", nf - 1 == 1 ? "" : "s", ")") - end -end - + +Base.eltype(::LineParametersProblem{T}) where {T} = T +Base.eltype(::Type{LineParametersProblem{T}}) where {T} = T + +Base.eltype(::LineParameters{T}) where {T} = T +Base.eltype(::Type{LineParameters{T}}) where {T} = T + +Base.eltype(::EMTWorkspace{T}) where {T} = T +Base.eltype(::Type{EMTWorkspace{T}}) where {T} = T + +abstract type UnitLen end +struct PerMeter <: UnitLen end +struct PerKilometer <: UnitLen end +_len_scale(::PerMeter) = 1.0 +_len_scale(::PerKilometer) = 1_000.0 +_len_label(::PerMeter) = "m" +_len_label(::PerKilometer) = "km" + +abstract type DisplayMode end +struct AsZY <: DisplayMode end +struct AsRLCG <: DisplayMode end + +using Printf +using Measurements: value, uncertainty + +""" +ResultsView: pretty, non-mutating renderer with zero-clipping and units. + +- `mode = AsZY()` prints Z [Ω/len], Y [S/len]. +- `mode = AsRLCG()` prints R [Ω/len], L [mH/len], G [S/len], C [µF/len]; + frequency is taken from the `LineParameters.f` vector of the view. +- `tol` clips tiny magnitudes to 0.0 in display only (value & uncertainty). +""" +struct ResultsView{LP <: LineParameters, U <: UnitLen, M <: DisplayMode} + lp::LP + unit::U + mode::M + tol::Float64 +end + +# Builders +resultsview( + lp::LineParameters; + per::Symbol = :km, + mode::Symbol = :ZY, + tol::Real = sqrt(eps(Float64)), +) = ResultsView( + lp, + per === :km ? PerKilometer() : PerMeter(), + mode === :ZY ? AsZY() : AsRLCG(), + float(tol), +) + +# --- Scalar formatting with zero-clipping ------------------------------------- + +# Zero-clip helpers (display only) +_clip(x::Real, tol) = (abs(x) < tol ? 0.0 : x) + +_format_real(io, x::Real, tol) = @printf(io, "%.6g", _clip(x, tol)) + +_format_meas(io, m, tol) = begin + v = _clip(value(m), tol) + u = _clip(uncertainty(m), tol) + @printf(io, "%.6g±%.6g", v, u) +end + +_format_complex(io, z, tol) = begin + # z may be Complex{<:Real} or Complex{<:Measurement} + print(io, "") + if z.re isa Real + _format_real(io, real(z), tol) + else + _format_meas(io, real(z), tol) + end + print(io, "+") + if z.im isa Real + _format_real(io, imag(z), tol) + else + _format_meas(io, imag(z), tol) + end + print(io, "im") +end + +_format_any(io, x, tol) = + x isa Complex ? _format_complex(io, x, tol) : + x isa Measurements.Measurement ? _format_meas(io, x, tol) : + _format_real(io, x, tol) + +# String versions (for aligned, copy-pastable matrix literals) +_repr_real(x::Real, tol) = @sprintf("%.6g", _clip(x, tol)) +_repr_meas(m, tol) = begin + v = _clip(value(m), tol) + u = _clip(uncertainty(m), tol) + @sprintf("%.6g±%.6g", v, u) +end +function _repr_complex(z, tol) + if z.re isa Real + rs = _repr_real(real(z), tol) + else + rs = _repr_meas(real(z), tol) + end + if z.im isa Real + is = _repr_real(imag(z), tol) + else + is = _repr_meas(imag(z), tol) + end + return string(rs, "+", is, "im") +end +_repr_any(x, tol) = + x isa Complex ? _repr_complex(x, tol) : + x isa Measurements.Measurement ? _repr_meas(x, tol) : _repr_real(x, tol) + +# Detect if any element would be clipped by tolerance after mapping +_would_clip(x::Real, tol) = (x != 0 && abs(x) < tol) +_would_clip_meas(m, tol) = _would_clip(value(m), tol) || _would_clip(uncertainty(m), tol) +function _would_clip_complex(z, tol) + (z.re isa Real ? _would_clip(real(z), tol) : _would_clip_meas(real(z), tol)) || + (z.im isa Real ? _would_clip(imag(z), tol) : _would_clip_meas(imag(z), tol)) +end +_would_clip_any(x, tol) = + x isa Complex ? _would_clip_complex(x, tol) : + x isa Measurements.Measurement ? _would_clip_meas(x, tol) : _would_clip(x, tol) + +function _any_clipped(A::AbstractMatrix; tol::Float64, map::Function = identity) + n1, n2 = size(A, 1), size(A, 2) + @inbounds for i in 1:n1, j in 1:n2 + x = map(A[i, j]) + _would_clip_any(x, tol) && return true + end + return false +end + + + +# --- Show methods -------------------------------------------------------------- + +function _show_matrix(io::IO, A::AbstractArray; tol::Float64, map::Function = identity) + n1, n2 = size(A, 1), size(A, 2) + for i in 1:n1 + for j in 1:n2 + j > 1 && print(io, " ") + _format_any(io, map(A[i, j]), tol) + end + i < n1 && print(io, '\n') + end +end + +# Copy-pastable Julia matrix literal with column alignment +function _show_matrix_literal( + io::IO, + A::AbstractMatrix; + tol::Float64, + map::Function = identity, +) + n1, n2 = size(A, 1), size(A, 2) + # Build string table + S = [_repr_any(map(A[i, j]), tol) for i in 1:n1, j in 1:n2] + # Column widths + widths = [maximum(length(S[i, j]) for i in 1:n1) for j in 1:n2] + # Print rows + for i in 1:n1 + print(io, i == 1 ? "[" : " ") + for j in 1:n2 + s = S[i, j] + pad = widths[j] - length(s) + # right align + print(io, " "^pad, s) + if j < n2 + print(io, " ") + end + end + if i < n1 + print(io, ";\n") + else + print(io, "]") + end + end +end + +function Base.show(io::IO, ::MIME"text/plain", rv::ResultsView) + lp = rv.lp + unit = rv.unit + tol = rv.tol + scale = _len_scale(unit) + ulabel = _len_label(unit) + _, _, nf = size(lp.Z) + + # Determine if any value would be clipped across displayed content + any_clipped = false + if rv.mode isa AsZY + @inbounds for k in 1:nf + Zk = lp.Z.values[:, :, k] + Yk = lp.Y.values[:, :, k] + any_clipped |= _any_clipped(Zk; tol = tol, map = x -> scale * x) + any_clipped && break + any_clipped |= _any_clipped(Yk; tol = tol, map = x -> scale * x) + any_clipped && break + end + else + @inbounds for k in 1:nf + Zk = lp.Z.values[:, :, k] + Yk = lp.Y.values[:, :, k] + fk = lp.f[k] + ω = 2 * pi * float(fk) + any_clipped |= + _any_clipped(Zk; tol = tol, map = x -> scale * real(x)) || + _any_clipped(Zk; tol = tol, map = x -> (scale * 1e3 / ω) * imag(x)) || + _any_clipped(Yk; tol = tol, map = x -> scale * real(x)) || + _any_clipped(Yk; tol = tol, map = x -> (scale * 1e6 / ω) * imag(x)) + any_clipped && break + end + end + + # Styled header similar to DataFrame-like formatting + n, _, _ = size(lp.Z) + mode_label = rv.mode isa AsZY ? "ZY" : "RLCG" + tol_str = @sprintf("%.1e", tol) + header_plain = + @sprintf("%dx%dx%d LineParameters | mode = %s | units per %s | tol = %s%s", + n, n, nf, mode_label, ulabel, tol_str, any_clipped ? " (!)" : "") + printstyled(io, @sprintf("%dx%dx%d LineParameters", n, n, nf); bold = true) + print(io, " | mode = ") + printstyled(io, mode_label; bold = true, color = :cyan) + print(io, " | units per ", ulabel, " | tol = ", tol_str) + if any_clipped + print(io, " ") + printstyled(io, "(!)"; bold = true, color = :yellow) + end + print(io, "\n") + print(io, repeat("─", length(header_plain))) + print(io, "\n\n") + + @views for k in 1:nf + # Slice header with frequency of the slice + fk = lp.f[k] + print(io, "\n[:, :, ", k, "] @ f=") + print(io, @sprintf("%.6g", float(fk))) + print(io, " Hz\n") + Zk = lp.Z.values[:, :, k] + Yk = lp.Y.values[:, :, k] + + if rv.mode isa AsZY + println(io, "Z [Ω/", ulabel, "] =") + _show_matrix_literal(io, Zk; tol = tol, map = x -> scale * x) + + print(io, "\n\nY [S/", ulabel, "] =\n") + _show_matrix_literal(io, Yk; tol = tol, map = x -> scale * x) + else + # derive ω from frequency vector for this slice + ω = 2 * pi * float(fk) + + println(io, "R [Ω/", ulabel, "] =") + _show_matrix_literal(io, Zk; tol = tol, map = x -> scale * real(x)) + + print(io, "\n\nL [mH/", ulabel, "] =\n") + _show_matrix_literal(io, Zk; tol = tol, map = x -> (scale * 1e3 / ω) * imag(x)) + + print(io, "\n\nG [S/", ulabel, "] =\n") + _show_matrix_literal(io, Yk; tol = tol, map = x -> scale * real(x)) + + print(io, "\n\nC [µF/", ulabel, "] =\n") + _show_matrix_literal(io, Yk; tol = tol, map = x -> (scale * 1e6 / ω) * imag(x)) + end + + k < nf && print(io, "\n", "---"^10, "\n") + end +end + +function Base.show(io::IO, ::MIME"text/plain", Z::SeriesImpedance) + n, _, nf = size(Z.values) + header_plain = @sprintf("%dx%dx%d SeriesImpedance [Ω/m]", n, n, nf) + printstyled(io, header_plain; bold = true) + print(io, "\n") + print(io, repeat("─", length(header_plain))) + print(io, "\n") + @views _show_matrix(io, Z.values[:, :, 1]; tol = sqrt(eps(Float64))) + size(Z, 3) > 1 && print( + io, + "\n… (", + size(Z, 3) - 1, + " more slice", + size(Z, 3) - 1 == 1 ? "" : "s", + ")", + ) +end + +function Base.show(io::IO, ::MIME"text/plain", Y::ShuntAdmittance) + n, _, nf = size(Y.values) + header_plain = @sprintf("%dx%dx%d ShuntAdmittance [S/m]", n, n, nf) + printstyled(io, header_plain; bold = true) + print(io, "\n") + print(io, repeat("─", length(header_plain))) + print(io, "\n") + @views _show_matrix(io, Y.values[:, :, 1]; tol = sqrt(eps(Float64))) + size(Y, 3) > 1 && print( + io, + "\n… (", + size(Y, 3) - 1, + " more slice", + size(Y, 3) - 1 == 1 ? "" : "s", + ")", + ) +end + + + +# ---- SeriesImpedance array-ish interface ---- +Base.size(Z::SeriesImpedance) = size(Z.values) +Base.size(Z::SeriesImpedance, d::Int) = size(Z.values, d) +Base.axes(Z::SeriesImpedance) = axes(Z.values) +Base.ndims(::Type{SeriesImpedance{T}}) where {T} = 3 +Base.eltype(::Type{SeriesImpedance{T}}) where {T} = T +Base.getindex(Z::SeriesImpedance, I...) = @inbounds Z.values[I...] + +# ---- ShuntAdmittance array-ish interface ---- +Base.size(Y::ShuntAdmittance) = size(Y.values) +Base.size(Y::ShuntAdmittance, d::Int) = size(Y.values, d) +Base.axes(Y::ShuntAdmittance) = axes(Y.values) +Base.ndims(::Type{ShuntAdmittance{T}}) where {T} = 3 +Base.eltype(::Type{ShuntAdmittance{T}}) where {T} = T +Base.getindex(Y::ShuntAdmittance, I...) = @inbounds Y.values[I...] + +# --- Frequency-slice sugar ---------------------------------------------------- +@inline Base.getindex(lp::LineParameters, k::Integer) = LineParameters( + SeriesImpedance(@view lp.Z.values[:, :, k:k]), + ShuntAdmittance(@view lp.Y.values[:, :, k:k]), + lp.f[k:k], +) + +# --- One-argument k, derive ω from freq (or accept ω directly) --------------- +function per_km(lp::LineParameters, k::Integer = 1; + mode::Symbol = :ZY, + tol::Real = sqrt(eps(Float64))) + lpk = lp[k] + return resultsview(lpk; per = :km, mode = mode, tol = tol) +end + +function per_m(lp::LineParameters, k::Integer = 1; + mode::Symbol = :ZY, + tol::Real = sqrt(eps(Float64))) + lpk = lp[k] + return resultsview(lpk; per = :m, mode = mode, tol = tol) +end + +# Helper: detect uncertainties in element type +_has_uncertainty_type(::Type{Complex{S}}) where {S} = S <: Measurement +_has_uncertainty_type(::Type) = false + +# Terse summary (used inside collections) +function Base.show(io::IO, lp::LineParameters) + n, _, nf = size(lp.Z) + T = eltype(lp.Z) + print(io, "LineParameters{$(T)} ", n, "×", n, "×", nf, " [Z:Ω/m, Y:S/m]") + _has_uncertainty_type(T) && print(io, " (±)") +end + +function Base.show(io::IO, ::MIME"text/plain", lp::LineParameters) + n, _, nf = size(lp.Z) + T = eltype(lp.Z) + tol = sqrt(eps(Float64)) + scale = 1_000.0 # per km preview + ulabel = "km" + + # Styled header similar to ResultsView + header_plain = string( + n, "x", n, "x", nf, " LineParameters | eltype = ", T, + _has_uncertainty_type(T) ? " | uncertainties: yes" : "", + ) + + printstyled(io, string(n, "x", n, "x", nf, " LineParameters"); bold = true) + print(io, " | eltype = ", T) + _has_uncertainty_type(T) && print(io, " | uncertainties: yes") + print(io, "\n") + print(io, repeat("─", length(header_plain))) + print(io, "\n\n") + + # Preview: slice 1, per km, Z then Y + @views begin + Z1 = view(lp.Z.values,:,:,1) + Y1 = view(lp.Y.values,:,:,1) + + println(io, "Preview (slice 1/", nf, ") per ", ulabel) + println(io, "Z [Ω/", ulabel, "] =") + _show_matrix(io, Z1; tol = tol, map = x -> scale * x) + + print(io, "\n\nY [S/", ulabel, "] =\n") + _show_matrix(io, Y1; tol = tol, map = x -> scale * x) + end + + if nf > 1 + print(io, "\n\n… (", nf - 1, " more frequency slice", nf - 1 == 1 ? "" : "s", ")") + end +end + diff --git a/src/engine/earthadmittance/EarthAdmittance.jl b/src/engine/earthadmittance/EarthAdmittance.jl index 8905cb2b..da5842fb 100644 --- a/src/engine/earthadmittance/EarthAdmittance.jl +++ b/src/engine/earthadmittance/EarthAdmittance.jl @@ -1,28 +1,28 @@ -""" - LineCableModels.Engine.EarthAdmittance - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module EarthAdmittance - -# Export public API -export Papadopoulos - -# Module-specific dependencies -using ...Commons -import ...Commons: get_description -import ..Engine: EarthAdmittanceFormulation -using Measurements: Measurement, value -using QuadGK: quadgk -using ...Utils: _to_σ, _bessel_diff, to_nominal - -include("homogeneous.jl") -include("base.jl") - -end # module EarthAdmittance +""" + LineCableModels.Engine.EarthAdmittance + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module EarthAdmittance + +# Export public API +export Papadopoulos + +# Module-specific dependencies +using ...Commons +import ...Commons: get_description +import ..Engine: EarthAdmittanceFormulation +using Measurements: Measurement, value +using QuadGK: quadgk +using ...Utils: _to_σ, _bessel_diff, to_nominal + +include("homogeneous.jl") +include("base.jl") + +end # module EarthAdmittance diff --git a/src/engine/earthadmittance/base.jl b/src/engine/earthadmittance/base.jl index 69adbf0a..d4d318d7 100644 --- a/src/engine/earthadmittance/base.jl +++ b/src/engine/earthadmittance/base.jl @@ -1,7 +1,7 @@ -@inline function Base.getproperty(f::Homogeneous, name::Symbol) - if name === :s || name === :t || name === :Γx || name === :γ1 || name === :γ2 || - name === :μ2 - return getproperty(from_kernel(f), name) - end - return getfield(f, name) # subtype-specific fields (if any) +@inline function Base.getproperty(f::Homogeneous, name::Symbol) + if name === :s || name === :t || name === :Γx || name === :γ1 || name === :γ2 || + name === :μ2 + return getproperty(from_kernel(f), name) + end + return getfield(f, name) # subtype-specific fields (if any) end \ No newline at end of file diff --git a/src/engine/earthadmittance/homogeneous.jl b/src/engine/earthadmittance/homogeneous.jl index 2fbd7b08..dfeb0d92 100644 --- a/src/engine/earthadmittance/homogeneous.jl +++ b/src/engine/earthadmittance/homogeneous.jl @@ -1,228 +1,228 @@ -abstract type Homogeneous <: EarthAdmittanceFormulation end - -struct Kernel{Tγ1, Tγ2, Tμ2} - "Layer where the source conductor is placed." - s::Int - "Layer where the target conductor is placed." - t::Int - "Primary field propagation constant (0 = lossless, 1 = air, 2 = earth)." - Γx::Int - "Air propagation constant γ₁(jω, μ, σ, ε)." - γ1::Tγ1 - "Earth propagation constant γ₂(jω, μ, σ, ε)." - γ2::Tγ2 - "Earth magnetic-constant assumption μ₂(μ)." - μ2::Tμ2 -end - -struct Papadopoulos{Tγ1, Tγ2, Tμ2} <: Homogeneous - kernel::Kernel{Tγ1, Tγ2, Tμ2} -end - -Papadopoulos(; s::Int = 2, t::Int = 2, Γx::Int = 2, - γ1 = (jω, μ, σ, ε) -> sqrt(jω * μ * (σ + jω*ε)), - γ2 = (jω, μ, σ, ε) -> sqrt(jω * μ * (σ + jω*ε)), - μ2 = μ -> μ) = - Papadopoulos( - Kernel{typeof(γ1), typeof(γ2), typeof(μ2)}(s, t, Γx, γ1, γ2, μ2), - ) - -get_description(::Papadopoulos) = "Papadopoulos" -from_kernel(f::Papadopoulos) = f.kernel - - -struct Pollaczek{Tγ1, Tγ2, Tμ2} <: Homogeneous - kernel::Kernel{Tγ1, Tγ2, Tμ2} -end - -Pollaczek(; s::Int = 2, t::Int = 2, Γx::Int = 0, - γ1 = (jω, μ, σ, ε) -> jω * sqrt(μ * ε₀), - γ2 = (jω, μ, σ, ε) -> jω * sqrt(μ * ε₀), - μ2 = μ -> oftype(μ, μ₀)) = - Pollaczek( - Kernel{typeof(γ1), typeof(γ2), typeof(μ2)}(s, t, Γx, γ1, γ2, μ2), - ) - -get_description(::Pollaczek) = "Pollaczek" -from_kernel(f::Pollaczek) = f.kernel - -struct Images{Tγ1, Tγ2, Tμ2} <: Homogeneous - kernel::Kernel{Tγ1, Tγ2, Tμ2} -end - -Images(; s::Int = 1, t::Int = 1, Γx::Int = 0, - γ1 = (jω, μ, σ, ε) -> jω * sqrt(μ * ε₀), - γ2 = (jω, μ, σ, ε) -> jω * sqrt(μ * ε₀), - μ2 = μ -> oftype(μ, μ₀)) = - Images( - Kernel{typeof(γ1), typeof(γ2), typeof(μ2)}(s, t, Γx, γ1, γ2, μ2), - ) - -get_description(::Images) = "Electrostatic images" -from_kernel(f::Images) = f.kernel - -# ρ, ε, μ = ws.rho_g, ws.eps_g, ws.mu_g -# f(h, d, @view(ρ[:,k]), @view(ε[:,k]), @view(μ[:,k]), ws.jω[k]) - -# Functor implementation for all homogeneous earth impedance formulations. -function (f::Homogeneous)( - form::Symbol, - h::AbstractVector{T}, - yij::T, - rho_g::AbstractVector{T}, - eps_g::AbstractVector{T}, - mu_g::AbstractVector{T}, - jω::Complex{T}, -) where {T <: REALSCALAR} - Base.@nospecialize form - return form === :self ? f(Val(:self), h, yij, rho_g, eps_g, mu_g, jω) : - form === :mutual ? f(Val(:mutual), h, yij, rho_g, eps_g, mu_g, jω) : - throw(ArgumentError("Unknown earth admittance form: $form")) -end - -# function (f::Homogeneous)( -# h::AbstractVector{T}, -# yij::T, -# rho_g::AbstractVector{T}, -# eps_g::AbstractVector{T}, -# mu_g::AbstractVector{T}, -# jω::Complex{T}, -# ) where {T <: REALSCALAR} -# return f(Val(:mutual), h, yij, rho_g, eps_g, mu_g, jω) -# end - -function (f::Homogeneous)( - ::Val{:self}, - h::AbstractVector{T}, - yij::T, - rho_g::AbstractVector{T}, - eps_g::AbstractVector{T}, - mu_g::AbstractVector{T}, - jω::Complex{T}, -) where {T <: REALSCALAR} - return f(Val(:mutual), h, yij, rho_g, eps_g, mu_g, jω) -end - -@inline _not(s::Int) = - (s == 1 || s == 2) ? (3 - s) : - throw(ArgumentError("s must be 1 or 2")) - -@inline _get_layer(z) = - z > 0 ? 1 : - (z < 0 ? 2 : throw(ArgumentError("Conductor at interface (h=0) is invalid"))) - -@noinline function _layer_mismatch(which::AbstractString, got::Int, expected::Int) - throw( - ArgumentError( - "conductor $which is in layer $got but formulation expects layer $expected", - ), - ) -end - -@inline function validate_layers!(f::Homogeneous, h) - @boundscheck length(h) == 2 || throw(ArgumentError("h must have length 2")) - ℓ1 = _get_layer(h[1]) - ℓ2 = _get_layer(h[2]) - (ℓ1 == f.s) || _layer_mismatch("i (h[1])", ℓ1, f.s) - (ℓ2 == f.t) || _layer_mismatch("j (h[2])", ℓ2, f.t) - return nothing -end - -@inline function (f::Homogeneous)( - ::Val{:mutual}, - h::AbstractVector{T}, - yij::T, - rho_g::AbstractVector{T}, - eps_g::AbstractVector{T}, - mu_g::AbstractVector{T}, - jω::Complex{T}, -) where {T <: REALSCALAR} - - validate_layers!(f, h) - - s = f.s # index of source layer - o = _not(s) # the other layer - nL = length(rho_g) - μ = similar(mu_g); - σ = similar(rho_g); - @inbounds for i in 1:nL - μ[i] = (i == 1) ? mu_g[i] : f.μ2(mu_g[i]) # μ₂ for earth layers - σ[i] = _to_σ(rho_g[i]) - end - - # construct propagation constants according to formulation assumptions - γ = Vector{Complex{T}}(undef, nL) - @inbounds for i in 1:nL - γ[i] = (i == 1 ? f.γ1 : f.γ2)(jω, μ[i], σ[i], eps_g[i]) - end - γ_s = γ[s]; - γ_o = γ[o] - γs_2 = γ_s^2 - γo_2 = γ_o^2 - - # kx from struct: 0:none, 1:air, 2:source layer - kx_2 = if f.Γx == 0 # precalc squared - zero(γs_2) - else - ℓ = (f.Γx == 1) ? 1 : s - oftype(γs_2, (-jω^2) * μ[ℓ] * eps_g[ℓ]) - end - - σ̃ = σ[s] + jω*eps_g[s] # complex conductivity of source layer - - # unpack geometry - @inbounds hi, hj = abs(h[1]), abs(h[2]) - dij = hypot(yij, hi - hj) # √(y^2 + (hi - hj)^2) - conductor-conductor - Dij = hypot(yij, hi + hj) # √(y^2 + (hi + hj)^2) - conductor-image - - # perfectly conducting earth term in Bessel form - Λij = _bessel_diff(γ_s, dij, Dij) - - # --- Overhead special case --- - # physics: source in AIR (s=t=1), kx = 0, σ_air ≈ 0, - # earth propagation constant negligible γ_earth ≈ 0 - # ⇒ Sij = Tij = 0, Pe = (jω)/(2π(σ_air+jωε_air)) * Λ ≡ (1/(2π ε0)) * Λ - if f.s == 1 && f.Γx == 0 && isapprox(to_nominal(real(γ_o)), 0.0, atol = TOL) - return (jω/(2π*σ̃)) * Λij #(1/(2π*ε₀)) * Λij - end - - # --- Underground,"no displacement currents" --- - # physics: source in EARTH (s=t=2), kx = 0, γ_earth ≈ 0 - # ⇒ Pe = 0 - if f.s == 2 && f.t == 2 && isapprox(to_nominal(real(γ_s)), 0.0, atol = TOL) - return (jω/(2π*σ̃)) * Λij - end - - # precompute scalars for integrand - μ_s = μ[s] - μ_o = μ[o] - H = hi + hj - - # S_ij + T_ij in one go: 2∫₀^∞ (Fij+Gij) cos(yij λ) dλ - # integrand = (λ) -> (Fij(λ) + Gij(λ)) * cos(yij * λ) - @inline function integrand(λ::Float64)::Complex{T} - as = sqrt(λ*λ + γs_2 + kx_2) - ao = sqrt(λ*λ + γo_2 + kx_2) - - F = μ_o * exp(-as*H) / (as*μ_o + ao*μ_s) - - num = μ_o*μ_s*as*(γs_2 - γo_2)*exp(-as*H) - den = (as*μ_o + ao*μ_s) * (as*γo_2*μ_s + ao*γs_2*μ_o) - G = num/den - - (F + G) * cos(yij*λ) - end - - Iij, _ = quadgk( - integrand, - 0.0, - Inf; - rtol = 1e-8, - norm = z -> abs(complex(value(real(z)), value(imag(z)))), - ) - Iij *= 2 - - - return (jω / (2π * σ̃)) * (Λij + Iij) - -end +abstract type Homogeneous <: EarthAdmittanceFormulation end + +struct Kernel{Tγ1, Tγ2, Tμ2} + "Layer where the source conductor is placed." + s::Int + "Layer where the target conductor is placed." + t::Int + "Primary field propagation constant (0 = lossless, 1 = air, 2 = earth)." + Γx::Int + "Air propagation constant γ₁(jω, μ, σ, ε)." + γ1::Tγ1 + "Earth propagation constant γ₂(jω, μ, σ, ε)." + γ2::Tγ2 + "Earth magnetic-constant assumption μ₂(μ)." + μ2::Tμ2 +end + +struct Papadopoulos{Tγ1, Tγ2, Tμ2} <: Homogeneous + kernel::Kernel{Tγ1, Tγ2, Tμ2} +end + +Papadopoulos(; s::Int = 2, t::Int = 2, Γx::Int = 2, + γ1 = (jω, μ, σ, ε) -> sqrt(jω * μ * (σ + jω*ε)), + γ2 = (jω, μ, σ, ε) -> sqrt(jω * μ * (σ + jω*ε)), + μ2 = μ -> μ) = + Papadopoulos( + Kernel{typeof(γ1), typeof(γ2), typeof(μ2)}(s, t, Γx, γ1, γ2, μ2), + ) + +get_description(::Papadopoulos) = "Papadopoulos" +from_kernel(f::Papadopoulos) = f.kernel + + +struct Pollaczek{Tγ1, Tγ2, Tμ2} <: Homogeneous + kernel::Kernel{Tγ1, Tγ2, Tμ2} +end + +Pollaczek(; s::Int = 2, t::Int = 2, Γx::Int = 0, + γ1 = (jω, μ, σ, ε) -> jω * sqrt(μ * ε₀), + γ2 = (jω, μ, σ, ε) -> jω * sqrt(μ * ε₀), + μ2 = μ -> oftype(μ, μ₀)) = + Pollaczek( + Kernel{typeof(γ1), typeof(γ2), typeof(μ2)}(s, t, Γx, γ1, γ2, μ2), + ) + +get_description(::Pollaczek) = "Pollaczek" +from_kernel(f::Pollaczek) = f.kernel + +struct Images{Tγ1, Tγ2, Tμ2} <: Homogeneous + kernel::Kernel{Tγ1, Tγ2, Tμ2} +end + +Images(; s::Int = 1, t::Int = 1, Γx::Int = 0, + γ1 = (jω, μ, σ, ε) -> jω * sqrt(μ * ε₀), + γ2 = (jω, μ, σ, ε) -> jω * sqrt(μ * ε₀), + μ2 = μ -> oftype(μ, μ₀)) = + Images( + Kernel{typeof(γ1), typeof(γ2), typeof(μ2)}(s, t, Γx, γ1, γ2, μ2), + ) + +get_description(::Images) = "Electrostatic images" +from_kernel(f::Images) = f.kernel + +# ρ, ε, μ = ws.rho_g, ws.eps_g, ws.mu_g +# f(h, d, @view(ρ[:,k]), @view(ε[:,k]), @view(μ[:,k]), ws.jω[k]) + +# Functor implementation for all homogeneous earth impedance formulations. +function (f::Homogeneous)( + form::Symbol, + h::AbstractVector{T}, + yij::T, + rho_g::AbstractVector{T}, + eps_g::AbstractVector{T}, + mu_g::AbstractVector{T}, + jω::Complex{T}, +) where {T <: REALSCALAR} + Base.@nospecialize form + return form === :self ? f(Val(:self), h, yij, rho_g, eps_g, mu_g, jω) : + form === :mutual ? f(Val(:mutual), h, yij, rho_g, eps_g, mu_g, jω) : + throw(ArgumentError("Unknown earth admittance form: $form")) +end + +# function (f::Homogeneous)( +# h::AbstractVector{T}, +# yij::T, +# rho_g::AbstractVector{T}, +# eps_g::AbstractVector{T}, +# mu_g::AbstractVector{T}, +# jω::Complex{T}, +# ) where {T <: REALSCALAR} +# return f(Val(:mutual), h, yij, rho_g, eps_g, mu_g, jω) +# end + +function (f::Homogeneous)( + ::Val{:self}, + h::AbstractVector{T}, + yij::T, + rho_g::AbstractVector{T}, + eps_g::AbstractVector{T}, + mu_g::AbstractVector{T}, + jω::Complex{T}, +) where {T <: REALSCALAR} + return f(Val(:mutual), h, yij, rho_g, eps_g, mu_g, jω) +end + +@inline _not(s::Int) = + (s == 1 || s == 2) ? (3 - s) : + throw(ArgumentError("s must be 1 or 2")) + +@inline _get_layer(z) = + z > 0 ? 1 : + (z < 0 ? 2 : throw(ArgumentError("Conductor at interface (h=0) is invalid"))) + +@noinline function _layer_mismatch(which::AbstractString, got::Int, expected::Int) + throw( + ArgumentError( + "conductor $which is in layer $got but formulation expects layer $expected", + ), + ) +end + +@inline function validate_layers!(f::Homogeneous, h) + @boundscheck length(h) == 2 || throw(ArgumentError("h must have length 2")) + ℓ1 = _get_layer(h[1]) + ℓ2 = _get_layer(h[2]) + (ℓ1 == f.s) || _layer_mismatch("i (h[1])", ℓ1, f.s) + (ℓ2 == f.t) || _layer_mismatch("j (h[2])", ℓ2, f.t) + return nothing +end + +@inline function (f::Homogeneous)( + ::Val{:mutual}, + h::AbstractVector{T}, + yij::T, + rho_g::AbstractVector{T}, + eps_g::AbstractVector{T}, + mu_g::AbstractVector{T}, + jω::Complex{T}, +) where {T <: REALSCALAR} + + validate_layers!(f, h) + + s = f.s # index of source layer + o = _not(s) # the other layer + nL = length(rho_g) + μ = similar(mu_g); + σ = similar(rho_g); + @inbounds for i in 1:nL + μ[i] = (i == 1) ? mu_g[i] : f.μ2(mu_g[i]) # μ₂ for earth layers + σ[i] = _to_σ(rho_g[i]) + end + + # construct propagation constants according to formulation assumptions + γ = Vector{Complex{T}}(undef, nL) + @inbounds for i in 1:nL + γ[i] = (i == 1 ? f.γ1 : f.γ2)(jω, μ[i], σ[i], eps_g[i]) + end + γ_s = γ[s]; + γ_o = γ[o] + γs_2 = γ_s^2 + γo_2 = γ_o^2 + + # kx from struct: 0:none, 1:air, 2:source layer + kx_2 = if f.Γx == 0 # precalc squared + zero(γs_2) + else + ℓ = (f.Γx == 1) ? 1 : s + oftype(γs_2, (-jω^2) * μ[ℓ] * eps_g[ℓ]) + end + + σ̃ = σ[s] + jω*eps_g[s] # complex conductivity of source layer + + # unpack geometry + @inbounds hi, hj = abs(h[1]), abs(h[2]) + dij = hypot(yij, hi - hj) # √(y^2 + (hi - hj)^2) - conductor-conductor + Dij = hypot(yij, hi + hj) # √(y^2 + (hi + hj)^2) - conductor-image + + # perfectly conducting earth term in Bessel form + Λij = _bessel_diff(γ_s, dij, Dij) + + # --- Overhead special case --- + # physics: source in AIR (s=t=1), kx = 0, σ_air ≈ 0, + # earth propagation constant negligible γ_earth ≈ 0 + # ⇒ Sij = Tij = 0, Pe = (jω)/(2π(σ_air+jωε_air)) * Λ ≡ (1/(2π ε0)) * Λ + if f.s == 1 && f.Γx == 0 && isapprox(to_nominal(real(γ_o)), 0.0, atol = TOL) + return (jω/(2π*σ̃)) * Λij #(1/(2π*ε₀)) * Λij + end + + # --- Underground,"no displacement currents" --- + # physics: source in EARTH (s=t=2), kx = 0, γ_earth ≈ 0 + # ⇒ Pe = 0 + if f.s == 2 && f.t == 2 && isapprox(to_nominal(real(γ_s)), 0.0, atol = TOL) + return (jω/(2π*σ̃)) * Λij + end + + # precompute scalars for integrand + μ_s = μ[s] + μ_o = μ[o] + H = hi + hj + + # S_ij + T_ij in one go: 2∫₀^∞ (Fij+Gij) cos(yij λ) dλ + # integrand = (λ) -> (Fij(λ) + Gij(λ)) * cos(yij * λ) + @inline function integrand(λ::Float64)::Complex{T} + as = sqrt(λ*λ + γs_2 + kx_2) + ao = sqrt(λ*λ + γo_2 + kx_2) + + F = μ_o * exp(-as*H) / (as*μ_o + ao*μ_s) + + num = μ_o*μ_s*as*(γs_2 - γo_2)*exp(-as*H) + den = (as*μ_o + ao*μ_s) * (as*γo_2*μ_s + ao*γs_2*μ_o) + G = num/den + + (F + G) * cos(yij*λ) + end + + Iij, _ = quadgk( + integrand, + 0.0, + Inf; + rtol = 1e-8, + norm = z -> abs(complex(value(real(z)), value(imag(z)))), + ) + Iij *= 2 + + + return (jω / (2π * σ̃)) * (Λij + Iij) + +end diff --git a/src/engine/earthimpedance/EarthImpedance.jl b/src/engine/earthimpedance/EarthImpedance.jl index 098ca9e8..1568d4b1 100644 --- a/src/engine/earthimpedance/EarthImpedance.jl +++ b/src/engine/earthimpedance/EarthImpedance.jl @@ -1,28 +1,28 @@ -""" - LineCableModels.Engine.EarthImpedance - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module EarthImpedance - -# Export public API -export Papadopoulos - -# Module-specific dependencies -using ...Commons -import ...Commons: get_description -import ..Engine: EarthImpedanceFormulation -using Measurements: Measurement, value -using QuadGK: quadgk -using ...Utils: _to_σ, _bessel_diff, to_nominal - -include("homogeneous.jl") -include("base.jl") - -end # module EarthImpedance +""" + LineCableModels.Engine.EarthImpedance + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module EarthImpedance + +# Export public API +export Papadopoulos + +# Module-specific dependencies +using ...Commons +import ...Commons: get_description +import ..Engine: EarthImpedanceFormulation +using Measurements: Measurement, value +using QuadGK: quadgk +using ...Utils: _to_σ, _bessel_diff, to_nominal + +include("homogeneous.jl") +include("base.jl") + +end # module EarthImpedance diff --git a/src/engine/earthimpedance/base.jl b/src/engine/earthimpedance/base.jl index 3df1a4b4..cb461149 100644 --- a/src/engine/earthimpedance/base.jl +++ b/src/engine/earthimpedance/base.jl @@ -1,8 +1,8 @@ - -@inline function Base.getproperty(f::Homogeneous, name::Symbol) - if name === :s || name === :t || name === :Γx || name === :γ1 || name === :γ2 || - name === :μ2 - return getproperty(from_kernel(f), name) - end - return getfield(f, name) # subtype-specific fields (if any) + +@inline function Base.getproperty(f::Homogeneous, name::Symbol) + if name === :s || name === :t || name === :Γx || name === :γ1 || name === :γ2 || + name === :μ2 + return getproperty(from_kernel(f), name) + end + return getfield(f, name) # subtype-specific fields (if any) end \ No newline at end of file diff --git a/src/engine/earthimpedance/homogeneous.jl b/src/engine/earthimpedance/homogeneous.jl index c4caa662..f3106044 100644 --- a/src/engine/earthimpedance/homogeneous.jl +++ b/src/engine/earthimpedance/homogeneous.jl @@ -1,206 +1,206 @@ -abstract type Homogeneous <: EarthImpedanceFormulation end - -struct Kernel{Tγ1, Tγ2, Tμ2} - "Layer where the source conductor is placed." - s::Int - "Layer where the target conductor is placed." - t::Int - "Primary field propagation constant (0 = lossless, 1 = air, 2 = earth)." - Γx::Int - "Air propagation constant γ₁(jω, μ, σ, ε)." - γ1::Tγ1 - "Earth propagation constant γ₂(jω, μ, σ, ε)." - γ2::Tγ2 - "Earth magnetic-constant assumption μ₂(μ)." - μ2::Tμ2 -end - -struct Papadopoulos{Tγ1, Tγ2, Tμ2} <: Homogeneous - kernel::Kernel{Tγ1, Tγ2, Tμ2} -end - -Papadopoulos(; s::Int = 2, t::Int = 2, Γx::Int = 2, - γ1 = (jω, μ, σ, ε) -> sqrt(jω * μ * (σ + jω*ε)), - γ2 = (jω, μ, σ, ε) -> sqrt(jω * μ * (σ + jω*ε)), - μ2 = μ -> μ) = - Papadopoulos( - Kernel{typeof(γ1), typeof(γ2), typeof(μ2)}(s, t, Γx, γ1, γ2, μ2), - ) - -get_description(::Papadopoulos) = "Papadopoulos" -from_kernel(f::Papadopoulos) = f.kernel - - -struct Pollaczek{Tγ1, Tγ2, Tμ2} <: Homogeneous - kernel::Kernel{Tγ1, Tγ2, Tμ2} -end - -Pollaczek(; s::Int = 2, t::Int = 2, Γx::Int = 0, - γ1 = (jω, μ, σ, ε) -> jω * sqrt(μ * ε), - γ2 = (jω, μ, σ, ε) -> sqrt(jω * μ * σ), - μ2 = μ -> oftype(μ, μ₀)) = - Pollaczek( - Kernel{typeof(γ1), typeof(γ2), typeof(μ2)}(s, t, Γx, γ1, γ2, μ2), - ) - -get_description(::Pollaczek) = "Pollaczek" -from_kernel(f::Pollaczek) = f.kernel - -struct Carson{Tγ1, Tγ2, Tμ2} <: Homogeneous - kernel::Kernel{Tγ1, Tγ2, Tμ2} -end - -Carson(; s::Int = 1, t::Int = 1, Γx::Int = 0, - γ1 = (jω, μ, σ, ε) -> jω * sqrt(μ * ε), - γ2 = (jω, μ, σ, ε) -> sqrt(jω * μ * σ), - μ2 = μ -> oftype(μ, μ₀)) = - Carson( - Kernel{typeof(γ1), typeof(γ2), typeof(μ2)}(s, t, Γx, γ1, γ2, μ2), - ) - -get_description(::Carson) = "Carson" -from_kernel(f::Carson) = f.kernel - - -# ρ, ε, μ = ws.rho_g, ws.eps_g, ws.mu_g -# f(h, d, @view(ρ[:,k]), @view(ε[:,k]), @view(μ[:,k]), ws.freq[k]) - -# Functor implementation for all homogeneous earth impedance formulations. -function (f::Homogeneous)( - form::Symbol, - h::AbstractVector{T}, - yij::T, - rho_g::AbstractVector{T}, - eps_g::AbstractVector{T}, - mu_g::AbstractVector{T}, - jω::Complex{T}, -) where {T <: REALSCALAR} - Base.@nospecialize form - return form === :self ? f(Val(:self), h, yij, rho_g, eps_g, mu_g, jω) : - form === :mutual ? f(Val(:mutual), h, yij, rho_g, eps_g, mu_g, jω) : - throw(ArgumentError("Unknown earth impedance form: $form")) -end - -# function (f::Homogeneous)( -# h::AbstractVector{T}, -# yij::T, -# rho_g::AbstractVector{T}, -# eps_g::AbstractVector{T}, -# mu_g::AbstractVector{T}, -# jω::Complex{T}, -# ) where {T <: REALSCALAR} -# return f(Val(:mutual), h, yij, rho_g, eps_g, mu_g, jω) -# end - -function (f::Homogeneous)( - ::Val{:self}, - h::AbstractVector{T}, - yij::T, - rho_g::AbstractVector{T}, - eps_g::AbstractVector{T}, - mu_g::AbstractVector{T}, - jω::Complex{T}, -) where {T <: REALSCALAR} - return f(Val(:mutual), h, yij, rho_g, eps_g, mu_g, jω) -end - -@inline _not(s::Int) = - (s == 1 || s == 2) ? (3 - s) : - throw(ArgumentError("s must be 1 or 2")) - -@inline _get_layer(z) = - z > 0 ? 1 : - (z < 0 ? 2 : throw(ArgumentError("Conductor at interface (h=0) is invalid"))) - -@noinline function _layer_mismatch(which::AbstractString, got::Int, expected::Int) - throw( - ArgumentError( - "conductor $which is in layer $got but formulation expects layer $expected", - ), - ) -end - -@inline function validate_layers!(f::Homogeneous, h) - @boundscheck length(h) == 2 || throw(ArgumentError("h must have length 2")) - ℓ1 = _get_layer(h[1]) - ℓ2 = _get_layer(h[2]) - (ℓ1 == f.s) || _layer_mismatch("i (h[1])", ℓ1, f.s) - (ℓ2 == f.t) || _layer_mismatch("j (h[2])", ℓ2, f.t) - return nothing -end - -@inline function (f::Homogeneous)( - ::Val{:mutual}, - h::AbstractVector{T}, - yij::T, - rho_g::AbstractVector{T}, - eps_g::AbstractVector{T}, - mu_g::AbstractVector{T}, - jω::Complex{T}, -) where {T <: REALSCALAR} - - validate_layers!(f, h) - - s = f.s # index of source layer - o = _not(s) # the other layer - nL = length(rho_g) - μ = similar(mu_g); - σ = similar(rho_g); - @inbounds for i in 1:nL - μ[i] = (i == 1) ? mu_g[i] : f.μ2(mu_g[i]) # μ₂ for earth layers - σ[i] = _to_σ(rho_g[i]) - end - - # construct propagation constants according to formulation assumptions - γ = Vector{Complex{T}}(undef, nL) - @inbounds for i in 1:nL - γ[i] = (i == 1 ? f.γ1 : f.γ2)(jω, μ[i], σ[i], eps_g[i]) - end - γ_s = γ[s]; - γ_o = γ[o] - γs_2 = γ_s^2 - γo_2 = γ_o^2 - - # kx from struct: 0:none, 1:air, 2:source layer - kx_2 = if f.Γx == 0 # precalc squared - zero(γs_2) - else - ℓ = (f.Γx == 1) ? 1 : s - oftype(γs_2, (-jω^2) * μ[ℓ] * eps_g[ℓ]) - end - - # unpack geometry - @inbounds hi, hj = abs(h[1]), abs(h[2]) - dij = hypot(yij, hi - hj) # √(y^2 + (hi - hj)^2) - conductor-conductor - Dij = hypot(yij, hi + hj) # √(y^2 + (hi + hj)^2) - conductor-image - - # perfectly conducting earth term in Bessel form - Λij = _bessel_diff(γ_s, dij, Dij) - - # precompute scalars for integrand - μ_s = μ[s] - μ_o = μ[o] - H = hi + hj - - # Sij = 2 ∫_0^∞ Fij(λ) cos(yij λ) dλ - # integrand = (λ) -> Fij(λ) * cos(yij * λ) - @inline function integrand(λ::Float64)::Complex{T} - as = sqrt(λ*λ + γs_2 + kx_2) - ao = sqrt(λ*λ + γo_2 + kx_2) - - F = μ_o * exp(-as*H) / (as*μ_o + ao*μ_s) - - F * cos(yij*λ) - end - - Sij, _ = quadgk( - integrand, - 0.0, - 1.0; - rtol = 1e-8, - norm = z -> abs(complex(value(real(z)), value(imag(z)))), - ) - Sij *= 2 - - return (jω * μ_s / (2π)) * (Λij + Sij) -end +abstract type Homogeneous <: EarthImpedanceFormulation end + +struct Kernel{Tγ1, Tγ2, Tμ2} + "Layer where the source conductor is placed." + s::Int + "Layer where the target conductor is placed." + t::Int + "Primary field propagation constant (0 = lossless, 1 = air, 2 = earth)." + Γx::Int + "Air propagation constant γ₁(jω, μ, σ, ε)." + γ1::Tγ1 + "Earth propagation constant γ₂(jω, μ, σ, ε)." + γ2::Tγ2 + "Earth magnetic-constant assumption μ₂(μ)." + μ2::Tμ2 +end + +struct Papadopoulos{Tγ1, Tγ2, Tμ2} <: Homogeneous + kernel::Kernel{Tγ1, Tγ2, Tμ2} +end + +Papadopoulos(; s::Int = 2, t::Int = 2, Γx::Int = 2, + γ1 = (jω, μ, σ, ε) -> sqrt(jω * μ * (σ + jω*ε)), + γ2 = (jω, μ, σ, ε) -> sqrt(jω * μ * (σ + jω*ε)), + μ2 = μ -> μ) = + Papadopoulos( + Kernel{typeof(γ1), typeof(γ2), typeof(μ2)}(s, t, Γx, γ1, γ2, μ2), + ) + +get_description(::Papadopoulos) = "Papadopoulos" +from_kernel(f::Papadopoulos) = f.kernel + + +struct Pollaczek{Tγ1, Tγ2, Tμ2} <: Homogeneous + kernel::Kernel{Tγ1, Tγ2, Tμ2} +end + +Pollaczek(; s::Int = 2, t::Int = 2, Γx::Int = 0, + γ1 = (jω, μ, σ, ε) -> jω * sqrt(μ * ε), + γ2 = (jω, μ, σ, ε) -> sqrt(jω * μ * σ), + μ2 = μ -> oftype(μ, μ₀)) = + Pollaczek( + Kernel{typeof(γ1), typeof(γ2), typeof(μ2)}(s, t, Γx, γ1, γ2, μ2), + ) + +get_description(::Pollaczek) = "Pollaczek" +from_kernel(f::Pollaczek) = f.kernel + +struct Carson{Tγ1, Tγ2, Tμ2} <: Homogeneous + kernel::Kernel{Tγ1, Tγ2, Tμ2} +end + +Carson(; s::Int = 1, t::Int = 1, Γx::Int = 0, + γ1 = (jω, μ, σ, ε) -> jω * sqrt(μ * ε), + γ2 = (jω, μ, σ, ε) -> sqrt(jω * μ * σ), + μ2 = μ -> oftype(μ, μ₀)) = + Carson( + Kernel{typeof(γ1), typeof(γ2), typeof(μ2)}(s, t, Γx, γ1, γ2, μ2), + ) + +get_description(::Carson) = "Carson" +from_kernel(f::Carson) = f.kernel + + +# ρ, ε, μ = ws.rho_g, ws.eps_g, ws.mu_g +# f(h, d, @view(ρ[:,k]), @view(ε[:,k]), @view(μ[:,k]), ws.freq[k]) + +# Functor implementation for all homogeneous earth impedance formulations. +function (f::Homogeneous)( + form::Symbol, + h::AbstractVector{T}, + yij::T, + rho_g::AbstractVector{T}, + eps_g::AbstractVector{T}, + mu_g::AbstractVector{T}, + jω::Complex{T}, +) where {T <: REALSCALAR} + Base.@nospecialize form + return form === :self ? f(Val(:self), h, yij, rho_g, eps_g, mu_g, jω) : + form === :mutual ? f(Val(:mutual), h, yij, rho_g, eps_g, mu_g, jω) : + throw(ArgumentError("Unknown earth impedance form: $form")) +end + +# function (f::Homogeneous)( +# h::AbstractVector{T}, +# yij::T, +# rho_g::AbstractVector{T}, +# eps_g::AbstractVector{T}, +# mu_g::AbstractVector{T}, +# jω::Complex{T}, +# ) where {T <: REALSCALAR} +# return f(Val(:mutual), h, yij, rho_g, eps_g, mu_g, jω) +# end + +function (f::Homogeneous)( + ::Val{:self}, + h::AbstractVector{T}, + yij::T, + rho_g::AbstractVector{T}, + eps_g::AbstractVector{T}, + mu_g::AbstractVector{T}, + jω::Complex{T}, +) where {T <: REALSCALAR} + return f(Val(:mutual), h, yij, rho_g, eps_g, mu_g, jω) +end + +@inline _not(s::Int) = + (s == 1 || s == 2) ? (3 - s) : + throw(ArgumentError("s must be 1 or 2")) + +@inline _get_layer(z) = + z > 0 ? 1 : + (z < 0 ? 2 : throw(ArgumentError("Conductor at interface (h=0) is invalid"))) + +@noinline function _layer_mismatch(which::AbstractString, got::Int, expected::Int) + throw( + ArgumentError( + "conductor $which is in layer $got but formulation expects layer $expected", + ), + ) +end + +@inline function validate_layers!(f::Homogeneous, h) + @boundscheck length(h) == 2 || throw(ArgumentError("h must have length 2")) + ℓ1 = _get_layer(h[1]) + ℓ2 = _get_layer(h[2]) + (ℓ1 == f.s) || _layer_mismatch("i (h[1])", ℓ1, f.s) + (ℓ2 == f.t) || _layer_mismatch("j (h[2])", ℓ2, f.t) + return nothing +end + +@inline function (f::Homogeneous)( + ::Val{:mutual}, + h::AbstractVector{T}, + yij::T, + rho_g::AbstractVector{T}, + eps_g::AbstractVector{T}, + mu_g::AbstractVector{T}, + jω::Complex{T}, +) where {T <: REALSCALAR} + + validate_layers!(f, h) + + s = f.s # index of source layer + o = _not(s) # the other layer + nL = length(rho_g) + μ = similar(mu_g); + σ = similar(rho_g); + @inbounds for i in 1:nL + μ[i] = (i == 1) ? mu_g[i] : f.μ2(mu_g[i]) # μ₂ for earth layers + σ[i] = _to_σ(rho_g[i]) + end + + # construct propagation constants according to formulation assumptions + γ = Vector{Complex{T}}(undef, nL) + @inbounds for i in 1:nL + γ[i] = (i == 1 ? f.γ1 : f.γ2)(jω, μ[i], σ[i], eps_g[i]) + end + γ_s = γ[s]; + γ_o = γ[o] + γs_2 = γ_s^2 + γo_2 = γ_o^2 + + # kx from struct: 0:none, 1:air, 2:source layer + kx_2 = if f.Γx == 0 # precalc squared + zero(γs_2) + else + ℓ = (f.Γx == 1) ? 1 : s + oftype(γs_2, (-jω^2) * μ[ℓ] * eps_g[ℓ]) + end + + # unpack geometry + @inbounds hi, hj = abs(h[1]), abs(h[2]) + dij = hypot(yij, hi - hj) # √(y^2 + (hi - hj)^2) - conductor-conductor + Dij = hypot(yij, hi + hj) # √(y^2 + (hi + hj)^2) - conductor-image + + # perfectly conducting earth term in Bessel form + Λij = _bessel_diff(γ_s, dij, Dij) + + # precompute scalars for integrand + μ_s = μ[s] + μ_o = μ[o] + H = hi + hj + + # Sij = 2 ∫_0^∞ Fij(λ) cos(yij λ) dλ + # integrand = (λ) -> Fij(λ) * cos(yij * λ) + @inline function integrand(λ::Float64)::Complex{T} + as = sqrt(λ*λ + γs_2 + kx_2) + ao = sqrt(λ*λ + γo_2 + kx_2) + + F = μ_o * exp(-as*H) / (as*μ_o + ao*μ_s) + + F * cos(yij*λ) + end + + Sij, _ = quadgk( + integrand, + 0.0, + 1.0; + rtol = 1e-8, + norm = z -> abs(complex(value(real(z)), value(imag(z)))), + ) + Sij *= 2 + + return (jω * μ_s / (2π)) * (Λij + Sij) +end diff --git a/src/engine/ehem/EHEM.jl b/src/engine/ehem/EHEM.jl index bf63e9c8..bccce1cc 100644 --- a/src/engine/ehem/EHEM.jl +++ b/src/engine/ehem/EHEM.jl @@ -1,26 +1,26 @@ -""" - LineCableModels.Engine.EHEM - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module EHEM - -# Export public API -export EnforceLayer - -# Module-specific dependencies -using ...Commons -using ...EarthProps: EarthModel -import ...Commons: get_description -import ..Engine: AbstractEHEMFormulation -using Measurements - -include("enforcelayer.jl") - +""" + LineCableModels.Engine.EHEM + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module EHEM + +# Export public API +export EnforceLayer + +# Module-specific dependencies +using ...Commons +using ...EarthProps: EarthModel +import ...Commons: get_description +import ..Engine: AbstractEHEMFormulation +using Measurements + +include("enforcelayer.jl") + end # module EHEM \ No newline at end of file diff --git a/src/engine/ehem/enforcelayer.jl b/src/engine/ehem/enforcelayer.jl index 86da5f68..176d9781 100644 --- a/src/engine/ehem/enforcelayer.jl +++ b/src/engine/ehem/enforcelayer.jl @@ -1,136 +1,136 @@ -""" -$(TYPEDEF) - -An EHEM formulation that creates a homogeneous earth model by enforcing the properties of a single, specified layer from a multi-layer model. - -# Attributes -$(TYPEDFIELDS) -""" -struct EnforceLayer <: AbstractEHEMFormulation - "Index of the earth layer to enforce. `-1` selects the bottommost layer." - layer::Int - - @doc """ - $(TYPEDSIGNATURES) - - Constructs an `EnforceLayer` instance. - - # Arguments - - `layer::Int`: The index of the layer to enforce. - - `-1` (default): Enforces the properties of the bottommost earth layer. - - `2`: Enforces the properties of the topmost earth layer (the one directly below the air). - - `> 2`: Enforces the properties of a specific layer by its index. - """ - function EnforceLayer(; layer::Int = -1) - @assert (layer == -1 || layer >= 2) "Invalid layer index. Must be -1 (bottommost) or >= 2." - new(layer) - end -end - -function get_description(f::EnforceLayer) - if f.layer == -1 - return "Assume bottom layer" - elseif f.layer == 2 - return "Assume top earth layer" - else - return "Assume layer $(f.layer)" - end -end - -""" -$(TYPEDSIGNATURES) - -Functor implementation for `EnforceLayer`. - -Builds a 2-layer (air + one enforced earth layer) data pack as three matrices -ρ, ε, μ of size (2 × n_freq), already converted to `T`. - -# Returns -- `(ρ, ε, μ) :: (Matrix{T}, Matrix{T}, Matrix{T})` - with row 1 = air, row 2 = enforced earth layer. -""" -function (f::EnforceLayer)( - model::EarthModel, - freq::AbstractVector{<:REALSCALAR}, - ::Type{T}, -) where {T <: REALSCALAR} - - nL = length(model.layers) - nF = length(freq) - - layer_idx = f.layer == -1 ? nL : f.layer - (2 <= layer_idx <= nL) || error( - "Invalid layer index: $layer_idx. Model has $nL layers (including air). " * - "Valid earth layer indices are 2:$nL.", - ) - - Lair = model.layers[1] - Lsel = model.layers[layer_idx] - - ρ = Matrix{T}(undef, 2, nF) - ε = similar(ρ) - μ = similar(ρ) - - @inbounds for j in 1:nF - ρ[1, j] = T(Lair.rho_g[j]) - ε[1, j] = T(Lair.eps_g[j]) - μ[1, j] = T(Lair.mu_g[j]) - - ρ[2, j] = T(Lsel.rho_g[j]) - ε[2, j] = T(Lsel.eps_g[j]) - μ[2, j] = T(Lsel.mu_g[j]) - end - - return ρ, ε, μ -end - -# """ -# $(TYPEDSIGNATURES) - -# Functor implementation for `EnforceLayer`. - -# Takes a multi-layer `EarthModel` and returns a new two-layer model (air + one effective earth layer) based on the properties of the layer specified in the `EnforceLayer` instance. - -# # Returns -# - A `Vector{EarthLayer}` containing two layers: the original air layer and the selected earth layer. -# """ -# function (f::EnforceLayer)( -# model::EarthModel, -# freq::Vector{<:REALSCALAR}, -# T::DataType, -# ) -# num_layers = length(model.layers) - -# # Determine the index of the layer to select -# layer_idx = f.layer == -1 ? num_layers : f.layer - -# # Validate the chosen index -# if !(2 <= layer_idx <= num_layers) -# Base.error( -# "Invalid layer index: $layer_idx. The model only has $num_layers layers (including air). Valid earth layer indices are from 2 to $num_layers.", -# ) -# end - -# # The air layer is always the first layer in the original model -# air_layer = model.layers[1] - -# # The enforced earth layer is the one at the selected index -# enforced_layer = model.layers[layer_idx] - -# # Create a NamedTuple for the air layer with type-promoted property vectors -# air_data = ( -# rho_g = T.(air_layer.rho_g), -# eps_g = T.(air_layer.eps_g), -# mu_g = T.(air_layer.mu_g), -# ) - -# # Create a NamedTuple for the enforced earth layer -# earth_data = ( -# rho_g = T.(enforced_layer.rho_g), -# eps_g = T.(enforced_layer.eps_g), -# mu_g = T.(enforced_layer.mu_g), -# ) - -# # Return a new vector containing only these two layers -# return [air_data, earth_data] +""" +$(TYPEDEF) + +An EHEM formulation that creates a homogeneous earth model by enforcing the properties of a single, specified layer from a multi-layer model. + +# Attributes +$(TYPEDFIELDS) +""" +struct EnforceLayer <: AbstractEHEMFormulation + "Index of the earth layer to enforce. `-1` selects the bottommost layer." + layer::Int + + @doc """ + $(TYPEDSIGNATURES) + + Constructs an `EnforceLayer` instance. + + # Arguments + - `layer::Int`: The index of the layer to enforce. + - `-1` (default): Enforces the properties of the bottommost earth layer. + - `2`: Enforces the properties of the topmost earth layer (the one directly below the air). + - `> 2`: Enforces the properties of a specific layer by its index. + """ + function EnforceLayer(; layer::Int = -1) + @assert (layer == -1 || layer >= 2) "Invalid layer index. Must be -1 (bottommost) or >= 2." + new(layer) + end +end + +function get_description(f::EnforceLayer) + if f.layer == -1 + return "Assume bottom layer" + elseif f.layer == 2 + return "Assume top earth layer" + else + return "Assume layer $(f.layer)" + end +end + +""" +$(TYPEDSIGNATURES) + +Functor implementation for `EnforceLayer`. + +Builds a 2-layer (air + one enforced earth layer) data pack as three matrices +ρ, ε, μ of size (2 × n_freq), already converted to `T`. + +# Returns +- `(ρ, ε, μ) :: (Matrix{T}, Matrix{T}, Matrix{T})` + with row 1 = air, row 2 = enforced earth layer. +""" +function (f::EnforceLayer)( + model::EarthModel, + freq::AbstractVector{<:REALSCALAR}, + ::Type{T}, +) where {T <: REALSCALAR} + + nL = length(model.layers) + nF = length(freq) + + layer_idx = f.layer == -1 ? nL : f.layer + (2 <= layer_idx <= nL) || error( + "Invalid layer index: $layer_idx. Model has $nL layers (including air). " * + "Valid earth layer indices are 2:$nL.", + ) + + Lair = model.layers[1] + Lsel = model.layers[layer_idx] + + ρ = Matrix{T}(undef, 2, nF) + ε = similar(ρ) + μ = similar(ρ) + + @inbounds for j in 1:nF + ρ[1, j] = T(Lair.rho_g[j]) + ε[1, j] = T(Lair.eps_g[j]) + μ[1, j] = T(Lair.mu_g[j]) + + ρ[2, j] = T(Lsel.rho_g[j]) + ε[2, j] = T(Lsel.eps_g[j]) + μ[2, j] = T(Lsel.mu_g[j]) + end + + return ρ, ε, μ +end + +# """ +# $(TYPEDSIGNATURES) + +# Functor implementation for `EnforceLayer`. + +# Takes a multi-layer `EarthModel` and returns a new two-layer model (air + one effective earth layer) based on the properties of the layer specified in the `EnforceLayer` instance. + +# # Returns +# - A `Vector{EarthLayer}` containing two layers: the original air layer and the selected earth layer. +# """ +# function (f::EnforceLayer)( +# model::EarthModel, +# freq::Vector{<:REALSCALAR}, +# T::DataType, +# ) +# num_layers = length(model.layers) + +# # Determine the index of the layer to select +# layer_idx = f.layer == -1 ? num_layers : f.layer + +# # Validate the chosen index +# if !(2 <= layer_idx <= num_layers) +# Base.error( +# "Invalid layer index: $layer_idx. The model only has $num_layers layers (including air). Valid earth layer indices are from 2 to $num_layers.", +# ) +# end + +# # The air layer is always the first layer in the original model +# air_layer = model.layers[1] + +# # The enforced earth layer is the one at the selected index +# enforced_layer = model.layers[layer_idx] + +# # Create a NamedTuple for the air layer with type-promoted property vectors +# air_data = ( +# rho_g = T.(air_layer.rho_g), +# eps_g = T.(air_layer.eps_g), +# mu_g = T.(air_layer.mu_g), +# ) + +# # Create a NamedTuple for the enforced earth layer +# earth_data = ( +# rho_g = T.(enforced_layer.rho_g), +# eps_g = T.(enforced_layer.eps_g), +# mu_g = T.(enforced_layer.mu_g), +# ) + +# # Return a new vector containing only these two layers +# return [air_data, earth_data] # end \ No newline at end of file diff --git a/src/engine/fem/FEM.jl b/src/engine/fem/FEM.jl index 5759a593..c7ed7d22 100644 --- a/src/engine/fem/FEM.jl +++ b/src/engine/fem/FEM.jl @@ -1,72 +1,72 @@ -""" - LineCableModels.Engine.FEM - -The [`FEM`](@ref) module provides functionality for generating geometric meshes for cable cross-sections, assigning physical properties, and preparing the system for electromagnetic simulation within the [`LineCableModels.jl`](index.md) package. - -# Overview - -- Defines core types [`FEMFormulation`](@ref), and [`FEMWorkspace`](@ref) for managing simulation parameters and state. -- Implements a physical tag encoding system (CCOGYYYYY scheme for cable components, EPFXXXXX for domain regions). -- Provides primitive drawing functions for geometric elements. -- Creates a two-phase workflow: creation → fragmentation → identification. -- Maintains all state in a structured [`FEMWorkspace`](@ref) object. - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module FEM - -# Export public API -export MeshTransition, calc_domain_size -export compute!, preview_results -export FormulationSet, Electrodynamics, Darwin - -# Module-specific dependencies -using ...Commons -using ...Materials -using ...EarthProps -using ...DataModel -using ...Engine -import ...Engine: kronify, reorder_M, reorder_indices, merge_bundles!, FormulationSet, - AbstractFormulationSet, AbstractImpedanceFormulation, AbstractAdmittanceFormulation, - compute! -import ...Engine: AbstractFormulationOptions, LineParamOptions, build_options, _COMMON_SYMS -import ...DataModel: AbstractCablePart, AbstractConductorPart, AbstractInsulatorPart -using ...Utils: - display_path, set_verbosity!, is_headless, to_nominal, symtrans!, symtrans, - line_transpose! -using Measurements -using LinearAlgebra -using Colors - -# FEM specific dependencies -using Gmsh -using GetDP -using GetDP: Problem, get_getdp_executable, add! - - -include("types.jl") -include("lineparamopts.jl") # Line parameter options - -# Include auxiliary files -include("meshtransitions.jl") # Mesh transition objects -include("problemdefs.jl") # Problem definitions -include("workspace.jl") # Workspace functions -include("encoding.jl") # Tag encoding schemes -include("drawing.jl") # Primitive drawing functions -include("identification.jl") # Entity identification -include("mesh.jl") # Mesh generation -include("materialprops.jl") # Material handling -include("helpers.jl") # Various utilities -include("visualization.jl") # Visualization functions -include("space.jl") # Domain creation functions -include("cable.jl") # Cable geometry creation functions -include("solver.jl") # Solver functions -include("base.jl") # Base namespace extensions - -end # module FEM +""" + LineCableModels.Engine.FEM + +The [`FEM`](@ref) module provides functionality for generating geometric meshes for cable cross-sections, assigning physical properties, and preparing the system for electromagnetic simulation within the [`LineCableModels.jl`](index.md) package. + +# Overview + +- Defines core types [`FEMFormulation`](@ref), and [`FEMWorkspace`](@ref) for managing simulation parameters and state. +- Implements a physical tag encoding system (CCOGYYYYY scheme for cable components, EPFXXXXX for domain regions). +- Provides primitive drawing functions for geometric elements. +- Creates a two-phase workflow: creation → fragmentation → identification. +- Maintains all state in a structured [`FEMWorkspace`](@ref) object. + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module FEM + +# Export public API +export MeshTransition, calc_domain_size +export compute!, preview_results +export FormulationSet, Electrodynamics, Darwin, MagnetoThermal + +# Module-specific dependencies +using ...Commons +using ...Materials +using ...EarthProps +using ...DataModel +using ...Engine +import ...Engine: kronify, reorder_M, reorder_indices, merge_bundles!, FormulationSet, + AbstractFormulationSet, AbstractImpedanceFormulation, AbstractAdmittanceFormulation, + compute!, AmpacityFormulation +import ...Engine: AbstractFormulationOptions, LineParamOptions, build_options, _COMMON_SYMS +import ...DataModel: AbstractCablePart, AbstractConductorPart, AbstractInsulatorPart +using ...Utils: + display_path, set_verbosity!, is_headless, to_nominal, symtrans!, symtrans, + line_transpose! +using Measurements +using LinearAlgebra +using Colors + +# FEM specific dependencies +using Gmsh +using GetDP +using GetDP: Problem, get_getdp_executable, add! + + +include("types.jl") +include("lineparamopts.jl") # Line parameter options + +# Include auxiliary files +include("meshtransitions.jl") # Mesh transition objects +include("problemdefs.jl") # Problem definitions +include("workspace.jl") # Workspace functions +include("encoding.jl") # Tag encoding schemes +include("drawing.jl") # Primitive drawing functions +include("identification.jl") # Entity identification +include("mesh.jl") # Mesh generation +include("materialprops.jl") # Material handling +include("helpers.jl") # Various utilities +include("visualization.jl") # Visualization functions +include("space.jl") # Domain creation functions +include("cable.jl") # Cable geometry creation functions +include("solver.jl") # Solver functions +include("base.jl") # Base namespace extensions + +end # module FEM diff --git a/src/engine/fem/ampacityproblem/helpers.jl b/src/engine/fem/ampacityproblem/helpers.jl new file mode 100644 index 00000000..feb2ae5a --- /dev/null +++ b/src/engine/fem/ampacityproblem/helpers.jl @@ -0,0 +1,140 @@ +# """ +# Utility functions for the FEMTools.jl module. +# These functions provide various utilities for file management, logging, etc. +# """ + + +# """ +# $(TYPEDSIGNATURES) + +# Set up directory structure and file paths for a FEM simulation. + +# # Arguments + +# - `solver`: The [`FEMSolver`](@ref) containing the base path. +# - `cable_system`: The [`LineCableSystem`](@ref) containing the case ID. + +# # Returns + +# - A dictionary of paths for the simulation. + +# # Examples + +# ```julia +# paths = $(FUNCTIONNAME)(solver, cable_system) +# ``` +# """ +# function setup_paths(cable_system::LineCableSystem, formulation::ElectroThermalFEMFormulation) + +# opts = formulation.options +# # Create base output directory if it doesn't exist +# if !isdir(opts.save_path) +# mkpath(opts.save_path) +# @info "Created base output directory: $(display_path(opts.save_path))" +# end + +# # Set up case-specific paths +# case_id = cable_system.system_id +# case_dir = joinpath(opts.save_path, case_id) + +# # Create case directory if needed +# if !isdir(case_dir) && (opts.force_remesh || opts.mesh_only) +# mkpath(case_dir) +# @info "Created case directory: $(display_path(case_dir))" +# end + +# # Create results directory path +# results_dir = joinpath(case_dir, "results") + +# # Define key file paths +# mesh_file = joinpath(case_dir, "$(case_id).msh") +# geo_file = joinpath(case_dir, "$(case_id).geo_unrolled") +# # data_file = joinpath(case_dir, "$(case_id)_data.geo") +# multiphysics_file = String[] +# for prob in formulation.analysis_type +# push!(multiphysics_file, joinpath(case_dir, "$(case_id)_$(prob.resolution_name).pro")) +# end + + +# # Return compiled dictionary of paths +# paths = Dict{Symbol,Any}( +# :base_dir => opts.save_path, +# :case_dir => case_dir, +# :results_dir => results_dir, +# :mesh_file => mesh_file, +# :geo_file => geo_file, +# :multiphysics_file => multiphysics_file +# ) + +# @debug "Paths configured: $(join(["$(k): $(v)" for (k,v) in paths], ", "))" + +# return paths +# end + + +# # function read_results_file( +# # fem_formulation::Union{AbstractImpedanceFormulation,AbstractAdmittanceFormulation}, +# # workspace::FEMWorkspace; +# # file::Union{String,Nothing}=nothing, +# # ) + +# # results_path = +# # joinpath(workspace.paths[:results_dir], lowercase(fem_formulation.resolution_name)) + +# # if isnothing(file) +# # file = +# # fem_formulation isa AbstractImpedanceFormulation ? "Z.dat" : +# # fem_formulation isa AbstractAdmittanceFormulation ? "Y.dat" : +# # throw(ArgumentError("Invalid formulation type: $(typeof(fem_formulation))")) +# # end + +# # filepath = joinpath(results_path, file) + +# # isfile(filepath) || Base.error("File not found: $filepath") + +# # # Read all lines from file +# # lines = readlines(filepath) +# # n_rows = +# # sum([length(c.design_data.components) for c in workspace.core.system.cables]) + +# # # Pre-allocate result matrix +# # matrix = zeros(ComplexF64, n_rows, n_rows) + +# # # Process each line (matrix row) +# # for (i, line) in enumerate(lines) +# # # Parse all numbers, dropping the initial 0 +# # values = parse.(Float64, split(line))[2:end] + +# # # Fill matrix row with complex values +# # for j in 1:n_rows +# # idx = 2j - 1 # Index for real part +# # matrix[i, j] = Complex(values[idx], values[idx+1]) +# # end +# # end + +# # return matrix +# # end + +# function archive_frequency_results(workspace::ElectroThermalFEMWorkspace, frequency::Float64) +# try +# results_dir = workspace.paths[:results_dir] +# freq_dir = +# joinpath(dirname(results_dir), "results_f=$(round(frequency, sigdigits=6))") + +# if isdir(results_dir) +# mv(results_dir, freq_dir, force=true) +# @debug "Archived results for f=$frequency Hz" +# end + +# # Move solver files +# for ext in [".res", ".pre"] +# case_files = filter(f -> endswith(f, ext), +# readdir(workspace.paths[:case_dir], join=true)) +# for f in case_files +# mv(f, joinpath(freq_dir, basename(f)), force=true) +# end +# end +# catch e +# @warn "Failed to archive results for frequency $frequency Hz" exception = e +# end +# end diff --git a/src/engine/fem/ampacityproblem/problemdefs.jl b/src/engine/fem/ampacityproblem/problemdefs.jl new file mode 100644 index 00000000..9839d399 --- /dev/null +++ b/src/engine/fem/ampacityproblem/problemdefs.jl @@ -0,0 +1,184 @@ +# """ +# $(TYPEDEF) + +# Represents an Magneto-thermal computation problem for a given physical cable system +# and a specific energization. + +# $(TYPEDFIELDS) +# """ +# struct AmpacityProblem{T <: REALSCALAR} <: ProblemDefinition +# "The physical cable system to analyze." +# system::LineCableSystem{T} +# "Ambient operating temperature [°C]." +# temperature::T +# "Earth properties model." +# earth_props::EarthModel{T} +# "Vector of frequencies at which the analysis is performed [Hz]." +# frequencies::Vector{T} +# "Vector of energizing currents [A]. Index corresponds to phase number." +# energizations::Vector{Complex{T}} +# "Velocity of the ambient wind [m/s]." +# wind_velocity::T + +# @doc """ +# $(TYPEDSIGNATURES) + +# Constructs an [`AmpacityProblem`](@ref) instance. + +# # Arguments + +# - `system`: The cable system to analyze ([`LineCableSystem`](@ref)). +# - `temperature`: Ambient temperature [°C]. Default: `T₀`. +# - `earth_props`: Earth properties model ([`EarthModel`](@ref)). +# - `frequencies`: Vector of frequency for analysis [Hz]. Default: [`f₀`](@ref). +# - `energizations`: Vector of phase currents [A]. Must match `system.num_phases`. + +# # Returns + +# - An [`AmpacityProblem`](@ref) object with validated inputs. + +# # Examples + +# ```julia +# prob = $(FUNCTIONNAME)(system; +# temperature=25.0, +# earth_props=earth, +# frequencies=[60.0], +# energizations=[100.0 + 0im, 100.0 * cis(-2pi/3), 100.0 * cis(2pi/3)] +# ) +# ``` +# """ +# function AmpacityProblem( +# system::LineCableSystem; +# temperature::REALSCALAR = (T₀), +# earth_props::EarthModel, +# frequencies::Vector{<:Number} = [f₀], +# energizations::Vector{<:Number}, +# wind_velocity::REALSCALAR = 1.0, +# ) + +# # 1. System structure validation +# @assert !isempty(system.cables) "LineCableSystem must contain at least one cable" + +# # 2. Phase assignment validation +# phase_numbers = unique(vcat([cable.conn for cable in system.cables]...)) +# @assert !isempty(filter(x -> x > 0, phase_numbers)) "At least one conductor must be assigned to a phase (>0)" +# @assert maximum(phase_numbers) <= system.num_phases "Invalid phase number detected" + +# # 3. Cable components validation +# for (i, cable) in enumerate(system.cables) +# @assert !isempty(cable.design_data.components) "Cable $i has no components defined" + +# # Validate conductor-insulator pairs +# for (j, comp) in enumerate(cable.design_data.components) +# @assert !isempty(comp.conductor_group.layers) "Component $j in cable $i has no conductor layers" +# @assert !isempty(comp.insulator_group.layers) "Component $j in cable $i has no insulator layers" + +# # Validate monotonic increase of radii +# @assert comp.conductor_group.radius_ext > comp.conductor_group.radius_in "Component $j in cable $i: conductor outer radius must be larger than inner radius" +# @assert comp.insulator_group.radius_ext > comp.insulator_group.radius_in "Component $j in cable $i: insulator outer radius must be larger than inner radius" + +# # Validate geometric continuity between conductor and insulator +# r_ext_cond = comp.conductor_group.radius_ext +# r_in_ins = comp.insulator_group.radius_in +# @assert abs(r_ext_cond - r_in_ins) < 1e-10 "Geometric mismatch in cable $i component $j: conductor outer radius ≠ insulator inner radius" + +# # Validate electromagnetic properties +# # Conductor properties +# @assert comp.conductor_props.rho > 0 "Component $j in cable $i: conductor resistivity must be positive" +# @assert comp.conductor_props.mu_r > 0 "Component $j in cable $i: conductor relative permeability must be positive" +# @assert comp.conductor_props.eps_r >= 0 "Component $j in cable $i: conductor relative permittivity grater than or equal to zero" + +# # Insulator properties +# @assert comp.insulator_props.rho > 0 "Component $j in cable $i: insulator resistivity must be positive" +# @assert comp.insulator_props.mu_r > 0 "Component $j in cable $i: insulator relative permeability must be positive" +# @assert comp.insulator_props.eps_r > 0 "Component $j in cable $i: insulator relative permittivity must be positive" +# end +# end + +# # 4. Temperature range validation +# @assert abs(temperature - T₀) < ΔTmax """ +# Temperature is outside the valid range for linear resistivity model: +# T = $temperature +# T₀ = $T₀ +# ΔTmax = $ΔTmax +# |T - T₀| = $(abs(temperature - T₀))""" +# # 5. Wind velocity validation +# @assert wind_velocity >= 0.0 "Wind velocity must be non-negative" +# @assert wind_velocity <= 140.0 "Wind velocity exceeds typical maximum values (140 m/s)" + +# # 6. Frequency range validation +# @assert !isempty(frequencies) "Frequency vector cannot be empty" +# @assert all(f -> f > 0, frequencies) "All frequencies must be positive" +# @assert issorted(frequencies) "Frequency vector must be monotonically increasing" +# if maximum(frequencies) > 1e8 +# @warn "Frequencies above 100 MHz exceed quasi-TEM validity limit. High-frequency results should be interpreted with caution." maxfreq = +# maximum(frequencies) +# end + +# # 7. Earth model validation (Adapted for single frequency) +# @assert length(earth_props.layers[end].rho_g) == 1 """Earth model frequencies must match analysis frequency count (must be 1) +# Earth model frequencies = $(length(earth_props.layers[end].rho_g)) +# Analysis frequencies = 1 +# """ + +# # 8. Geometric validation +# positions = [ +# ( +# cable.horz, +# cable.vert, +# maximum( +# comp.insulator_group.radius_ext +# for comp in cable.design_data.components +# ), +# ) +# for cable in system.cables +# ] + +# for i in eachindex(positions) +# for j in (i+1):lastindex(positions) +# # Calculate center-to-center distance +# dist = sqrt( +# (positions[i][1] - positions[j][1])^2 + +# (positions[i][2] - positions[j][2])^2, +# ) + +# # Get outermost radii for both cables +# r_outer_i = positions[i][3] +# r_outer_j = positions[j][3] + +# # Check if cables overlap +# min_allowed_dist = r_outer_i + r_outer_j + +# @assert dist > min_allowed_dist """ +# Cables $i and $j overlap! +# Center-to-center distance: $(dist) m +# Minimum required distance: $(min_allowed_dist) m +# Cable $i outer radius: $(r_outer_i) m +# Cable $j outer radius: $(r_outer_j) m""" +# end +# end + +# # 9. Energization validation +# @assert length(energizations) == system.num_phases """ +# Number of energizations must match the number of phases in the system. +# Phases in system: $(system.num_phases) +# Energizations provided: $(length(energizations))""" + + +# # 10. Type resolution and final construction +# T = resolve_T(system, temperature, earth_props, frequencies, wind_velocity) + +# # Coerce energizations to Complex{T} +# complex_energizations = coerce_to_T(energizations, Complex{T}) + +# return new{T}( +# coerce_to_T(system, T), +# coerce_to_T(temperature, T), +# coerce_to_T(earth_props, T), +# coerce_to_T(frequencies, T), +# complex_energizations, +# coerce_to_T(wind_velocity, T), +# ) +# end +# end diff --git a/src/engine/fem/ampacityproblem/solver.jl b/src/engine/fem/ampacityproblem/solver.jl new file mode 100644 index 00000000..0dc75c21 --- /dev/null +++ b/src/engine/fem/ampacityproblem/solver.jl @@ -0,0 +1,543 @@ + +# function make_fem_problem!( +# fem_formulation::AmpacityFormulation, +# frequency::Float64, +# workspace::ElectroThermalFEMWorkspace, +# ) + +# fem_formulation.problem = GetDP.Problem() +# define_jacobian!(fem_formulation.problem, workspace) +# define_integration!(fem_formulation.problem) +# define_material_props!(fem_formulation.problem, workspace) +# define_constants!(fem_formulation.problem, fem_formulation, frequency, workspace ) +# define_domain_groups!(fem_formulation.problem, fem_formulation, workspace) +# define_constraint!(fem_formulation.problem, fem_formulation, workspace) +# define_resolution!(fem_formulation.problem, fem_formulation, workspace) + +# make_problem!(fem_formulation.problem) + +# index = findfirst(item -> item isa typeof(fem_formulation), workspace.formulation.analysis_type) +# fem_formulation.problem.filename = workspace.paths[:multiphysics_file][index] +# @info "Writing multiphysics problem to $(fem_formulation.problem.filename)" + +# write_file(fem_formulation.problem) +# end + +# function define_constants!( +# problem::GetDP.Problem, +# fem_formulation::AmpacityFormulation, +# frequency::Float64, +# workspace::ElectroThermalFEMWorkspace, +# ) +# func = GetDP.Function() + +# add_constant!(func, "Freq", frequency) +# add_constant!(func, "Tambient[]", workspace.temperature+273.15) # Kelvin +# add_constant!(func, "V_wind", workspace.wind_velocity) # m/s +# add_constant!(func, "h[]", "7.371 + 6.43*V_wind^0.75") # Convective coefficient [W/(m^2 K)] + +# push!(problem.function_obj, func) +# end + +# function define_domain_groups!( +# problem::GetDP.Problem, +# fem_formulation::AmpacityFormulation, +# workspace::ElectroThermalFEMWorkspace, +# ) + +# material_reg = Dict{Symbol, Vector{Int}}( +# :DomainC => Int[], +# :DomainCC => Int[], +# :DomainInf => Int[], +# ) +# inds_reg = Int[] +# cables_reg = Dict{Int, Vector{Int}}() +# boundary_reg = Int[] +# is_magneto_thermal = fem_formulation isa MagnetoThermal + +# for tag in keys(workspace.physical_groups) +# if tag > 10^8 +# # Decode tag information +# surface_type, entity_num, component_num, material_group, _ = +# decode_physical_group_tag(tag) + +# # Categorize regions +# if surface_type == 1 +# push!(get!(cables_reg, entity_num, Int[]), tag) +# if material_group == 1 && (!is_magneto_thermal || component_num == 1) +# push!(inds_reg, tag) +# end +# end +# if material_group == 1 +# push!(material_reg[:DomainC], tag) +# elseif material_group == 2 +# push!(material_reg[:DomainCC], tag) +# end + +# surface_type == 3 && push!(material_reg[:DomainInf], tag) + +# else +# decode_boundary_tag(tag)[1] == 2 && push!(boundary_reg, tag) +# end +# end +# inds_reg = sort(inds_reg) +# material_reg[:DomainC] = sort(material_reg[:DomainC]) +# material_reg[:DomainCC] = sort(material_reg[:DomainCC]) + +# # Create and configure groups +# group = GetDP.Group() + +# # Add common domains +# add!( +# group, +# "DomainInf", +# material_reg[:DomainInf], +# "Region", +# comment = "Domain transformation to infinity", +# ) + +# for (key, tag) in enumerate(inds_reg) +# add!(group, "Con_$key", [tag], "Region"; +# comment = "$(create_physical_group_name(workspace, tag))") +# end + +# add!(group, "Conductors", inds_reg, "Region") + +# # Add standard FEM domains +# domain_configs = [ +# ("DomainC", Int[], "All conductor materials"), +# ("DomainCC", Int[], "All non-conductor materials"), +# ] + +# for (name, regions, comment) in domain_configs +# add!(group, name, regions, "Region"; comment = comment) +# end + +# for tag in material_reg[:DomainC] +# add!(group, "DomainC", [tag], "Region"; +# operation = "+=", +# comment = "$(create_physical_group_name(workspace, tag))") +# end + +# for tag in material_reg[:DomainCC] +# add!(group, "DomainCC", [tag], "Region"; +# operation = "+=", +# comment = "$(create_physical_group_name(workspace, tag))") +# end + + +# if fem_formulation isa MagnetoThermal + +# add!(group, "Domain_Mag", ["DomainCC", "DomainC"], "Region") +# add!(group, "Sur_Dirichlet_Mag", boundary_reg, "Region") +# add!(group, "Sur_Dirichlet_The", boundary_reg, "Region") +# add!(group, "Sur_Convection_Thermal", [], "Region") +# end + +# problem.group = group +# end + +# function define_constraint!( +# problem::GetDP.Problem, +# fem_formulation::MagnetoThermal, +# workspace::ElectroThermalFEMWorkspace, +# ) +# constraint = GetDP.Constraint() + +# # MagneticVectorPotential_2D +# mvp = assign!(constraint, "MagneticVectorPotential_2D") +# case!(mvp, "Sur_Dirichlet_Mag", value = "0.0") + +# # Voltage_2D (placeholder) +# voltage = assign!(constraint, "Voltage_2D") +# case!(voltage, "") + +# # Current_2D +# current = assign!(constraint, "Current_2D") +# for (idx, curr) in enumerate(workspace.energizations) +# case!(current, "Con_$idx", value = "Complex[$(real(curr)), $(imag(curr))]") +# end + +# temp = assign!(constraint, "DirichletTemp") +# case!(temp, "Sur_Dirichlet_The", value = "Tambient[]") # Ambient temperature + +# problem.constraint = constraint + +# end + + +# function define_resolution!( +# problem::GetDP.Problem, +# formulation::MagnetoThermal, +# workspace::ElectroThermalFEMWorkspace, +# ) + +# resolution_name = formulation.resolution_name + +# # Create a new Problem instance +# functionspace = FunctionSpace() + +# # FunctionSpace section +# fs1 = add!(functionspace, "Hcurl_a_Mag_2D", nothing, nothing, Type = "Form1P") +# add_basis_function!( +# functionspace, +# "se", +# "ae", +# "BF_PerpendicularEdge"; +# Support = "Domain_Mag", +# Entity = "NodesOf[ All ]", +# ) + +# add_constraint!(functionspace, "ae", "NodesOf", "MagneticVectorPotential_2D") + +# fs3 = add!(functionspace, "Hregion_u_Mag_2D", nothing, nothing, Type = "Form1P") +# add_basis_function!( +# functionspace, +# "sr", +# "ur", +# "BF_RegionZ"; +# Support = "DomainC", +# Entity = "DomainC", +# ) +# add_global_quantity!(functionspace, "U", "AliasOf"; NameOfCoef = "ur") +# add_global_quantity!(functionspace, "I", "AssociatedWith"; NameOfCoef = "ur") +# add_constraint!(functionspace, "U", "Region", "Voltage_2D") +# add_constraint!(functionspace, "I", "Region", "Current_2D") + +# fs1 = add!(functionspace, "Hgrad_Thermal", nothing, nothing, Type = "Form0") +# add_basis_function!( +# functionspace, +# "sn", +# "t", +# "BF_Node"; +# Support = "Domain_Mag", +# Entity = "NodesOf[ All ]", +# ) + +# add_constraint!(functionspace, "t", "NodesOf", "DirichletTemp") + +# problem.functionspace = functionspace + +# # Define Formulation +# formulation = GetDP.Formulation() + +# form = add!(formulation, "Darwin_a_2D", "FemEquation") +# add_quantity!(form, "a", Type = "Local", NameOfSpace = "Hcurl_a_Mag_2D") +# add_quantity!(form, "ur", Type = "Local", NameOfSpace = "Hregion_u_Mag_2D") +# add_quantity!(form, "T", Type = "Local", NameOfSpace = "Hgrad_Thermal") +# add_quantity!(form, "I", Type = "Global", NameOfSpace = "Hregion_u_Mag_2D [I]") +# add_quantity!(form, "U", Type = "Global", NameOfSpace = "Hregion_u_Mag_2D [U]") + +# eq = add_equation!(form) + +# add!( +# eq, +# "Galerkin", +# "[ nu[] * Dof{d a} , {d a} ]", +# In = "Domain_Mag", +# Jacobian = "Vol", +# Integration = "I1", +# ) +# add!( +# eq, +# "Galerkin", +# "DtDof [ sigma[{T}] * Dof{a} , {a} ]", +# In = "DomainC", +# Jacobian = "Vol", +# Integration = "I1", +# ) +# add!( +# eq, +# "Galerkin", +# "[ sigma[{T}] * Dof{ur}, {a} ]", +# In = "DomainC", +# Jacobian = "Vol", +# Integration = "I1", +# ) +# add!( +# eq, +# "Galerkin", +# "DtDof [ sigma[{T}] * Dof{a} , {ur} ]", +# In = "DomainC", +# Jacobian = "Vol", +# Integration = "I1", +# ) +# add!( +# eq, +# "Galerkin", +# "[ sigma[{T}] * Dof{ur}, {ur}]", +# In = "DomainC", +# Jacobian = "Vol", +# Integration = "I1", +# ) +# add!( +# eq, +# "Galerkin", +# "DtDtDof [ epsilon[] * Dof{a} , {a}]", +# In = "DomainC", +# Jacobian = "Vol", +# Integration = "I1", +# comment = " Darwin approximation term", +# ) +# add!( +# eq, +# "Galerkin", +# "DtDof[ epsilon[] * Dof{ur}, {a} ]", +# In = "DomainC", +# Jacobian = "Vol", +# Integration = "I1", +# ) +# add!( +# eq, +# "Galerkin", +# "DtDtDof [ epsilon[] * Dof{a} , {ur}]", +# In = "DomainC", +# Jacobian = "Vol", +# Integration = "I1", +# ) +# add!( +# eq, +# "Galerkin", +# "DtDof[ epsilon[] * Dof{ur}, {ur} ]", +# In = "DomainC", +# Jacobian = "Vol", +# Integration = "I1", +# ) +# add!(eq, "GlobalTerm", "[ Dof{I} , {U} ]", In = "Conductors") #DomainActive + +# form = add!(formulation, "ThermalSta", "FemEquation") +# add_quantity!(form, "T", Type = "Local", NameOfSpace = "Hgrad_Thermal") +# add_quantity!(form, "a", Type = "Local", NameOfSpace = "Hcurl_a_Mag_2D") +# add_quantity!(form, "ur", Type = "Local", NameOfSpace = "Hregion_u_Mag_2D") + +# eq = add_equation!(form) + +# add!( +# eq, +# "Galerkin", +# "[ k[] * Dof{d T} , {d T} ]", +# In = "Domain_Mag", +# Jacobian = "Vol", +# Integration = "I1", +# ) + +# add!( +# eq, +# "Galerkin", +# "[ -0.5*sigma[{T}] * [ SquNorm[Dt[{a}]+{ur}] ], {T} ]", +# In = "DomainC", +# Jacobian = "Vol", +# Integration = "I1", +# ) + +# add!( +# eq, +# "Galerkin", +# "[ h[] * Dof{T} , {T} ]", +# In = "Sur_Convection_Thermal", +# Jacobian = "Sur", +# Integration = "I1", +# comment = " Convection boundary condition", +# ) + +# add!( +# eq, +# "Galerkin", +# "[-h[] * Tambient[] , {T} ]", +# In = "Sur_Convection_Thermal", +# Jacobian = "Sur", +# Integration = "I1", +# ) +# # Add the formulation to the problem +# problem.formulation = formulation + +# # Define Resolution +# resolution = Resolution() + +# sys_mag = SystemItem("Sys_Mag", "Darwin_a_2D"; Type="Complex", Frequency="Freq") +# sys_the = SystemItem("Sys_The", "ThermalSta") + +# # Add a resolution +# output_dir = joinpath("results", lowercase(resolution_name)) +# output_dir = replace(output_dir, "\\" => "/") # for compatibility with Windows paths + +# # Construct the final Operation vector +# add!(resolution, resolution_name, [sys_mag, sys_the], +# Operation = [ +# "CreateDir[\"$(output_dir)\"]", +# "InitSolution[Sys_Mag]", +# "InitSolution[Sys_The]", +# "Generate[Sys_Mag]", +# "Solve[Sys_Mag]", +# "Generate[Sys_The]", +# "Solve[Sys_The]", +# "SaveSolution[Sys_Mag]", +# "SaveSolution[Sys_The]", +# "PostOperation[LineParams]", +# ] +# ) +# # Add the resolution to the problem +# problem.resolution = resolution + +# # PostProcessing section +# postprocessing = PostProcessing() + +# pp = add!(postprocessing, "Darwin_a_2D", "Darwin_a_2D") + +# q = add_post_quantity_term!(pp, "a") +# add!(q, "Term", "{a}"; In = "Domain_Mag", Jacobian = "Vol") +# q = add_post_quantity_term!(pp, "az") +# add!(q, "Term", "CompZ[{a}]"; In = "Domain_Mag", Jacobian = "Vol") +# q = add_post_quantity_term!(pp, "b") +# add!(q, "Term", "{d a}"; In = "Domain_Mag", Jacobian = "Vol") +# q = add_post_quantity_term!(pp, "bm") +# add!(q, "Term", "Norm[{d a}]"; In = "Domain_Mag", Jacobian = "Vol") +# q = add_post_quantity_term!(pp, "j") +# add!(q, "Term", "-sigma[]*(Dt[{a}]+{ur})"; In = "DomainC", Jacobian = "Vol") +# q = add_post_quantity_term!(pp, "jz") +# add!(q, "Term", "CompZ[-sigma[{T}]*(Dt[{a}]+{ur})]"; In = "DomainC", Jacobian = "Vol") +# q = add_post_quantity_term!(pp, "jm") +# add!(q, "Term", "Norm[-sigma[{T}]*(Dt[{a}]+{ur})]"; In = "DomainC", Jacobian = "Vol") +# q = add_post_quantity_term!(pp, "d") +# add!(q, "Term", "epsilon[] * Dt[Dt[{a}]+{ur}]"; In = "DomainC", Jacobian = "Vol") +# q = add_post_quantity_term!(pp, "dz") +# add!(q, "Term", "CompZ[epsilon[] * Dt[Dt[{a}]+{ur}]]"; In = "DomainC", Jacobian = "Vol") +# q = add_post_quantity_term!(pp, "dm") +# add!(q, "Term", "Norm[epsilon[] * Dt[Dt[{a}]+{ur}]]"; In = "DomainC", Jacobian = "Vol") +# q = add_post_quantity_term!(pp, "rhoj2") +# add!(q, "Term", "0.5*sigma[{T}]*SquNorm[Dt[{a}]+{ur}]"; In = "DomainC", Jacobian = "Vol") + +# q = add_post_quantity_term!(pp, "U") +# add!(q, "Term", "{U}"; In = "DomainC") +# q = add_post_quantity_term!(pp, "I") +# add!(q, "Term", "{I}"; In = "DomainC") +# q = add_post_quantity_term!(pp, "Z") +# add!(q, "Term", "-{U}"; In = "DomainC") + + +# pp_the = add!(postprocessing, "ThermalSta", "ThermalSta") + +# q = add_quantity_term!(pp_the, "T") +# add!(q, "Local", "{T}"; In = "Domain_Mag", Jacobian = "Vol") + +# q = add_quantity_term!(pp_the, "TinC") +# add!(q, "Local", "{T}-273.15"; In = "Domain_Mag", Jacobian = "Vol") + +# q = add_quantity_term!(pp_the, "q") +# add!(q, "Local", "-k[]*{d T}"; In = "Domain_Mag", Jacobian = "Vol") + +# problem.postprocessing = postprocessing + +# # PostOperation section +# postoperation = PostOperation() + +# # Add post-operation items +# po1 = add!(postoperation, "Field_Maps", "ThermalSta") +# op1 = add_operation!(po1) +# add_operation!(op1, "Print[ TinC, OnElementsOf Domain_Mag, Name \"T [°C] around cable\", File StrCat[ \"$(joinpath(output_dir,"TinC"))\", \".pos\" ] ];") +# add_operation!(op1, "Print[ q , OnElementsOf Domain_Mag, Name \"heat flux [W/m²] around cable\", File StrCat[ \"$(joinpath(output_dir,"q"))\", \".pos\" ] ];") + +# po2 = add!(postoperation, "LineParams", "ThermalSta") +# op2 = add_operation!(po2) +# add_operation!(op2, "Print[ TinC, OnElementsOf Domain_Mag, Name \"T [°C] around cable\", File StrCat[ \"$(joinpath(output_dir,"TinC"))\", \".pos\" ] ];") +# add_operation!(op2, "Print[ q , OnElementsOf Domain_Mag, Name \"heat flux [W/m²] around cable\", File StrCat[ \"$(joinpath(output_dir,"q"))\", \".pos\" ] ];") +# # add_operation!(op2, "Print[ Z, OnRegion Conductors, Format Table, File \"$(joinpath(output_dir,"Z.dat"))\", AppendToExistingFile (active_con > 1 ? 1 : 0) ];") + + +# # Add the post-operation to the problem +# problem.postoperation = postoperation + +# end + +# function run_getdp(workspace::FEMWorkspace, fem_formulation::AmpacityFormulation) +# # Initialize Gmsh if not already initialized +# if gmsh.is_initialized() == 0 +# gmsh.initialize() +# end + +# # Number of iterations (from the original function) +# n_phases = +# sum([length(c.design_data.components) for c in workspace.core.system.cables]) + +# # Flag to track if all solves are successful +# all_success = true + +# # Map verbosity to Gmsh/GetDP level +# gmsh_verbosity = map_verbosity_to_gmsh(workspace.opts.verbosity) +# gmsh.option.set_number("General.Verbosity", gmsh_verbosity) + + +# getdp_verbosity = map_verbosity_to_getdp(workspace.opts.verbosity) + +# # Construct solver command with -setnumber active_ind i +# solve_cmd = "$(workspace.opts.getdp_executable) $(fem_formulation.problem.filename) -msh $(workspace.paths[:mesh_file]) -solve $(fem_formulation.resolution_name) -v2 -verbose $(getdp_verbosity)" + +# # Log the current solve attempt +# @info "Solving multiphysics... (Resolution = $(fem_formulation.resolution_name))" + +# # Attempt to run the solver +# try +# gmsh.onelab.run("GetDP", solve_cmd) + +# if workspace.opts.plot_field_maps +# @info "Building field maps multiphysics... (Resolution = $(fem_formulation.resolution_name))" + +# post_cmd = "$(workspace.opts.getdp_executable) $(fem_formulation.problem.filename) -msh $(workspace.paths[:mesh_file]) -pos Field_Maps -v2 -verbose $(getdp_verbosity)" + +# gmsh.onelab.run("GetDP", post_cmd) +# end + +# @info "Solve successful!" +# catch e +# # Log the error and update the success flag +# @error "Solver failed: $e" +# all_success = false +# # Continue to the next iteration even if this one fails +# end + +# # Return true only if all solves were successful +# return all_success +# end + +# using LinearAlgebra: BLAS, BlasFloat + +# # function run_solver!(::Val{:AmpacityProblem}, workspace::ElectroThermalFEMWorkspace) + +# # n_frequencies = workspace.n_frequencies + +# # for (k, frequency) in enumerate(workspace.freq) +# # @info "Solving frequency $k/$n_frequencies: $frequency Hz" + +# # # Fill Z,Y (original ordering) for this slice +# # _do_run_solver!(k, workspace) + +# # # Archive if requested +# # if workspace.opts.keep_run_files +# # archive_frequency_results(workspace, frequency) +# # end +# # end + +# # end + + +# # function _do_run_solver!(freq_idx::Int, +# # workspace::ElectroThermalFEMWorkspace) # Z::Array{ComplexF64, 3}, Y::Array{ComplexF64, 3}) + +# # # Get formulation from workspace +# # formulation = workspace.formulation +# # # Z, Y = workspace.Z, workspace.Y +# # frequency = workspace.freq[freq_idx] + +# # # Build and solve both formulations +# # for fem_formulation in formulation.analysis_type +# # @debug "Processing $(fem_formulation.resolution_name) formulation" + +# # make_fem_problem!(fem_formulation, frequency, workspace) + +# # if !run_getdp(workspace, fem_formulation) +# # Base.error("$(fem_formulation.resolution_name) solver failed") +# # end +# # end + +# # end + diff --git a/src/engine/fem/ampacityproblem/workspace.jl b/src/engine/fem/ampacityproblem/workspace.jl new file mode 100644 index 00000000..7aac9bc8 --- /dev/null +++ b/src/engine/fem/ampacityproblem/workspace.jl @@ -0,0 +1,203 @@ +# """ +# $(TYPEDEF) + +# A container for the flattened, type-stable data arrays derived from a +# [`AmpacityProblem`](@ref). This struct serves as the primary data source +# for all subsequent computational steps. + +# # Fields +# $(TYPEDFIELDS) +# """ +# @kwdef struct ElectroThermalFEMWorkspace{T <: REALSCALAR} +# "Electro Thermal problem definition." +# problem_def::AmpacityProblem{T} +# "Formulation parameters." +# formulation::ElectroThermalFEMFormulation +# "Computation options." +# opts::FEMOptions + +# "Path information." +# paths::Dict{Symbol, Any} + +# "Conductor surfaces within cables." +# conductors::Vector{GmshObject{<:AbstractEntityData}} +# "Insulator surfaces within cables." +# insulators::Vector{GmshObject{<:AbstractEntityData}} +# "Domain-space physical surfaces (air and earth layers)." +# space_regions::Vector{GmshObject{<:AbstractEntityData}} +# "Domain boundary curves." +# boundaries::Vector{GmshObject{<:AbstractEntityData}} +# "Container for all pre-fragmentation entities." +# unassigned_entities::Dict{Vector{Float64}, AbstractEntityData} +# "Container for all material names used in the model." +# material_registry::Dict{String, Int} +# "Container for unique physical groups." +# physical_groups::Dict{Int, Material} + +# "Vector of frequency values [Hz]." +# frequencies::Vector{T} +# "Vector of horizontal positions [m]." +# horz::Vector{T} +# "Vector of vertical positions [m]." +# vert::Vector{T} +# "Vector of internal conductor radii [m]." +# r_in::Vector{T} +# "Vector of external conductor radii [m]." +# r_ext::Vector{T} +# "Vector of internal insulator radii [m]." +# r_ins_in::Vector{T} +# "Vector of external insulator radii [m]." +# r_ins_ext::Vector{T} +# "Vector of conductor resistivities [Ω·m]." +# rho_cond::Vector{T} +# "Vector of conductor temperature coefficients [1/°C]." +# alpha_cond::Vector{T} +# "Vector of conductor relative permeabilities." +# mu_cond::Vector{T} +# "Vector of conductor relative permittivities." +# eps_cond::Vector{T} +# "Vector of insulator resistivities [Ω·m]." +# rho_ins::Vector{T} +# "Vector of insulator relative permeabilities." +# mu_ins::Vector{T} +# "Vector of insulator relative permittivities." +# eps_ins::Vector{T} +# "Vector of insulator loss tangents." +# tan_ins::Vector{T} +# "Effective earth resistivity (layers × frequencies)." +# rho_g::Matrix{T} +# "Effective earth permittivity (layers × frequencies)." +# eps_g::Matrix{T} +# "Effective earth permeability (layers × frequencies)." +# mu_g::Matrix{T} +# "Effective earth conductivity (layers × frequencies)." +# kappa_g::Matrix{T} +# "Operating temperature [°C]." +# temp::T +# "Number of frequency samples." +# n_frequencies::Int +# "Number of phases in the system." +# n_phases::Int +# "Number of cables in the system." +# n_cables::Int + +# end + + + +# """ +# $(TYPEDSIGNATURES) + +# Initializes and populates the [`ElectroThermalFEMWorkspace`](@ref) by normalizing a +# [`AmpacityProblem`](@ref) into flat, type-stable arrays. +# """ +# function init_workspace( +# problem::AmpacityProblem{U}, +# formulation::ElectroThermalFEMFormulation, +# ) where {U <: REALSCALAR} + +# opts = formulation.options + +# system = problem.system +# n_frequencies = length(problem.frequencies) +# n_phases = sum(length(cable.design_data.components) for cable in system.cables) +# n_cables = system.num_cables + +# # Pre-allocate 1D arrays +# T = BASE_FLOAT +# frequencies = Vector{T}(undef, n_frequencies) +# horz = Vector{T}(undef, n_phases) +# vert = Vector{T}(undef, n_phases) +# r_in = Vector{T}(undef, n_phases) +# r_ext = Vector{T}(undef, n_phases) +# r_ins_in = Vector{T}(undef, n_phases) +# r_ins_ext = Vector{T}(undef, n_phases) +# rho_cond = Vector{T}(undef, n_phases) +# alpha_cond = Vector{T}(undef, n_phases) +# mu_cond = Vector{T}(undef, n_phases) +# eps_cond = Vector{T}(undef, n_phases) +# rho_ins = Vector{T}(undef, n_phases) +# mu_ins = Vector{T}(undef, n_phases) +# eps_ins = Vector{T}(undef, n_phases) +# tan_ins = Vector{T}(undef, n_phases) + + +# # Fill arrays, ensuring type promotion +# frequencies .= problem.frequencies + +# idx = 0 +# for (cable_idx, cable) in enumerate(system.cables) +# for (comp_idx, component) in enumerate(cable.design_data.components) +# idx += 1 +# # Geometric properties +# horz[idx] = T(cable.horz) +# vert[idx] = T(cable.vert) +# r_in[idx] = T(component.conductor_group.radius_in) +# r_ext[idx] = T(component.conductor_group.radius_ext) +# r_ins_in[idx] = T(component.insulator_group.radius_in) +# r_ins_ext[idx] = T(component.insulator_group.radius_ext) + +# # Material properties +# rho_cond[idx] = T(component.conductor_props.rho) +# alpha_cond[idx] = T(component.conductor_props.alpha) +# mu_cond[idx] = T(component.conductor_props.mu_r) +# eps_cond[idx] = T(component.conductor_props.eps_r) +# rho_ins[idx] = T(component.insulator_props.rho) +# mu_ins[idx] = T(component.insulator_props.mu_r) +# eps_ins[idx] = T(component.insulator_props.eps_r) + +# # Calculate loss factor from resistivity +# ω = 2 * π * f₀ # Using default frequency +# C_eq = T(component.insulator_group.shunt_capacitance) +# G_eq = T(component.insulator_group.shunt_conductance) +# tan_ins[idx] = G_eq / (ω * C_eq) + +# end +# end + +# (rho_g, eps_g, mu_g, kappa_g) = _get_earth_data( +# nothing, +# problem.earth_props, +# frequencies, +# T, +# ) + +# temp = T(problem.temperature) + +# # Construct and return the ElectroThermalFEMWorkspace struct +# return ElectroThermalFEMWorkspace{T}( +# problem_def = problem, formulation = formulation, opts = opts, +# paths = setup_paths(problem.system, formulation), +# conductors = Vector{GmshObject{<:AbstractEntityData}}(), +# insulators = Vector{GmshObject{<:AbstractEntityData}}(), +# space_regions = Vector{GmshObject{<:AbstractEntityData}}(), +# boundaries = Vector{GmshObject{<:AbstractEntityData}}(), +# unassigned_entities = Dict{Vector{Float64}, AbstractEntityData}(), +# material_registry = Dict{String, Int}(), # Maps physical group tags to materials, +# physical_groups = Dict{Int, Material}(), # Initialize empty material registry +# frequencies = frequencies, +# horz = horz, +# vert = vert, +# r_in = r_in, +# r_ext = r_ext, +# r_ins_in = r_ins_in, +# r_ins_ext = r_ins_ext, +# rho_cond = rho_cond, +# alpha_cond = alpha_cond, +# mu_cond = mu_cond, +# eps_cond = eps_cond, +# rho_ins = rho_ins, +# mu_ins = mu_ins, +# eps_ins = eps_ins, +# tan_ins = tan_ins, +# rho_g = rho_g, +# eps_g = eps_g, +# mu_g = mu_g, +# kappa_g = kappa_g, +# temp = temp, +# n_frequencies = n_frequencies, +# n_phases = n_phases, +# n_cables = n_cables, +# ) + +# end diff --git a/src/engine/fem/base.jl b/src/engine/fem/base.jl index 47b415fd..10255622 100644 --- a/src/engine/fem/base.jl +++ b/src/engine/fem/base.jl @@ -1,2 +1,2 @@ -Base.eltype(::FEMWorkspace{T}) where {T} = T -Base.eltype(::Type{FEMWorkspace{T}}) where {T} = T +Base.eltype(::FEMWorkspace{T}) where {T} = T +Base.eltype(::Type{FEMWorkspace{T}}) where {T} = T diff --git a/src/engine/fem/cable.jl b/src/engine/fem/cable.jl index 29753b81..53aafcb2 100644 --- a/src/engine/fem/cable.jl +++ b/src/engine/fem/cable.jl @@ -1,450 +1,450 @@ -""" -Cable geometry creation functions for the FEMTools.jl module. -These functions handle the creation of cable components. -""" - -""" -$(TYPEDSIGNATURES) - -Create the cable geometry for all cables in the system. - -# Arguments - -- `workspace`: The [`FEMWorkspace`](@ref) containing the model parameters. - -# Returns - -- Nothing. Updates the conductors and insulators vectors in the workspace. - -# Examples - -```julia -$(FUNCTIONNAME)(workspace) -``` -""" -function make_cable_geometry(workspace::FEMWorkspace) - - # Get the cable system - cable_system = workspace.problem_def.system - - # Process each cable in the system - for (cable_idx, cable_position) in enumerate(cable_system.cables) - @info "Processing cable $(cable_idx) at position ($(cable_position.horz), $(cable_position.vert))" - - # Get the cable design - cable_design = cable_position.design_data - - # Get the phase assignments - phase_assignments = cable_position.conn - - # Process each component in the cable - for (comp_idx, component) in enumerate(cable_design.components) - # Get the component ID - comp_id = component.id - - # Get the phase assignment for this component - phase = comp_idx <= length(phase_assignments) ? phase_assignments[comp_idx] : 0 - - @debug "Processing component $(comp_id) (phase $(phase))" - - # Process conductor group - if !isnothing(component.conductor_group) - @debug "Processing conductor group for component $(comp_id)" - - # Process each layer in the conductor group - for (layer_idx, layer) in enumerate(component.conductor_group.layers) - @debug "Processing conductor layer $(layer_idx)" - - # Create the cable part - _make_cablepart!( - workspace, - layer, - cable_idx, - comp_idx, - comp_id, - phase, - layer_idx, - ) - end - end - - # Process insulator group - if !isnothing(component.insulator_group) - @debug "Processing insulator group for component $(comp_id)" - - # Process each layer in the insulator group - for (layer_idx, layer) in enumerate(component.insulator_group.layers) - @debug "Processing insulator layer $(layer_idx)" - - # Create the cable part - _make_cablepart!( - workspace, - layer, - cable_idx, - comp_idx, - comp_id, - phase, - layer_idx, - ) - end - end - end - end - - @info "Cable geometry created" -end - -""" -$(TYPEDSIGNATURES) - -Create a cable part entity for all tubular shapes. - -# Arguments - -- `workspace`: The [`FEMWorkspace`](@ref) containing the model parameters. -- `part`: The [`AbstractCablePart`](@ref) to create. -- `cable_idx`: The index of the cable. -- `comp_idx`: The index of the component. -- `comp_id`: The ID of the component. -- `phase`: The phase assignment. -- `layer_idx`: The index of the layer. - -# Returns - -- Nothing. Updates the conductors or insulators vector in the workspace. - -# Examples - -```julia -$(FUNCTIONNAME)(workspace, part, 1, 1, "core", 1, 1) -``` -""" -function _make_cablepart!(workspace::FEMWorkspace, part::AbstractCablePart, - cable_idx::Int, comp_idx::Int, comp_id::String, - phase::Int, layer_idx::Int) - - # Get the cable definition - cable_position = workspace.problem_def.system.cables[cable_idx] - - # Get the center coordinates - x_center = to_nominal(cable_position.horz) - y_center = to_nominal(cable_position.vert) - - # Determine material group directly from part type - material_group = get_material_group(part) - - # Get or register material ID - material_id = get_or_register_material_id(workspace, part.material_props) - - # Create physical tag with new encoding scheme - physical_group_tag = encode_physical_group_tag( - 1, # Surface type 1 = cable component - cable_idx, # Cable number - comp_idx, # Component number - material_group, # Material group from part type - material_id, # Material ID from registry - ) - - # Create physical name - part_type = lowercase(string(nameof(typeof(part)))) - elementary_name = create_cable_elementary_name( - cable_idx = cable_idx, - component_id = comp_id, - group_type = material_group, - part_type = part_type, - layer_idx = layer_idx, - phase = phase, - ) - - # Extract parameters - radius_in = to_nominal(part.radius_in) - radius_ext = to_nominal(part.radius_ext) - - # Calculate mesh size for this part - if part isa AbstractConductorPart - num_elements = workspace.formulation.elements_per_length_conductor - elseif part isa Insulator - num_elements = workspace.formulation.elements_per_length_insulator - elseif part isa Semicon - num_elements = workspace.formulation.elements_per_length_semicon - end - - mesh_size_current = - _calc_mesh_size(radius_in, radius_ext, part.material_props, num_elements, workspace) - - # Calculate mesh size for the next part - num_layers = - length(cable_position.design_data.components[comp_idx].conductor_group.layers) - next_part = - layer_idx < num_layers ? - cable_position.design_data.components[comp_idx].conductor_group.layers[layer_idx+1] : - nothing - - if !isnothing(next_part) - next_radius_in = to_nominal(next_part.radius_in) - next_radius_ext = to_nominal(next_part.radius_ext) - mesh_size_next = _calc_mesh_size( - next_radius_in, - next_radius_ext, - next_part.material_props, - num_elements, - workspace, - ) - if next_part isa Insulator - mesh_size = min(mesh_size_current, mesh_size_next) - else - mesh_size = max(mesh_size_current, mesh_size_next) - end - else - mesh_size = mesh_size_current - end - - num_points_circumference = workspace.formulation.points_per_circumference - - # Create annular shape and assign marker - if radius_in ≈ 0 - # Solid disk - _, _, marker, _ = - draw_disk(x_center, y_center, radius_ext, mesh_size, num_points_circumference) - else - # Annular shape - _, _, marker, _ = draw_annular( - x_center, - y_center, - radius_in, - radius_ext, - mesh_size, - num_points_circumference, - ) - end - - # Create entity data - core_data = CoreEntityData(physical_group_tag, elementary_name, mesh_size) - entity_data = CablePartEntity(core_data, part) - - # Add to workspace in the unassigned container for subsequent processing - workspace.unassigned_entities[marker] = entity_data - - # Add physical groups to the workspace - register_physical_group!(workspace, physical_group_tag, part.material_props) - - - -end - -""" -$(TYPEDSIGNATURES) - -Specialized method to create individual wire entities for `WireArray` parts. - -# Arguments - -- `workspace`: The [`FEMWorkspace`](@ref) containing the model parameters. -- `part`: The [`WireArray`](@ref) to create. -- `cable_idx`: The index of the cable. -- `comp_idx`: The index of the component. -- `comp_id`: The ID of the component. -- `phase`: The phase assignment. -- `layer_idx`: The index of the layer. - -# Returns - -- Nothing. Updates the conductors vector in the workspace. - -# Examples - -```julia -$(FUNCTIONNAME)(workspace, part, 1, 1, "core", 1, 1) -``` -""" -function _make_cablepart!(workspace::FEMWorkspace, part::WireArray, - cable_idx::Int, comp_idx::Int, comp_id::String, - phase::Int, layer_idx::Int) - - # Get the cable definition - cable_position = workspace.problem_def.system.cables[cable_idx] - - # Get the center coordinates - x_center = to_nominal(cable_position.horz) - y_center = to_nominal(cable_position.vert) - - # Determine material group directly from part type - material_group = get_material_group(part) - - # Get or register material ID - material_id = get_or_register_material_id(workspace, part.material_props) - - # Create physical tag with new encoding scheme - physical_group_tag = encode_physical_group_tag( - 1, # Surface type 1 = cable component - cable_idx, # Cable number - comp_idx, # Component number - material_group, # Material group from part type - material_id, # Material ID from registry - ) - - # -------- First handle the wires - - # Create physical name - part_type = lowercase(string(nameof(typeof(part)))) - - # Extract parameters - radius_in = to_nominal(part.radius_in) - radius_ext = to_nominal(part.radius_ext) - - radius_wire = to_nominal(part.radius_wire) - num_wires = part.num_wires - - - # Calculate mesh size for this part - num_elements = workspace.formulation.elements_per_length_conductor - mesh_size_current = - _calc_mesh_size(radius_in, radius_ext, part.material_props, num_elements, workspace) - - # Calculate mesh size for the next part - num_layers = - length(cable_position.design_data.components[comp_idx].conductor_group.layers) - next_part = - layer_idx < num_layers ? - cable_position.design_data.components[comp_idx].conductor_group.layers[layer_idx+1] : - nothing - - if !isnothing(next_part) - next_radius_in = to_nominal(next_part.radius_in) - next_radius_ext = to_nominal(next_part.radius_ext) - mesh_size_next = _calc_mesh_size( - next_radius_in, - next_radius_ext, - next_part.material_props, - num_elements, - workspace, - ) - mesh_size = max(mesh_size_current, mesh_size_next) - else - mesh_size = mesh_size_current - end - - # A single wire without air gaps - is_single_wire = - (num_wires == 1) && (isnothing(next_part) || !(next_part isa WireArray)) - - - - num_points_circumference = workspace.formulation.points_per_circumference - - # Calculate wire positions - function _calc_wirearray_coords( - num_wires::Number, - # radius_wire::Number, - radius_in::Number, - radius_ext::Number; - C = (0.0, 0.0), - ) - wire_coords = [] # Global coordinates of all wires - - lay_radius = num_wires == 1 ? 0 : (radius_in + radius_ext) / 2 - - # Calculate the angle between each wire - angle_step = 2 * π / num_wires - for i in 0:(num_wires-1) - angle = i * angle_step - x = C[1] + lay_radius * cos(angle) - y = C[2] + lay_radius * sin(angle) - push!(wire_coords, (x, y)) # Add wire center - end - return wire_coords - end - - wire_positions = - _calc_wirearray_coords(num_wires, radius_in, radius_ext, C = (x_center, y_center)) - - # Create wires - TOL = is_single_wire ? 0 : 5e-6 # Shrink the radius to avoid overlapping boundaries, this must be greater than Gmsh geometry tolerance - for (wire_idx, (wx, wy)) in enumerate(wire_positions) - - _, _, marker, _ = - draw_disk(wx, wy, radius_wire - TOL, mesh_size, num_points_circumference) - - # Create wire name - elementary_name = create_cable_elementary_name( - cable_idx = cable_idx, - component_id = comp_id, - group_type = material_group, - part_type = part_type, - layer_idx = layer_idx, - phase = phase, - wire_idx = wire_idx, - ) - - # Create entity data - core_data = CoreEntityData(physical_group_tag, elementary_name, mesh_size) - entity_data = CablePartEntity(core_data, part) - - # Add to workspace - workspace.unassigned_entities[marker] = entity_data - end - # Add physical groups to the workspace - register_physical_group!(workspace, physical_group_tag, part.material_props) - - # Handle WireArray outermost boundary - mesh_size = (radius_ext - radius_in) - if !(next_part isa WireArray) && !isnothing(next_part) - # step_angle = 2 * pi / num_wires - add_mesh_points( - radius_in = radius_ext, - radius_ext = radius_ext, - theta_0 = 0, - theta_1 = 2 * pi, - mesh_size = mesh_size, - num_points_ang = num_points_circumference, - num_points_rad = 0, - C = (x_center, y_center), - theta_offset = 0, #step_angle / 2 - ) - end - - # Create air gaps for: - # - Multiple wires (always) - # - Single wire IF next part is a WireArray - # Skip ONLY for single wire when next part is not a WireArray - if !is_single_wire - # Air gaps will be determined from the boolean fragmentation operation and do not need to be drawn. Only the markers are needed. - markers_air_gap = get_air_gap_markers(num_wires, radius_wire, radius_in) - - # Adjust air gap markers to cable center - for marker in markers_air_gap - marker[1] += x_center - marker[2] += y_center - end - - # Determine material group - air gaps map to insulators - material_group = 2 - - # Get air material - air_material = get_air_material(workspace) - - # Get or register material ID - material_id = get_or_register_material_id(workspace, air_material) - - # Create physical tag with new encoding scheme - physical_group_tag_air_gap = encode_physical_group_tag( - 1, # Surface type 1 = cable component - cable_idx, # Cable number - comp_idx, # Component number - material_group, # Material group from part type - material_id, # Material ID from registry - ) - - for marker in markers_air_gap - # elementary names are not assigned to the air gaps because they are not drawn and appear as a result of the boolean operation - core_data = CoreEntityData(physical_group_tag_air_gap, "", mesh_size) - entity_data = SurfaceEntity(core_data, air_material) - - # Add to unassigned entities with type information - workspace.unassigned_entities[marker] = entity_data - end - - # Add physical groups to the workspace - register_physical_group!(workspace, physical_group_tag_air_gap, air_material) - end -end +""" +Cable geometry creation functions for the FEMTools.jl module. +These functions handle the creation of cable components. +""" + +""" +$(TYPEDSIGNATURES) + +Create the cable geometry for all cables in the system. + +# Arguments + +- `workspace`: The [`FEMWorkspace`](@ref) containing the model parameters. + +# Returns + +- Nothing. Updates the conductors and insulators vectors in the workspace. + +# Examples + +```julia +$(FUNCTIONNAME)(workspace) +``` +""" +function make_cable_geometry(workspace::FEMWorkspace) + + # Get the cable system + cable_system = workspace.core.system + + # Process each cable in the system + for (cable_idx, cable_position) in enumerate(cable_system.cables) + @info "Processing cable $(cable_idx) at position ($(cable_position.horz), $(cable_position.vert))" + + # Get the cable design + cable_design = cable_position.design_data + + # Get the phase assignments + phase_assignments = cable_position.conn + + # Process each component in the cable + for (comp_idx, component) in enumerate(cable_design.components) + # Get the component ID + comp_id = component.id + + # Get the phase assignment for this component + phase = comp_idx <= length(phase_assignments) ? phase_assignments[comp_idx] : 0 + + @debug "Processing component $(comp_id) (phase $(phase))" + + # Process conductor group + if !isnothing(component.conductor_group) + @debug "Processing conductor group for component $(comp_id)" + + # Process each layer in the conductor group + for (layer_idx, layer) in enumerate(component.conductor_group.layers) + @debug "Processing conductor layer $(layer_idx)" + + # Create the cable part + _make_cablepart!( + workspace, + layer, + cable_idx, + comp_idx, + comp_id, + phase, + layer_idx, + ) + end + end + + # Process insulator group + if !isnothing(component.insulator_group) + @debug "Processing insulator group for component $(comp_id)" + + # Process each layer in the insulator group + for (layer_idx, layer) in enumerate(component.insulator_group.layers) + @debug "Processing insulator layer $(layer_idx)" + + # Create the cable part + _make_cablepart!( + workspace, + layer, + cable_idx, + comp_idx, + comp_id, + phase, + layer_idx, + ) + end + end + end + end + + @info "Cable geometry created" +end + +""" +$(TYPEDSIGNATURES) + +Create a cable part entity for all tubular shapes. + +# Arguments + +- `workspace`: The [`FEMWorkspace`](@ref) containing the model parameters. +- `part`: The [`AbstractCablePart`](@ref) to create. +- `cable_idx`: The index of the cable. +- `comp_idx`: The index of the component. +- `comp_id`: The ID of the component. +- `phase`: The phase assignment. +- `layer_idx`: The index of the layer. + +# Returns + +- Nothing. Updates the conductors or insulators vector in the workspace. + +# Examples + +```julia +$(FUNCTIONNAME)(workspace, part, 1, 1, "core", 1, 1) +``` +""" +function _make_cablepart!(workspace::FEMWorkspace, part::AbstractCablePart, + cable_idx::Int, comp_idx::Int, comp_id::String, + phase::Int, layer_idx::Int) + + # Get the cable definition + cable_position = workspace.core.system.cables[cable_idx] + + # Get the center coordinates + x_center = to_nominal(cable_position.horz) + y_center = to_nominal(cable_position.vert) + + # Determine material group directly from part type + material_group = get_material_group(part) + + # Get or register material ID + material_id = get_or_register_material_id(workspace, part.material_props) + + # Create physical tag with new encoding scheme + physical_group_tag = encode_physical_group_tag( + 1, # Surface type 1 = cable component + cable_idx, # Cable number + comp_idx, # Component number + material_group, # Material group from part type + material_id, # Material ID from registry + ) + + # Create physical name + part_type = lowercase(string(nameof(typeof(part)))) + elementary_name = create_cable_elementary_name( + cable_idx = cable_idx, + component_id = comp_id, + group_type = material_group, + part_type = part_type, + layer_idx = layer_idx, + phase = phase, + ) + + # Extract parameters + radius_in = to_nominal(part.radius_in) + radius_ext = to_nominal(part.radius_ext) + + # Calculate mesh size for this part + if part isa AbstractConductorPart + num_elements = workspace.core.formulation.elements_per_length_conductor + elseif part isa Insulator + num_elements = workspace.core.formulation.elements_per_length_insulator + elseif part isa Semicon + num_elements = workspace.core.formulation.elements_per_length_semicon + end + + mesh_size_current = + _calc_mesh_size(radius_in, radius_ext, part.material_props, num_elements, workspace) + + # Calculate mesh size for the next part + num_layers = + length(cable_position.design_data.components[comp_idx].conductor_group.layers) + next_part = + layer_idx < num_layers ? + cable_position.design_data.components[comp_idx].conductor_group.layers[layer_idx+1] : + nothing + + if !isnothing(next_part) + next_radius_in = to_nominal(next_part.radius_in) + next_radius_ext = to_nominal(next_part.radius_ext) + mesh_size_next = _calc_mesh_size( + next_radius_in, + next_radius_ext, + next_part.material_props, + num_elements, + workspace, + ) + if next_part isa Insulator + mesh_size = min(mesh_size_current, mesh_size_next) + else + mesh_size = max(mesh_size_current, mesh_size_next) + end + else + mesh_size = mesh_size_current + end + + num_points_circumference = workspace.core.formulation.points_per_circumference + + # Create annular shape and assign marker + if radius_in ≈ 0 + # Solid disk + _, _, marker, _ = + draw_disk(x_center, y_center, radius_ext, mesh_size, num_points_circumference) + else + # Annular shape + _, _, marker, _ = draw_annular( + x_center, + y_center, + radius_in, + radius_ext, + mesh_size, + num_points_circumference, + ) + end + + # Create entity data + core_data = CoreEntityData(physical_group_tag, elementary_name, mesh_size) + entity_data = CablePartEntity(core_data, part) + + # Add to workspace in the unassigned container for subsequent processing + workspace.core.unassigned_entities[marker] = entity_data + + # Add physical groups to the workspace + register_physical_group!(workspace, physical_group_tag, part.material_props) + + + +end + +""" +$(TYPEDSIGNATURES) + +Specialized method to create individual wire entities for `WireArray` parts. + +# Arguments + +- `workspace`: The [`FEMWorkspace`](@ref) containing the model parameters. +- `part`: The [`WireArray`](@ref) to create. +- `cable_idx`: The index of the cable. +- `comp_idx`: The index of the component. +- `comp_id`: The ID of the component. +- `phase`: The phase assignment. +- `layer_idx`: The index of the layer. + +# Returns + +- Nothing. Updates the conductors vector in the workspace. + +# Examples + +```julia +$(FUNCTIONNAME)(workspace, part, 1, 1, "core", 1, 1) +``` +""" +function _make_cablepart!(workspace::FEMWorkspace, part::WireArray, + cable_idx::Int, comp_idx::Int, comp_id::String, + phase::Int, layer_idx::Int) + + # Get the cable definition + cable_position = workspace.core.system.cables[cable_idx] + + # Get the center coordinates + x_center = to_nominal(cable_position.horz) + y_center = to_nominal(cable_position.vert) + + # Determine material group directly from part type + material_group = get_material_group(part) + + # Get or register material ID + material_id = get_or_register_material_id(workspace, part.material_props) + + # Create physical tag with new encoding scheme + physical_group_tag = encode_physical_group_tag( + 1, # Surface type 1 = cable component + cable_idx, # Cable number + comp_idx, # Component number + material_group, # Material group from part type + material_id, # Material ID from registry + ) + + # -------- First handle the wires + + # Create physical name + part_type = lowercase(string(nameof(typeof(part)))) + + # Extract parameters + radius_in = to_nominal(part.radius_in) + radius_ext = to_nominal(part.radius_ext) + + radius_wire = to_nominal(part.radius_wire) + num_wires = part.num_wires + + + # Calculate mesh size for this part + num_elements = workspace.core.formulation.elements_per_length_conductor + mesh_size_current = + _calc_mesh_size(radius_in, radius_ext, part.material_props, num_elements, workspace) + + # Calculate mesh size for the next part + num_layers = + length(cable_position.design_data.components[comp_idx].conductor_group.layers) + next_part = + layer_idx < num_layers ? + cable_position.design_data.components[comp_idx].conductor_group.layers[layer_idx+1] : + nothing + + if !isnothing(next_part) + next_radius_in = to_nominal(next_part.radius_in) + next_radius_ext = to_nominal(next_part.radius_ext) + mesh_size_next = _calc_mesh_size( + next_radius_in, + next_radius_ext, + next_part.material_props, + num_elements, + workspace, + ) + mesh_size = max(mesh_size_current, mesh_size_next) + else + mesh_size = mesh_size_current + end + + # A single wire without air gaps + is_single_wire = + (num_wires == 1) && (isnothing(next_part) || !(next_part isa WireArray)) + + + + num_points_circumference = workspace.core.formulation.points_per_circumference + + # Calculate wire positions + function _calc_wirearray_coords( + num_wires::Number, + # radius_wire::Number, + radius_in::Number, + radius_ext::Number; + C = (0.0, 0.0), + ) + wire_coords = [] # Global coordinates of all wires + + lay_radius = num_wires == 1 ? 0 : (radius_in + radius_ext) / 2 + + # Calculate the angle between each wire + angle_step = 2 * π / num_wires + for i in 0:(num_wires-1) + angle = i * angle_step + x = C[1] + lay_radius * cos(angle) + y = C[2] + lay_radius * sin(angle) + push!(wire_coords, (x, y)) # Add wire center + end + return wire_coords + end + + wire_positions = + _calc_wirearray_coords(num_wires, radius_in, radius_ext, C = (x_center, y_center)) + + # Create wires + TOL = is_single_wire ? 0 : 5e-6 # Shrink the radius to avoid overlapping boundaries, this must be greater than Gmsh geometry tolerance + for (wire_idx, (wx, wy)) in enumerate(wire_positions) + + _, _, marker, _ = + draw_disk(wx, wy, radius_wire - TOL, mesh_size, num_points_circumference) + + # Create wire name + elementary_name = create_cable_elementary_name( + cable_idx = cable_idx, + component_id = comp_id, + group_type = material_group, + part_type = part_type, + layer_idx = layer_idx, + phase = phase, + wire_idx = wire_idx, + ) + + # Create entity data + core_data = CoreEntityData(physical_group_tag, elementary_name, mesh_size) + entity_data = CablePartEntity(core_data, part) + + # Add to workspace + workspace.core.unassigned_entities[marker] = entity_data + end + # Add physical groups to the workspace + register_physical_group!(workspace, physical_group_tag, part.material_props) + + # Handle WireArray outermost boundary + mesh_size = (radius_ext - radius_in) + if !(next_part isa WireArray) && !isnothing(next_part) + # step_angle = 2 * pi / num_wires + add_mesh_points( + radius_in = radius_ext, + radius_ext = radius_ext, + theta_0 = 0, + theta_1 = 2 * pi, + mesh_size = mesh_size, + num_points_ang = num_points_circumference, + num_points_rad = 0, + C = (x_center, y_center), + theta_offset = 0, #step_angle / 2 + ) + end + + # Create air gaps for: + # - Multiple wires (always) + # - Single wire IF next part is a WireArray + # Skip ONLY for single wire when next part is not a WireArray + if !is_single_wire + # Air gaps will be determined from the boolean fragmentation operation and do not need to be drawn. Only the markers are needed. + markers_air_gap = get_air_gap_markers(num_wires, radius_wire, radius_in) + + # Adjust air gap markers to cable center + for marker in markers_air_gap + marker[1] += x_center + marker[2] += y_center + end + + # Determine material group - air gaps map to insulators + material_group = 2 + + # Get air material + air_material = get_air_material(workspace) + + # Get or register material ID + material_id = get_or_register_material_id(workspace, air_material) + + # Create physical tag with new encoding scheme + physical_group_tag_air_gap = encode_physical_group_tag( + 1, # Surface type 1 = cable component + cable_idx, # Cable number + comp_idx, # Component number + material_group, # Material group from part type + material_id, # Material ID from registry + ) + + for marker in markers_air_gap + # elementary names are not assigned to the air gaps because they are not drawn and appear as a result of the boolean operation + core_data = CoreEntityData(physical_group_tag_air_gap, "", mesh_size) + entity_data = SurfaceEntity(core_data, air_material) + + # Add to unassigned entities with type information + workspace.core.unassigned_entities[marker] = entity_data + end + + # Add physical groups to the workspace + register_physical_group!(workspace, physical_group_tag_air_gap, air_material) + end +end diff --git a/src/engine/fem/drawing.jl b/src/engine/fem/drawing.jl index 669b2f5c..7e16789e 100644 --- a/src/engine/fem/drawing.jl +++ b/src/engine/fem/drawing.jl @@ -1,740 +1,738 @@ -""" -Primitive drawing functions for the FEMTools.jl module. -These functions handle the creation of geometric entities in Gmsh. -""" - -function add_mesh_points(; - radius_ext::Number, - theta_0::Number, - theta_1::Number, - mesh_size::Number, - radius_in::Number = 0.0, - num_points_ang::Integer = 8, - num_points_rad::Integer = 0, - C::Tuple{Number, Number} = (0.0, 0.0), - theta_offset::Number = 0.0) - - point_tags = Vector{Int}() - center_x, center_y = C - - # Handle special cases - if num_points_ang <= 0 && num_points_rad <= 0 - # Single point at center C - point_tag = gmsh.model.occ.add_point(center_x, center_y, 0.0, mesh_size) - gmsh.model.set_entity_name( - 0, - point_tag, - "mesh_size_$(round(mesh_size, sigdigits=6))", - ) - return [point_tag] - end - - # Circular arc (default case or when num_points_rad=0) - if num_points_rad == 0 - r = radius_ext # Use external radius as default - np_ang = max(2, num_points_ang) # At least 2 points for an arc - - for i in 0:(np_ang-1) - t_ang = i / (np_ang - 1) - theta = theta_0 + t_ang * (theta_1 - theta_0) + theta_offset - - x = center_x + r * cos(theta) - y = center_y + r * sin(theta) - - point_tag = gmsh.model.occ.add_point(x, y, 0.0, mesh_size) - gmsh.model.set_entity_name( - 0, - point_tag, - "mesh_size_$(round(mesh_size, sigdigits=6))", - ) - push!(point_tags, point_tag) - end - - return point_tags - end - - # Radial line (when theta_0 == theta_1) - if theta_0 == theta_1 - theta = theta_0 + theta_offset - np_rad = max(2, num_points_rad) - - for j in 0:(np_rad-1) - t_rad = j / (np_rad - 1) - r = radius_in + t_rad * (radius_ext - radius_in) - - x = center_x + r * cos(theta) - y = center_y + r * sin(theta) - - point_tag = gmsh.model.occ.add_point(x, y, 0.0, mesh_size) - gmsh.model.set_entity_name( - 0, - point_tag, - "mesh_size_$(round(mesh_size, sigdigits=6))", - ) - push!(point_tags, point_tag) - end - - return point_tags - end - - # 2D array of points (both radial and angular) - np_rad = max(2, num_points_rad) - np_ang = max(2, num_points_ang) - - for j in 0:(np_rad-1) - t_rad = j / (np_rad - 1) - r = radius_in + t_rad * (radius_ext - radius_in) - - for i in 0:(np_ang-1) - t_ang = i / (np_ang - 1) - theta = theta_0 + t_ang * (theta_1 - theta_0) + theta_offset - * - x = center_x + r * cos(theta) - y = center_y + r * sin(theta) - - point_tag = gmsh.model.occ.add_point(x, y, 0.0, mesh_size) - gmsh.model.set_entity_name( - 0, - point_tag, - "mesh_size_$(round(mesh_size, sigdigits=6))", - ) - push!(point_tags, point_tag) - end - end - - return point_tags -end - - -""" -$(TYPEDSIGNATURES) - -Draw a point with specified coordinates and mesh size. - -# Arguments - -- `x`: X-coordinate \\[m\\]. -- `y`: Y-coordinate \\[m\\]. -- `z`: Z-coordinate \\[m\\]. - -# Returns - -- Gmsh point tag \\[dimensionless\\]. - -# Examples - -```julia -point_tag = $(FUNCTIONNAME)(0.0, 0.0, 0.0, 0.01) -``` -""" -function draw_point(x::Number, y::Number, z::Number) - return gmsh.model.occ.add_point(x, y, z) -end - -""" -$(TYPEDSIGNATURES) - -Draw a line between two points. - -# Arguments - -- `x1`: X-coordinate of the first point \\[m\\]. -- `y1`: Y-coordinate of the first point \\[m\\]. -- `x2`: X-coordinate of the second point \\[m\\]. -- `y2`: Y-coordinate of the second point \\[m\\]. - -# Returns - -- Gmsh line tag \\[dimensionless\\]. - -# Examples - -```julia -line_tag = $(FUNCTIONNAME)(0.0, 0.0, 1.0, 0.0, 0.01) -``` -""" -function draw_line( - x1::Number, - y1::Number, - x2::Number, - y2::Number, - mesh_size::Number, - num_points::Number, -) - - # Calculate line parameters - line_length = sqrt((x2 - x1)^2 + (y2 - y1)^2) - x_center = (x1 + x2) / 2 - y_center = (y1 + y2) / 2 - - # Calculate angle in polar coordinates (in radians) - theta = atan(y2 - y1, x2 - x1) - - # Use the distance as a "domain radius" for placing mesh points - radius = line_length / 2 - - # Create a unique marker for this line - marker = [x_center, y_center, 0.0] # Center of the line - - marker_tag = gmsh.model.occ.add_point(marker[1], marker[2], marker[3], mesh_size) - gmsh.model.set_entity_name(0, marker_tag, "marker_$(round(mesh_size, sigdigits=6))") - - mesh_points = add_mesh_points( - radius_in = -radius, - radius_ext = radius, - theta_0 = theta, - theta_1 = theta, - mesh_size = mesh_size, - num_points_ang = 0, - num_points_rad = num_points, # Not strictly a circumference, but the trick works - C = (x_center, y_center), - ) - - tag = gmsh.model.occ.add_line(mesh_points[1], mesh_points[end]) - - # Add midpoint markers between each pair of mesh points - segment_markers = Vector{Vector{Float64}}() - push!(segment_markers, marker) - - if length(mesh_points) >= 2 - # Iterate through adjacent pairs of mesh points - for i in 1:(num_points-1) - t = (i - 0.5) / (num_points - 1) # Parametric coordinate (0.5 between points) - mid_x = x1 + t * (x2 - x1) - mid_y = y1 + t * (y2 - y1) - # Create marker at midpoint - mid_marker = [mid_x, mid_y, 0.0] - push!(segment_markers, mid_marker) - end - end - - return tag, mesh_points, segment_markers -end - -""" -$(TYPEDSIGNATURES) - -Draw a circular disk with specified center and radius. - -# Arguments - -- `x`: X-coordinate of the center \\[m\\]. -- `y`: Y-coordinate of the center \\[m\\]. -- `radius`: Radius of the disk \\[m\\]. - -# Returns - -- Gmsh surface tag \\[dimensionless\\]. - -# Examples - -```julia -disk_tag = $(FUNCTIONNAME)(0.0, 0.0, 0.5, 0.01) -``` -""" -function draw_disk( - x::Number, - y::Number, - radius::Number, - mesh_size::Number, - num_points::Number, -) - - tag = gmsh.model.occ.add_disk(x, y, 0.0, radius, radius) - - mesh_points = add_mesh_points( - radius_in = radius, - radius_ext = radius, - theta_0 = 0, - theta_1 = 2 * pi, - mesh_size = mesh_size, - num_points_ang = num_points, - C = (x, y), - theta_offset = 0, #pi / 15 - ) - - - marker = [x, y + 0.99 * radius, 0.0] # A very small offset inwards the circle - marker_tag = gmsh.model.occ.add_point(marker[1], marker[2], marker[3], mesh_size) - gmsh.model.set_entity_name(0, marker_tag, "marker_$(round(mesh_size, sigdigits=6))") - - # Add midpoint markers between each pair of mesh points - arc_markers = Vector{Vector{Float64}}() - - if num_points >= 2 - # Calculate the angular step between mesh points - theta_step = 2 * pi / num_points - - # Add a midpoint marker for each arc segment - for i in 1:num_points - # Calculate midpoint theta (angle) - theta_mid = (i - 0.5) * theta_step - - # Calculate midpoint coordinates - mid_x = x + radius * cos(theta_mid) - mid_y = y + radius * sin(theta_mid) - - # Create marker at the midpoint - mid_marker = [mid_x, mid_y, 0.0] - - push!(arc_markers, mid_marker) - end - end - - return tag, mesh_points, marker, arc_markers -end - - -""" -$(TYPEDSIGNATURES) - -Draw an annular (ring) shape with specified center, inner radius, and outer radius. - -# Arguments - -- `x`: X-coordinate of the center \\[m\\]. -- `y`: Y-coordinate of the center \\[m\\]. -- `radius_in`: Inner radius of the annular shape \\[m\\]. -- `radius_ext`: Outer radius of the annular shape \\[m\\]. - -# Returns - -- Gmsh surface tag \\[dimensionless\\]. - -# Examples - -```julia -annular_tag = $(FUNCTIONNAME)(0.0, 0.0, 0.3, 0.5, 0.01) -``` -""" -function draw_annular( - x::Number, - y::Number, - radius_in::Number, - radius_ext::Number, - mesh_size::Number, - num_points::Number; - inner_points::Bool = false, -) - # Create outer disk - outer_disk = gmsh.model.occ.add_disk(x, y, 0.0, radius_ext, radius_ext) - - # Create inner disk - inner_disk = gmsh.model.occ.add_disk(x, y, 0.0, radius_in, radius_in) - - # Cut inner disk from outer disk to create annular shape - annular_obj, _ = gmsh.model.occ.cut([(2, outer_disk)], [(2, inner_disk)]) - - # Return the tag of the resulting surface - if length(annular_obj) > 0 - tag = annular_obj[1][2] - else - Base.error("Failed to create annular shape.") - end - - mesh_points = add_mesh_points( - radius_in = radius_ext, - radius_ext = radius_ext, - theta_0 = 0, - theta_1 = 2 * pi, - mesh_size = mesh_size, - num_points_ang = num_points, - C = (x, y), - theta_offset = 0, #pi / 15 - ) - - if inner_points - mesh_points = add_mesh_points( - radius_in = radius_in, - radius_ext = radius_in, - theta_0 = 0, - theta_1 = 2 * pi, - mesh_size = mesh_size, - num_points_ang = num_points, - C = (x, y), - theta_offset = pi / 3, - ) - end - - marker = [x, y + (radius_in + 0.99 * (radius_ext - radius_in)), 0.0] - marker_tag = gmsh.model.occ.add_point(marker[1], marker[2], marker[3], mesh_size) - gmsh.model.set_entity_name(0, marker_tag, "marker_$(round(mesh_size, sigdigits=6))") - - # Add midpoint markers between each pair of mesh points - arc_markers = Vector{Vector{Float64}}() - - if num_points >= 2 - # Calculate the angular step between mesh points - theta_step = 2 * pi / num_points - - # Add a midpoint marker for each arc segment - for i in 1:num_points - # Calculate midpoint theta (angle) - theta_mid = (i - 0.5) * theta_step - - # Calculate midpoint coordinates - mid_x = x + radius_ext * cos(theta_mid) - mid_y = y + radius_ext * sin(theta_mid) - - # Create marker at the midpoint - mid_marker = [mid_x, mid_y, 0.0] - - push!(arc_markers, mid_marker) - end - end - - return tag, mesh_points, marker, arc_markers -end - - -""" -$(TYPEDSIGNATURES) - -Draw a rectangle with specified center, width, and height. - -# Arguments - -- `x`: X-coordinate of the center \\[m\\]. -- `y`: Y-coordinate of the center \\[m\\]. -- `width`: Width of the rectangle \\[m\\]. -- `height`: Height of the rectangle \\[m\\]. - -# Returns - -- Gmsh surface tag \\[dimensionless\\]. - -# Examples - -```julia -rect_tag = $(FUNCTIONNAME)(0.0, 0.0, 1.0, 0.5, 0.01) -``` -""" -function draw_rectangle(x::Number, y::Number, width::Number, height::Number) - # Calculate corner coordinates - x1 = x - width / 2 - y1 = y - height / 2 - x2 = x + width / 2 - y2 = y + height / 2 - - # Create rectangle - return gmsh.model.occ.add_rectangle(x1, y1, 0.0, width, height) -end - -""" -$(TYPEDSIGNATURES) - -Draw a circular arc between two points with a specified center. - -# Arguments - -- `x1`: X-coordinate of the first point \\[m\\]. -- `y1`: Y-coordinate of the first point \\[m\\]. -- `x2`: X-coordinate of the second point \\[m\\]. -- `y2`: Y-coordinate of the second point \\[m\\]. -- `xc`: X-coordinate of the center \\[m\\]. -- `yc`: Y-coordinate of the center \\[m\\]. - -# Returns - -- Gmsh curve tag \\[dimensionless\\]. - -# Examples - -```julia -arc_tag = $(FUNCTIONNAME)(1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.01) -``` -""" -function draw_arc(x1::Number, y1::Number, x2::Number, y2::Number, xc::Number, yc::Number) - p1 = gmsh.model.occ.add_point(x1, y1, 0.0) - p2 = gmsh.model.occ.add_point(x2, y2, 0.0) - pc = gmsh.model.occ.add_point(xc, yc, 0.0) - - return gmsh.model.occ.add_circle_arc(p1, pc, p2) -end - -""" -$(TYPEDSIGNATURES) - -Draw a circle with specified center and radius. - -# Arguments - -- `x`: X-coordinate of the center \\[m\\]. -- `y`: Y-coordinate of the center \\[m\\]. -- `radius`: Radius of the circle \\[m\\]. - -# Returns - -- Gmsh curve tag \\[dimensionless\\]. - -# Examples - -```julia -circle_tag = $(FUNCTIONNAME)(0.0, 0.0, 0.5, 0.01) -``` -""" -function draw_circle(x::Number, y::Number, radius::Number) - return gmsh.model.occ.add_circle(x, y, 0.0, radius) -end - -""" -$(TYPEDSIGNATURES) - -Draw a polygon with specified vertices. - -# Arguments - -- `vertices`: Array of (x,y) coordinates for the vertices \\[m\\]. - -# Returns - -- Gmsh surface tag \\[dimensionless\\]. - -# Examples - -```julia -vertices = [(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)] -polygon_tag = $(FUNCTIONNAME)(vertices, 0.01) -``` -""" -function draw_polygon(vertices::Vector{<:Tuple{<:Number, <:Number}}) - # Create points - points = Vector{Int}() - for (x, y) in vertices - push!(points, gmsh.model.occ.add_point(x, y, 0.0)) - end - - # Create lines - lines = Vector{Int}() - for i in 1:length(points) - next_i = i % length(points) + 1 - push!(lines, gmsh.model.occ.add_line(points[i], points[next_i])) - end - - # Create curve loop - curve_loop = gmsh.model.occ.add_curve_loop(lines) - - # Create surface - return gmsh.model.occ.add_plane_surface([curve_loop]) -end - -function draw_transition_region( - x::Number, - y::Number, - radii::Vector{<:Number}, - mesh_sizes::Vector{<:Number}, - num_points::Number, -) - # Validate inputs - if length(radii) != length(mesh_sizes) - Base.error("Radii and mesh_sizes vectors must have the same length") - end - - n_regions = length(radii) - if n_regions < 1 - Base.error("At least one radius must be provided") - end - - # Sort radii in ascending order if not already sorted - if !issorted(radii) - p = sortperm(radii) - radii = radii[p] - mesh_sizes = mesh_sizes[p] - end - - # Note to future self: the reason why I did this is because in the edge case when the bounding box coincides with the cable outermost radius (i.e. when you want the transition region around 1 single cable and not several), the earth marker ends up inside the cable region, which present me does not need to explain to future me why it's bad. - - rad_buffer = 0.001 - radii[1] += rad_buffer # Ensure the innermost radius is slightly larger than zero to avoid ambiguous regions - tags = Int[] - all_mesh_points = Int[] - markers = Vector{Vector{Float64}}() - - # Create all disks - disk_tags = Int[] - for i in 1:n_regions - disk_tag = gmsh.model.occ.add_disk(x, y, 0.0, radii[i], radii[i]) - gmsh.model.occ.synchronize() - push!(disk_tags, disk_tag) - end - - # Add the innermost disk to output - push!(tags, disk_tags[1]) - - # Add mesh points for innermost disk - inner_mesh_points = add_mesh_points( - radius_in = radii[1], - radius_ext = radii[1], - theta_0 = 0, - theta_1 = 2 * pi, - mesh_size = mesh_sizes[1], - num_points_ang = num_points, - C = (x, y), - theta_offset = 0, - ) - append!(all_mesh_points, inner_mesh_points) - - # Create marker at the midpoint between the real radius and the added buffer - # Feel free to implement a less dumb way to do this - radius_inner_marker = (radii[1] + radii[1] - rad_buffer) / 2 - inner_marker = [x, y + radius_inner_marker, 0.0] - - marker_tag = gmsh.model.occ.add_point( - inner_marker[1], - inner_marker[2], - inner_marker[3], - mesh_sizes[1], - ) - gmsh.model.set_entity_name(0, marker_tag, "marker_$(round(mesh_sizes[1], sigdigits=6))") - push!(markers, inner_marker) - - # Synchronize the model - gmsh.model.occ.synchronize() - - # Create annular regions for the rest - for i in 2:n_regions - # Cut the inner disk from the outer disk - annular_obj, _ = - gmsh.model.occ.cut([(2, disk_tags[i])], [(2, disk_tags[i-1])], false, false) - - # Get the resulting surface tag - if length(annular_obj) > 0 - annular_tag = annular_obj[1][2] - push!(tags, annular_tag) - - # Add mesh points on the boundary - boundary_points = add_mesh_points( - radius_in = radii[i], - radius_ext = radii[i], - theta_0 = 0, - theta_1 = 2 * pi, - mesh_size = mesh_sizes[i], - num_points_ang = num_points, - C = (x, y), - theta_offset = 0, - ) - append!(all_mesh_points, boundary_points) - - # Create marker at 99% of the way from inner to outer radius - radius_marker = radii[i-1] + 0.99 * (radii[i] - radii[i-1]) - annular_marker = [x, y + radius_marker, 0.0] - marker_tag = gmsh.model.occ.add_point( - annular_marker[1], - annular_marker[2], - annular_marker[3], - mesh_sizes[i], - ) - gmsh.model.set_entity_name( - 0, - marker_tag, - "marker_$(round(mesh_sizes[i], sigdigits=6))", - ) - push!(markers, annular_marker) - else - Base.error( - "Failed to create annular region for radii $(radii[i-1]) and $(radii[i])", - ) - end - end - - return tags, all_mesh_points, markers -end - -function get_system_centroid(cable_system::LineCableSystem, cable_idx::Vector{<:Integer}) - # Check if cable_idx is empty - if isempty(cable_idx) - Base.error("Cable index vector cannot be empty") - end - - # Check if any index is out of bounds - if any(idx -> idx < 1 || idx > length(cable_system.cables), cable_idx) - Base.error("Cable index out of bounds") - end - - # Extract coordinates - horz_coords = [cable_system.cables[idx].horz for idx in cable_idx] - vert_coords = [cable_system.cables[idx].vert for idx in cable_idx] - - # Calculate centroid - centroid_x = sum(horz_coords) / length(horz_coords) - centroid_y = sum(vert_coords) / length(vert_coords) - - # Find the maximum distance from centroid to any cable's edge - max_distance = 0.0 - characteristic_len = Inf - - for idx in cable_idx - cable_position = cable_system.cables[idx] - - # Calculate distance from centroid to cable center - distance_to_center = sqrt( - (cable_position.horz - centroid_x)^2 + (cable_position.vert - centroid_y)^2, - ) - - # Get the outermost component (last component in the vector) - if !isempty(cable_position.design_data.components) - last_component = cable_position.design_data.components[end] - - outer_radius = last_component.insulator_group.radius_ext - - insulator_radius_in = last_component.insulator_group.layers[end].radius_in - last_layer_thickness = outer_radius - insulator_radius_in - - - # Add cable radius to get distance to edge - total_distance = distance_to_center + outer_radius - max_distance = max(max_distance, total_distance) - characteristic_len = min(characteristic_len, last_layer_thickness) - end - end - - return (centroid_x, centroid_y, max_distance, characteristic_len) -end - - -""" -$(TYPEDSIGNATURES) - -Calculate the coordinates of air gaps in a wire array. - -# Arguments - -- `num_wires`: Number of wires in the array \\[dimensionless\\]. -- `radius_wire`: Radius of each wire \\[m\\]. -- `radius_in`: Inner radius of the wire array \\[m\\]. - -# Returns - -- Vector of marker positions (3D coordinates) for air gaps \\[m\\]. - -# Notes - -This function calculates positions for markers that are guaranteed to be in the air gaps -between wires in a wire array. These markers are used to identify the air regions after -boolean fragmentation operations. - -# Examples - -```julia -markers = $(FUNCTIONNAME)(7, 0.002, 0.01) -``` -""" -function get_air_gap_markers(num_wires::Int, radius_wire::Number, radius_in::Number) - markers = Vector{Vector{Float64}}() - - lay_radius = radius_in + radius_wire - - num_angular_markers = num_wires == 1 ? 6 : num_wires - # For multiple wires, place markers between adjacent wires - angle_step = 2π / num_angular_markers - for i in 0:(num_angular_markers-1) - angle = i * angle_step + (angle_step / 2) # Midway between wires - r = lay_radius + (radius_wire / 2) # Slightly outward - x = r * cos(angle) - y = r * sin(angle) - push!(markers, [x, y, 0.0]) - end - return markers -end - - +""" +Primitive drawing functions for the FEMTools.jl module. +These functions handle the creation of geometric entities in Gmsh. +""" + +function add_mesh_points(; + radius_ext::Number, + theta_0::Number, + theta_1::Number, + mesh_size::Number, + radius_in::Number = 0.0, + num_points_ang::Integer = 8, + num_points_rad::Integer = 0, + C::Tuple{Number, Number} = (0.0, 0.0), + theta_offset::Number = 0.0) + + point_tags = Vector{Int}() + center_x, center_y = C + + # Handle special cases + if num_points_ang <= 0 && num_points_rad <= 0 + # Single point at center C + point_tag = gmsh.model.occ.add_point(center_x, center_y, 0.0, mesh_size) + gmsh.model.set_entity_name( + 0, + point_tag, + "mesh_size_$(round(mesh_size, sigdigits=6))", + ) + return [point_tag] + end + + # Circular arc (default case or when num_points_rad=0) + if num_points_rad == 0 + r = radius_ext # Use external radius as default + np_ang = max(2, num_points_ang) # At least 2 points for an arc + + for i in 0:(np_ang-1) + t_ang = i / (np_ang - 1) + theta = theta_0 + t_ang * (theta_1 - theta_0) + theta_offset + + x = center_x + r * cos(theta) + y = center_y + r * sin(theta) + + point_tag = gmsh.model.occ.add_point(x, y, 0.0, mesh_size) + gmsh.model.set_entity_name( + 0, + point_tag, + "mesh_size_$(round(mesh_size, sigdigits=6))", + ) + push!(point_tags, point_tag) + end + + return point_tags + end + + # Radial line (when theta_0 == theta_1) + if theta_0 == theta_1 + theta = theta_0 + theta_offset + np_rad = max(2, num_points_rad) + + for j in 0:(np_rad-1) + t_rad = j / (np_rad - 1) + r = radius_in + t_rad * (radius_ext - radius_in) + + x = center_x + r * cos(theta) + y = center_y + r * sin(theta) + + point_tag = gmsh.model.occ.add_point(x, y, 0.0, mesh_size) + gmsh.model.set_entity_name( + 0, + point_tag, + "mesh_size_$(round(mesh_size, sigdigits=6))", + ) + push!(point_tags, point_tag) + end + + return point_tags + end + + # 2D array of points (both radial and angular) + np_rad = max(2, num_points_rad) + np_ang = max(2, num_points_ang) + + for j in 0:(np_rad-1) + t_rad = j / (np_rad - 1) + r = radius_in + t_rad * (radius_ext - radius_in) + + for i in 0:(np_ang-1) + t_ang = i / (np_ang - 1) + theta = theta_0 + t_ang * (theta_1 - theta_0) + theta_offset + * + x = center_x + r * cos(theta) + y = center_y + r * sin(theta) + + point_tag = gmsh.model.occ.add_point(x, y, 0.0, mesh_size) + gmsh.model.set_entity_name( + 0, + point_tag, + "mesh_size_$(round(mesh_size, sigdigits=6))", + ) + push!(point_tags, point_tag) + end + end + + return point_tags +end + + +""" +$(TYPEDSIGNATURES) + +Draw a point with specified coordinates and mesh size. + +# Arguments + +- `x`: X-coordinate \\[m\\]. +- `y`: Y-coordinate \\[m\\]. +- `z`: Z-coordinate \\[m\\]. + +# Returns + +- Gmsh point tag \\[dimensionless\\]. + +# Examples + +```julia +point_tag = $(FUNCTIONNAME)(0.0, 0.0, 0.0, 0.01) +``` +""" +function draw_point(x::Number, y::Number, z::Number) + return gmsh.model.occ.add_point(x, y, z) +end + +""" +$(TYPEDSIGNATURES) + +Draw a line between two points. + +# Arguments + +- `x1`: X-coordinate of the first point \\[m\\]. +- `y1`: Y-coordinate of the first point \\[m\\]. +- `x2`: X-coordinate of the second point \\[m\\]. +- `y2`: Y-coordinate of the second point \\[m\\]. + +# Returns + +- Gmsh line tag \\[dimensionless\\]. + +# Examples + +```julia +line_tag = $(FUNCTIONNAME)(0.0, 0.0, 1.0, 0.0, 0.01) +``` +""" +function draw_line( + x1::Number, + y1::Number, + x2::Number, + y2::Number, + mesh_size::Number, + num_points::Number, +) + + # Calculate line parameters + line_length = sqrt((x2 - x1)^2 + (y2 - y1)^2) + x_center = (x1 + x2) / 2 + y_center = (y1 + y2) / 2 + + # Calculate angle in polar coordinates (in radians) + theta = atan(y2 - y1, x2 - x1) + + # Use the distance as a "domain radius" for placing mesh points + radius = line_length / 2 + + # Create a unique marker for this line + marker = [x_center, y_center, 0.0] # Center of the line + + marker_tag = gmsh.model.occ.add_point(marker[1], marker[2], marker[3], mesh_size) + gmsh.model.set_entity_name(0, marker_tag, "marker_$(round(mesh_size, sigdigits=6))") + + mesh_points = add_mesh_points( + radius_in = -radius, + radius_ext = radius, + theta_0 = theta, + theta_1 = theta, + mesh_size = mesh_size, + num_points_ang = 0, + num_points_rad = num_points, # Not strictly a circumference, but the trick works + C = (x_center, y_center), + ) + + tag = gmsh.model.occ.add_line(mesh_points[1], mesh_points[end]) + + # Add midpoint markers between each pair of mesh points + segment_markers = Vector{Vector{Float64}}() + push!(segment_markers, marker) + + if length(mesh_points) >= 2 + # Iterate through adjacent pairs of mesh points + for i in 1:(num_points*10-1) + t = (i - 0.5) / (num_points*10 - 1) # Parametric coordinate (0.5 between points) + mid_x = x1 + t * (x2 - x1) + mid_y = y1 + t * (y2 - y1) + # Create marker at midpoint + mid_marker = [mid_x, mid_y, 0.0] + push!(segment_markers, mid_marker) + end + end + + return tag, mesh_points, segment_markers +end + +""" +$(TYPEDSIGNATURES) + +Draw a circular disk with specified center and radius. + +# Arguments + +- `x`: X-coordinate of the center \\[m\\]. +- `y`: Y-coordinate of the center \\[m\\]. +- `radius`: Radius of the disk \\[m\\]. + +# Returns + +- Gmsh surface tag \\[dimensionless\\]. + +# Examples + +```julia +disk_tag = $(FUNCTIONNAME)(0.0, 0.0, 0.5, 0.01) +``` +""" +function draw_disk( + x::Number, + y::Number, + radius::Number, + mesh_size::Number, + num_points::Number, +) + + tag = gmsh.model.occ.add_disk(x, y, 0.0, radius, radius) + + mesh_points = add_mesh_points( + radius_in = radius, + radius_ext = radius, + theta_0 = 0, + theta_1 = 2 * pi, + mesh_size = mesh_size, + num_points_ang = num_points, + C = (x, y), + theta_offset = 0, #pi / 15 + ) + + + marker = [x, y + 0.99 * radius, 0.0] # A very small offset inwards the circle + marker_tag = gmsh.model.occ.add_point(marker[1], marker[2], marker[3], mesh_size) + gmsh.model.set_entity_name(0, marker_tag, "marker_$(round(mesh_size, sigdigits=6))") + + # Add midpoint markers between each pair of mesh points + arc_markers = Vector{Vector{Float64}}() + + if num_points >= 2 + # Calculate the angular step between mesh points + theta_step = 2 * pi / (num_points*1000) + + # Add a midpoint marker for each arc segment + for i in 1:(num_points*1000) + # Calculate midpoint theta (angle) + theta_mid = (i - 0.5) * theta_step + + # Calculate midpoint coordinates + mid_x = x + radius * cos(theta_mid) + mid_y = y + radius * sin(theta_mid) + + # Create marker at the midpoint + mid_marker = [mid_x, mid_y, 0.0] + + push!(arc_markers, mid_marker) + end + end + + return tag, mesh_points, marker, arc_markers +end + + +""" +$(TYPEDSIGNATURES) + +Draw an annular (ring) shape with specified center, inner radius, and outer radius. + +# Arguments + +- `x`: X-coordinate of the center \\[m\\]. +- `y`: Y-coordinate of the center \\[m\\]. +- `radius_in`: Inner radius of the annular shape \\[m\\]. +- `radius_ext`: Outer radius of the annular shape \\[m\\]. + +# Returns + +- Gmsh surface tag \\[dimensionless\\]. + +# Examples + +```julia +annular_tag = $(FUNCTIONNAME)(0.0, 0.0, 0.3, 0.5, 0.01) +``` +""" +function draw_annular( + x::Number, + y::Number, + radius_in::Number, + radius_ext::Number, + mesh_size::Number, + num_points::Number; + inner_points::Bool = false, +) + # Create outer disk + outer_disk = gmsh.model.occ.add_disk(x, y, 0.0, radius_ext, radius_ext) + + # Create inner disk + inner_disk = gmsh.model.occ.add_disk(x, y, 0.0, radius_in, radius_in) + + # Cut inner disk from outer disk to create annular shape + annular_obj, _ = gmsh.model.occ.cut([(2, outer_disk)], [(2, inner_disk)]) + + # Return the tag of the resulting surface + if length(annular_obj) > 0 + tag = annular_obj[1][2] + else + Base.error("Failed to create annular shape.") + end + + mesh_points = add_mesh_points( + radius_in = radius_ext, + radius_ext = radius_ext, + theta_0 = 0, + theta_1 = 2 * pi, + mesh_size = mesh_size, + num_points_ang = num_points, + C = (x, y), + theta_offset = 0, #pi / 15 + ) + + if inner_points + mesh_points = add_mesh_points( + radius_in = radius_in, + radius_ext = radius_in, + theta_0 = 0, + theta_1 = 2 * pi, + mesh_size = mesh_size, + num_points_ang = num_points, + C = (x, y), + theta_offset = pi / 3, + ) + end + + marker = [x, y + (radius_in + 0.99 * (radius_ext - radius_in)), 0.0] + marker_tag = gmsh.model.occ.add_point(marker[1], marker[2], marker[3], mesh_size) + gmsh.model.set_entity_name(0, marker_tag, "marker_$(round(mesh_size, sigdigits=6))") + + # Add midpoint markers between each pair of mesh points + arc_markers = Vector{Vector{Float64}}() + + if num_points >= 2 + # Calculate the angular step between mesh points + theta_step = 2 * pi / (num_points*1000) + + # Add a midpoint marker for each arc segment + for i in 1:(num_points*1000) + # Calculate midpoint theta (angle) + theta_mid = (i - 0.5) * theta_step + + # Calculate midpoint coordinates + mid_x = x + radius_ext * cos(theta_mid) + mid_y = y + radius_ext * sin(theta_mid) + + # Create marker at the midpoint + mid_marker = [mid_x, mid_y, 0.0] + + push!(arc_markers, mid_marker) + end + end + + return tag, mesh_points, marker, arc_markers +end + + +""" +$(TYPEDSIGNATURES) + +Draw a rectangle with specified center, width, and height. + +# Arguments + +- `x`: X-coordinate of the center \\[m\\]. +- `y`: Y-coordinate of the center \\[m\\]. +- `width`: Width of the rectangle \\[m\\]. +- `height`: Height of the rectangle \\[m\\]. + +# Returns + +- Gmsh surface tag \\[dimensionless\\]. + +# Examples + +```julia +rect_tag = $(FUNCTIONNAME)(0.0, 0.0, 1.0, 0.5, 0.01) +``` +""" +function draw_rectangle(x::Number, y::Number, width::Number, height::Number) + # Calculate corner coordinates + x1 = x - width / 2 + y1 = y - height / 2 + x2 = x + width / 2 + y2 = y + height / 2 + + # Create rectangle + return gmsh.model.occ.add_rectangle(x1, y1, 0.0, width, height) +end + +""" +$(TYPEDSIGNATURES) + +Draw a circular arc between two points with a specified center. + +# Arguments + +- `x1`: X-coordinate of the first point \\[m\\]. +- `y1`: Y-coordinate of the first point \\[m\\]. +- `x2`: X-coordinate of the second point \\[m\\]. +- `y2`: Y-coordinate of the second point \\[m\\]. +- `xc`: X-coordinate of the center \\[m\\]. +- `yc`: Y-coordinate of the center \\[m\\]. + +# Returns + +- Gmsh curve tag \\[dimensionless\\]. + +# Examples + +```julia +arc_tag = $(FUNCTIONNAME)(1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.01) +``` +""" +function draw_arc(x1::Number, y1::Number, x2::Number, y2::Number, xc::Number, yc::Number) + p1 = gmsh.model.occ.add_point(x1, y1, 0.0) + p2 = gmsh.model.occ.add_point(x2, y2, 0.0) + pc = gmsh.model.occ.add_point(xc, yc, 0.0) + + return gmsh.model.occ.add_circle_arc(p1, pc, p2) +end + +""" +$(TYPEDSIGNATURES) + +Draw a circle with specified center and radius. + +# Arguments + +- `x`: X-coordinate of the center \\[m\\]. +- `y`: Y-coordinate of the center \\[m\\]. +- `radius`: Radius of the circle \\[m\\]. + +# Returns + +- Gmsh curve tag \\[dimensionless\\]. + +# Examples + +```julia +circle_tag = $(FUNCTIONNAME)(0.0, 0.0, 0.5, 0.01) +``` +""" +function draw_circle(x::Number, y::Number, radius::Number) + return gmsh.model.occ.add_circle(x, y, 0.0, radius) +end + +""" +$(TYPEDSIGNATURES) + +Draw a polygon with specified vertices. + +# Arguments + +- `vertices`: Array of (x,y) coordinates for the vertices \\[m\\]. + +# Returns + +- Gmsh surface tag \\[dimensionless\\]. + +# Examples + +```julia +vertices = [(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)] +polygon_tag = $(FUNCTIONNAME)(vertices, 0.01) +``` +""" +function draw_polygon(vertices::Vector{<:Tuple{<:Number, <:Number}}) + # Create points + points = Vector{Int}() + for (x, y) in vertices + push!(points, gmsh.model.occ.add_point(x, y, 0.0)) + end + + # Create lines + lines = Vector{Int}() + for i in 1:length(points) + next_i = i % length(points) + 1 + push!(lines, gmsh.model.occ.add_line(points[i], points[next_i])) + end + + # Create curve loop + curve_loop = gmsh.model.occ.add_curve_loop(lines) + + # Create surface + return gmsh.model.occ.add_plane_surface([curve_loop]) +end + +function draw_transition_region( + x::Number, + y::Number, + radii::Vector{<:Number}, + mesh_sizes::Vector{<:Number}, + num_points::Number, +) + # Validate inputs + if length(radii) != length(mesh_sizes) + Base.error("Radii and mesh_sizes vectors must have the same length") + end + + n_regions = length(radii) + if n_regions < 1 + Base.error("At least one radius must be provided") + end + + # Sort radii in ascending order if not already sorted + if !issorted(radii) + p = sortperm(radii) + radii = radii[p] + mesh_sizes = mesh_sizes[p] + end + + # Note to future self: the reason why I did this is because in the edge case when the bounding box coincides with the cable outermost radius (i.e. when you want the transition region around 1 single cable and not several), the earth marker ends up inside the cable region, which present me does not need to explain to future me why it's bad. + + rad_buffer = 0.001 + radii[1] += rad_buffer # Ensure the innermost radius is slightly larger than zero to avoid ambiguous regions + tags = Int[] + all_mesh_points = Int[] + markers = Vector{Vector{Float64}}() + + # Create all disks + disk_tags = Int[] + for i in 1:n_regions + disk_tag = gmsh.model.occ.add_disk(x, y, 0.0, radii[i], radii[i]) + gmsh.model.occ.synchronize() + push!(disk_tags, disk_tag) + end + + # Add the innermost disk to output + push!(tags, disk_tags[1]) + + # Add mesh points for innermost disk + inner_mesh_points = add_mesh_points( + radius_in = radii[1], + radius_ext = radii[1], + theta_0 = 0, + theta_1 = 2 * pi, + mesh_size = mesh_sizes[1], + num_points_ang = num_points, + C = (x, y), + theta_offset = 0, + ) + append!(all_mesh_points, inner_mesh_points) + + # Create marker at the midpoint between the real radius and the added buffer + # Feel free to implement a less dumb way to do this + radius_inner_marker = (radii[1] + radii[1] - rad_buffer) / 2 + inner_marker = [x, y + radius_inner_marker, 0.0] + + marker_tag = gmsh.model.occ.add_point( + inner_marker[1], + inner_marker[2], + inner_marker[3], + mesh_sizes[1], + ) + gmsh.model.set_entity_name(0, marker_tag, "marker_$(round(mesh_sizes[1], sigdigits=6))") + push!(markers, inner_marker) + + # Synchronize the model + gmsh.model.occ.synchronize() + + # Create annular regions for the rest + for i in 2:n_regions + # Cut the inner disk from the outer disk + annular_obj, _ = + gmsh.model.occ.cut([(2, disk_tags[i])], [(2, disk_tags[i-1])], false, false) + + # Get the resulting surface tag + if length(annular_obj) > 0 + annular_tag = annular_obj[1][2] + push!(tags, annular_tag) + + # Add mesh points on the boundary + boundary_points = add_mesh_points( + radius_in = radii[i], + radius_ext = radii[i], + theta_0 = 0, + theta_1 = 2 * pi, + mesh_size = mesh_sizes[i], + num_points_ang = num_points, + C = (x, y), + theta_offset = 0, + ) + append!(all_mesh_points, boundary_points) + + # Create marker at 99% of the way from inner to outer radius + radius_marker = radii[i-1] + 0.99 * (radii[i] - radii[i-1]) + annular_marker = [x, y + radius_marker, 0.0] + marker_tag = gmsh.model.occ.add_point( + annular_marker[1], + annular_marker[2], + annular_marker[3], + mesh_sizes[i], + ) + gmsh.model.set_entity_name( + 0, + marker_tag, + "marker_$(round(mesh_sizes[i], sigdigits=6))", + ) + push!(markers, annular_marker) + else + Base.error( + "Failed to create annular region for radii $(radii[i-1]) and $(radii[i])", + ) + end + end + + return tags, all_mesh_points, markers +end + +function get_system_centroid(cable_system::LineCableSystem, cable_idx::Vector{<:Integer}) + # Check if cable_idx is empty + if isempty(cable_idx) + Base.error("Cable index vector cannot be empty") + end + + # Check if any index is out of bounds + if any(idx -> idx < 1 || idx > length(cable_system.cables), cable_idx) + Base.error("Cable index out of bounds") + end + + # Extract coordinates + horz_coords = [cable_system.cables[idx].horz for idx in cable_idx] + vert_coords = [cable_system.cables[idx].vert for idx in cable_idx] + + # Calculate centroid + centroid_x = sum(horz_coords) / length(horz_coords) + centroid_y = sum(vert_coords) / length(vert_coords) + + # Find the maximum distance from centroid to any cable's edge + max_distance = 0.0 + characteristic_len = Inf + + for idx in cable_idx + cable_position = cable_system.cables[idx] + + # Calculate distance from centroid to cable center + distance_to_center = sqrt( + (cable_position.horz - centroid_x)^2 + (cable_position.vert - centroid_y)^2, + ) + + # Get the outermost component (last component in the vector) + if !isempty(cable_position.design_data.components) + last_component = cable_position.design_data.components[end] + + outer_radius = last_component.insulator_group.radius_ext + + insulator_radius_in = last_component.insulator_group.layers[end].radius_in + last_layer_thickness = outer_radius - insulator_radius_in + + + # Add cable radius to get distance to edge + total_distance = distance_to_center + outer_radius + max_distance = max(max_distance, total_distance) + characteristic_len = min(characteristic_len, last_layer_thickness) + end + end + + return (centroid_x, centroid_y, max_distance, characteristic_len) +end + + +""" +$(TYPEDSIGNATURES) + +Calculate the coordinates of air gaps in a wire array. + +# Arguments + +- `num_wires`: Number of wires in the array \\[dimensionless\\]. +- `radius_wire`: Radius of each wire \\[m\\]. +- `radius_in`: Inner radius of the wire array \\[m\\]. + +# Returns + +- Vector of marker positions (3D coordinates) for air gaps \\[m\\]. + +# Notes + +This function calculates positions for markers that are guaranteed to be in the air gaps +between wires in a wire array. These markers are used to identify the air regions after +boolean fragmentation operations. + +# Examples + +```julia +markers = $(FUNCTIONNAME)(7, 0.002, 0.01) +``` +""" +function get_air_gap_markers(num_wires::Int, radius_wire::Number, radius_in::Number) + markers = Vector{Vector{Float64}}() + + lay_radius = radius_in + radius_wire + + num_angular_markers = num_wires == 1 ? 6 : num_wires + # For multiple wires, place markers between adjacent wires + angle_step = 2π / num_angular_markers + for i in 0:(num_angular_markers-1) + angle = i * angle_step + (angle_step / 2) # Midway between wires + r = lay_radius + (radius_wire / 2) # Slightly outward + x = r * cos(angle) + y = r * sin(angle) + push!(markers, [x, y, 0.0]) + end + return markers +end \ No newline at end of file diff --git a/src/engine/fem/encoding.jl b/src/engine/fem/encoding.jl index e55c1811..8725c130 100644 --- a/src/engine/fem/encoding.jl +++ b/src/engine/fem/encoding.jl @@ -1,485 +1,486 @@ -""" -Functions for physical group tag encoding and decoding in the FEMTools.jl module. -Implements the unified SCCCOOGMMM scheme for all entity types. -""" - -""" - encode_physical_group_tag(surface_type, entity_num, component_num, material_group, material_id) - -Encode entity information into a single integer ID using the unified SCCCOOGMMM scheme. - -# Arguments - -- `surface_type`: Surface type (1=cable, 2=physical space, 3=infinite shell) \\[dimensionless\\]. -- `entity_num`: Cable number or layer number (1-999) \\[dimensionless\\]. -- `component_num`: Component number (0-99, 0 for spatial regions) \\[dimensionless\\]. -- `material_group`: Material group (1=conductor, 2=insulator) \\[dimensionless\\]. -- `material_id`: Material identifier (0-999) \\[dimensionless\\]. - -# Returns - -- Encoded tag as an integer \\[dimensionless\\]. - -# Examples - -```julia -# Cable core conductor with material ID 5 -tag = encode_physical_group_tag(1, 1, 1, 1, 5) - -# Air region with material ID 1 -air_tag = encode_physical_group_tag(2, 1, 0, 2, 1) - -# Earth layer with material ID 3 -earth_tag = encode_physical_group_tag(2, 2, 0, 1, 3) -``` -""" -function encode_physical_group_tag( - surface_type::Int, - entity_num::Int, - component_num::Int, - material_group::Int, - material_id::Int, -) - # Input validation with detailed error messages - if !(1 <= surface_type <= 9) - Base.error("Invalid surface type: $surface_type. Must be between 1 and 9") - end - - if !(0 <= entity_num <= 999) - Base.error("Invalid entity number: $entity_num. Must be between 0 and 999") - end - - if !(0 <= component_num <= 99) - Base.error("Invalid component number: $component_num. Must be between 0 and 99") - end - - if !(1 <= material_group <= 2) - Base.error(""" - Invalid material group: $material_group - Material group must be either: - - 1: Conductor (accounts for eddy currents) - - 2: Insulator (no eddy currents) - """) - end - - if !(0 <= material_id <= 99) - Base.error("Invalid material ID: $material_id. Must be between 0 and 99") - end - - # SCCCOOGMM encoding - tag = ( - surface_type * 100_000_000 + - entity_num * 100_000 + - component_num * 1_000 + - material_group * 100 + - material_id - ) - - # Validate the generated tag - tag_str = string(tag) - expected_length = 9 # SCCCOOGMM = 9 digits - - if length(tag_str) != expected_length - Base.error( - "Generated tag $tag has invalid length ($(length(tag_str))) for inputs: surface_type=$surface_type, entity_num=$entity_num, component_num=$component_num, material_group=$material_group, material_id=$material_id", - ) - end - - return tag -end - -""" - decode_physical_group_tag(tag) - -Decode a physical group tag into its component parts. - -# Arguments - -- `tag`: Encoded tag as an integer \\[dimensionless\\]. - -# Returns - -- Tuple of (surface_type, entity_num, component_num, material_group, material_id) \\[dimensionless\\]. - -# Examples - -```julia -surface_type, entity_num, component_num, material_group, material_id = decode_physical_group_tag(1001010005) -println((surface_type, entity_num, component_num, material_group, material_id)) -# Output: (1, 1, 1, 1, 5) -``` -""" -function decode_physical_group_tag(tag::Int) - tag_str = string(tag) - - # Validate format - expected_length = 9 # SCCCOOGMM = 9 digits - if length(tag_str) != expected_length - Base.error("Invalid tag format: $tag. Expected a $expected_length-digit number") - end - - # Extract parts - surface_type = parse(Int, tag_str[1:1]) - entity_num = parse(Int, tag_str[2:4]) - component_num = parse(Int, tag_str[5:6]) - material_group = parse(Int, tag_str[7:7]) - material_id = parse(Int, tag_str[8:9]) - - return (surface_type, entity_num, component_num, material_group, material_id) -end - -function encode_boundary_tag( - curve_type::Int, - layer_idx::Int, - sequence_num::Int = 1, -) - # Input validation - if !(1 <= curve_type <= 3) - Base.error("Invalid curve type: $curve_type. Must be between 1 and 3: - 1 = domain boundary - 2 = domain -> infinity - 3 = layer interface") - end - - if !(1 <= layer_idx <= 999) - Base.error("Invalid layer index: $layer_idx. Must be between 1 and 999") - end - - if !(1 <= sequence_num <= 99) - Base.error("Invalid sequence number: $sequence_num. Must be between 1 and 99") - end - - # Use entity_num format consistent with the cable parts encoding - # Format: 1CCCLSS - # 1: Fixed prefix for boundaries/interfaces - # CCC: Layer index (1-999) - # L: Curve type (1-3) - # SS: Sequence number (1-99) - tag = 1_000_000 + - layer_idx * 1_000 + - curve_type * 100 + - sequence_num - - return tag -end - -function decode_boundary_tag(tag::Int) - tag_str = string(tag) - - # Validate format (should start with 1) - if length(tag_str) != 7 || tag_str[1] != '1' - Base.error( - "Invalid boundary tag format: $tag. Expected a 7-digit number starting with 1", - ) - end - - # Extract parts - layer_idx = parse(Int, tag_str[2:4]) - curve_type = parse(Int, tag_str[5]) - sequence_num = parse(Int, tag_str[6:7]) - - return (curve_type, layer_idx, sequence_num) -end - -""" - get_material_group(part) - -Get the material group (conductor or insulator) for a cable part based on its type. - -# Arguments - -- `part`: An AbstractCablePart instance. - -# Returns - -- Material group (1=conductor, 2=insulator) \\[dimensionless\\]. - -# Examples - -```julia -group = get_material_group(wire_array) # Returns 1 (conductor) -``` -""" -function get_material_group(part::AbstractCablePart) - if part isa AbstractConductorPart - return 1 # Conductor - elseif part isa AbstractInsulatorPart - return 2 # Insulator - else - Base.error("Unknown part type: $(typeof(part))") - end -end - -""" - get_material_group(earth_model, layer_idx) - -Get the material group (conductor or insulator) for an earth layer. - -# Arguments - -- `earth_model`: The EarthModel containing layer information. -- `layer_idx`: The layer index to check. - -# Returns - -- Material group (1=conductor, 2=insulator) \\[dimensionless\\]. - -# Examples - -```julia -group = get_material_group(earth_model, 1) # Layer 1 is air -> Returns 2 (insulator) -group = get_material_group(earth_model, 2) # Layer 2 is earth -> Returns 1 (conductor) -``` -""" -function get_material_group(earth_model::EarthModel, layer_idx::Int) - # Layer 1 is always air (insulator) - if layer_idx == 1 - return 2 # Insulator - else - # All other layers are earth (conductor) - return 1 # Conductor - end -end - -""" - get_or_register_material_id(workspace, material) - -Find or create a unique ID for a material within the current workspace. - -# Arguments - -- `workspace`: The FEMWorkspace containing the material registry. -- `material`: The Material object to register. - -# Returns - -- A unique material ID (1-99) \\[dimensionless\\]. - -# Examples - -```julia -material_id = get_or_register_material_id(workspace, copper_material) -``` -""" -function get_or_register_material_id(workspace::FEMWorkspace, material::Material) - # Create material_registry if it doesn't exist - if !isdefined(workspace, :material_registry) - workspace.material_registry = Dict{String, Int}() - end - - # Get material name using existing function that checks library first - material_name = get_material_name(material, workspace.formulation.materials) - - # Find or create the ID - if !haskey(workspace.material_registry, material_name) - # New material - assign next available ID - material_id = length(workspace.material_registry) + 1 - if material_id > 99 - Base.error("Material registry full: Maximum of 99 unique materials supported") - end - workspace.material_registry[material_name] = material_id - else - material_id = workspace.material_registry[material_name] - end - - return material_id -end - -function register_physical_group!( - workspace::FEMWorkspace, - physical_group_tag::Int, - material::Material, -) - - # Create physical_groups if it doesn't exist - if !isdefined(workspace, :physical_groups) - workspace.physical_groups = Dict{Int, Material}() - end - - # Find or create the ID - if !haskey(workspace.physical_groups, physical_group_tag) - # New material - assign next available ID - workspace.physical_groups[physical_group_tag] = Material( - to_nominal(material.rho), - to_nominal(material.eps_r), - to_nominal(material.mu_r), - to_nominal(material.T0), - to_nominal(material.alpha), - ) - end - -end -""" -$(TYPEDSIGNATURES) - -Generate a readable elementary name for a cable component. -Format: cable_X___layer__[_wire_N][_phase_M] - -# Arguments - -- `cable_idx`: Cable index \\[dimensionless\\]. -- `component_id`: Component ID (e.g., "core", "sheath") \\[dimensionless\\]. -- `group_type`: Group type (1=conductor, 2=insulator, 3=empty) \\[dimensionless\\]. -- `part_type`: Part type (e.g., "wire", "strip", "tubular") \\[dimensionless\\]. -- `layer_idx`: Layer index \\[dimensionless\\]. -- `wire_idx`: Optional wire index \\[dimensionless\\]. -- `phase`: Optional phase index \\[dimensionless\\]. - -# Returns - -- Human-readable physical name as a string. - -# Examples - -```julia -name = $(FUNCTIONNAME)( - cable_idx=1, - component_id="core", - group_type=1, - part_type="wire", - layer_idx=2, - wire_idx=3, - phase=1 -) -println(name) # Output: "cable_1_core_con_layer_2_wire_wire_3_phase_1" -``` -""" -function create_cable_elementary_name(; - cable_idx::Int, - component_id::String, - group_type::Int, # 1=conductor, 2=insulator, 3=air gap - part_type::String, - layer_idx::Union{Int, Nothing} = nothing, - wire_idx::Union{Int, Nothing} = nothing, - phase::Union{Int, Nothing} = nothing, -) - # Convert group_type to string - group_str = if group_type == 1 - "con" - elseif group_type == 2 - "ins" - else - Base.error("Invalid group_type: $group_type") - end - - # Base name without optional parts - name = "cable_$(cable_idx)_$(component_id)_$(group_str)" - - # Add layer index if provided - if !isnothing(layer_idx) - name *= "_layer_$(layer_idx)" - end - - name *= "_$(part_type)" - - # Add wire index if provided - if !isnothing(wire_idx) - name *= "_wire_$(wire_idx)" - end - - # Add phase if provided - if !isnothing(phase) && phase > 0 - name *= "_phase_$(phase)" - elseif !isnothing(phase) && phase == 0 - name *= "_ground" - end - - return name -end - -function create_physical_group_name(workspace::FEMWorkspace, tag::Int) - # Determine tag type by length - tag_str = string(tag) - - if length(tag_str) == 9 - # This is a physical group tag (SCCCOOGMM format) - return _create_surface_physical_name(workspace, tag) - elseif length(tag_str) == 7 && tag_str[1] == '1' - # This is a boundary tag (1CCCLSS format) - return _create_boundary_physical_name(workspace, tag) - else - # Unknown format - return generic name - return "group_$(tag)" - end -end - -function _create_surface_physical_name(workspace::FEMWorkspace, tag::Int) - # Decode the tag - surface_type, entity_num, component_num, material_group, material_id = - decode_physical_group_tag(tag) - - # Get material name if available - material_name = "unknown" - for (name, id) in workspace.material_registry - if id == material_id - material_name = name - break - end - end - - # Create base string based on surface type - base_str = if surface_type == 1 - # Cable component - # Try to get component name - component_name = "unknown" - if 1 <= entity_num <= length(workspace.problem_def.system.cables) - cable = workspace.problem_def.system.cables[entity_num] - - # Validate component_num is within range - if 1 <= component_num <= length(cable.design_data.components) - component = cable.design_data.components[component_num] - component_name = component.id - end - end - - group_str = material_group == 1 ? "con" : "ins" - "cable_$(entity_num)_$(component_name)_$(group_str)" - elseif surface_type == 2 - # Physical domain - layer_str = entity_num == 1 ? "air" : "earth" - group_str = material_group == 1 ? "con" : "ins" - "layer_$(entity_num)_$(layer_str)_$(group_str)" - elseif surface_type == 3 - # Infinite shell - layer_str = entity_num == 1 ? "air" : "earth" - group_str = material_group == 1 ? "con" : "ins" - "infshell_$(entity_num)_$(layer_str)_$(group_str)" - else - "surf_$(surface_type)" - end - - # Add material information - return "$(base_str)_$(material_name)" -end - -function _create_boundary_physical_name(workspace::FEMWorkspace, tag::Int) - # Decode the boundary tag - curve_type, layer_idx, sequence_num = decode_boundary_tag(tag) - - # Create boundary name based on curve type - base_str = if curve_type == 1 - # Domain boundary - layer_str = layer_idx == 1 ? "air" : "earth" - "boundary_domain_$(layer_str)" - elseif curve_type == 2 - # Domain to infinity - layer_str = layer_idx == 1 ? "air" : "earth" - "boundary_infinity_$(layer_str)" - elseif curve_type == 3 - # Layer interface - if layer_idx == 1 - "interface_air_earth" - else - "interface_earth_layers_$(layer_idx)_$(layer_idx+1)" - end - else - "boundary_unknown" - end - - # Add sequence number if more than one of the same type - if sequence_num > 1 - base_str *= "_$(sequence_num)" - end - - return base_str -end +""" +Functions for physical group tag encoding and decoding in the FEMTools.jl module. +Implements the unified SCCCOOGMMM scheme for all entity types. +""" + +""" + encode_physical_group_tag(surface_type, entity_num, component_num, material_group, material_id) + +Encode entity information into a single integer ID using the unified SCCCOOGMMM scheme. + +# Arguments + +- `surface_type`: Surface type (1=cable, 2=physical space, 3=infinite shell) \\[dimensionless\\]. +- `entity_num`: Cable number or layer number (1-999) \\[dimensionless\\]. +- `component_num`: Component number (0-99, 0 for spatial regions) \\[dimensionless\\]. +- `material_group`: Material group (1=conductor, 2=insulator) \\[dimensionless\\]. +- `material_id`: Material identifier (0-999) \\[dimensionless\\]. + +# Returns + +- Encoded tag as an integer \\[dimensionless\\]. + +# Examples + +```julia +# Cable core conductor with material ID 5 +tag = encode_physical_group_tag(1, 1, 1, 1, 5) + +# Air region with material ID 1 +air_tag = encode_physical_group_tag(2, 1, 0, 2, 1) + +# Earth layer with material ID 3 +earth_tag = encode_physical_group_tag(2, 2, 0, 1, 3) +``` +""" +function encode_physical_group_tag( + surface_type::Int, + entity_num::Int, + component_num::Int, + material_group::Int, + material_id::Int, +) + # Input validation with detailed error messages + if !(1 <= surface_type <= 9) + Base.error("Invalid surface type: $surface_type. Must be between 1 and 9") + end + + if !(0 <= entity_num <= 999) + Base.error("Invalid entity number: $entity_num. Must be between 0 and 999") + end + + if !(0 <= component_num <= 99) + Base.error("Invalid component number: $component_num. Must be between 0 and 99") + end + + if !(1 <= material_group <= 2) + Base.error(""" + Invalid material group: $material_group + Material group must be either: + - 1: Conductor (accounts for eddy currents) + - 2: Insulator (no eddy currents) + """) + end + + if !(0 <= material_id <= 99) + Base.error("Invalid material ID: $material_id. Must be between 0 and 99") + end + + # SCCCOOGMM encoding + tag = ( + surface_type * 100_000_000 + + entity_num * 100_000 + + component_num * 1_000 + + material_group * 100 + + material_id + ) + + # Validate the generated tag + tag_str = string(tag) + expected_length = 9 # SCCCOOGMM = 9 digits + + if length(tag_str) != expected_length + Base.error( + "Generated tag $tag has invalid length ($(length(tag_str))) for inputs: surface_type=$surface_type, entity_num=$entity_num, component_num=$component_num, material_group=$material_group, material_id=$material_id", + ) + end + + return tag +end + +""" + decode_physical_group_tag(tag) + +Decode a physical group tag into its component parts. + +# Arguments + +- `tag`: Encoded tag as an integer \\[dimensionless\\]. + +# Returns + +- Tuple of (surface_type, entity_num, component_num, material_group, material_id) \\[dimensionless\\]. + +# Examples + +```julia +surface_type, entity_num, component_num, material_group, material_id = decode_physical_group_tag(1001010005) +println((surface_type, entity_num, component_num, material_group, material_id)) +# Output: (1, 1, 1, 1, 5) +``` +""" +function decode_physical_group_tag(tag::Int) + tag_str = string(tag) + + # Validate format + expected_length = 9 # SCCCOOGMM = 9 digits + if length(tag_str) != expected_length + Base.error("Invalid tag format: $tag. Expected a $expected_length-digit number") + end + + # Extract parts + surface_type = parse(Int, tag_str[1:1]) + entity_num = parse(Int, tag_str[2:4]) + component_num = parse(Int, tag_str[5:6]) + material_group = parse(Int, tag_str[7:7]) + material_id = parse(Int, tag_str[8:9]) + + return (surface_type, entity_num, component_num, material_group, material_id) +end + +function encode_boundary_tag( + curve_type::Int, + layer_idx::Int, + sequence_num::Int = 1, +) + # Input validation + if !(1 <= curve_type <= 3) + Base.error("Invalid curve type: $curve_type. Must be between 1 and 3: + 1 = domain boundary + 2 = domain -> infinity + 3 = layer interface") + end + + if !(1 <= layer_idx <= 999) + Base.error("Invalid layer index: $layer_idx. Must be between 1 and 999") + end + + if !(1 <= sequence_num <= 99) + Base.error("Invalid sequence number: $sequence_num. Must be between 1 and 99") + end + + # Use entity_num format consistent with the cable parts encoding + # Format: 1CCCLSS + # 1: Fixed prefix for boundaries/interfaces + # CCC: Layer index (1-999) + # L: Curve type (1-3) + # SS: Sequence number (1-99) + tag = 1_000_000 + + layer_idx * 1_000 + + curve_type * 100 + + sequence_num + + return tag +end + +function decode_boundary_tag(tag::Int) + tag_str = string(tag) + + # Validate format (should start with 1) + if length(tag_str) != 7 || tag_str[1] != '1' + Base.error( + "Invalid boundary tag format: $tag. Expected a 7-digit number starting with 1", + ) + end + + # Extract parts + layer_idx = parse(Int, tag_str[2:4]) + curve_type = parse(Int, tag_str[5]) + sequence_num = parse(Int, tag_str[6:7]) + + return (curve_type, layer_idx, sequence_num) +end + +""" + get_material_group(part) + +Get the material group (conductor or insulator) for a cable part based on its type. + +# Arguments + +- `part`: An AbstractCablePart instance. + +# Returns + +- Material group (1=conductor, 2=insulator) \\[dimensionless\\]. + +# Examples + +```julia +group = get_material_group(wire_array) # Returns 1 (conductor) +``` +""" +function get_material_group(part::AbstractCablePart) + if part isa AbstractConductorPart + return 1 # Conductor + elseif part isa AbstractInsulatorPart + return 2 # Insulator + else + Base.error("Unknown part type: $(typeof(part))") + end +end + +""" + get_material_group(earth_model, layer_idx) + +Get the material group (conductor or insulator) for an earth layer. + +# Arguments + +- `earth_model`: The EarthModel containing layer information. +- `layer_idx`: The layer index to check. + +# Returns + +- Material group (1=conductor, 2=insulator) \\[dimensionless\\]. + +# Examples + +```julia +group = get_material_group(earth_model, 1) # Layer 1 is air -> Returns 2 (insulator) +group = get_material_group(earth_model, 2) # Layer 2 is earth -> Returns 1 (conductor) +``` +""" +function get_material_group(earth_model::EarthModel, layer_idx::Int) + # Layer 1 is always air (insulator) + if layer_idx == 1 + return 2 # Insulator + else + # All other layers are earth (conductor) + return 1 # Conductor + end +end + +""" + get_or_register_material_id(workspace, material) + +Find or create a unique ID for a material within the current workspace. + +# Arguments + +- `workspace`: The FEMWorkspace containing the material registry. +- `material`: The Material object to register. + +# Returns + +- A unique material ID (1-99) \\[dimensionless\\]. + +# Examples + +```julia +material_id = get_or_register_material_id(workspace, copper_material) +``` +""" +function get_or_register_material_id(workspace::FEMWorkspace, material::Material) + # Create material_registry if it doesn't exist + if !isdefined(workspace.core, :material_registry) + workspace.core.material_registry = Dict{String, Int}() + end + + # Get material name using existing function that checks library first + material_name = get_material_name(material, workspace.core.formulation.materials) + + # Find or create the ID + if !haskey(workspace.core.material_registry, material_name) + # New material - assign next available ID + material_id = length(workspace.core.material_registry) + 1 + if material_id > 99 + Base.error("Material registry full: Maximum of 99 unique materials supported") + end + workspace.core.material_registry[material_name] = material_id + else + material_id = workspace.core.material_registry[material_name] + end + + return material_id +end + +function register_physical_group!( + workspace::FEMWorkspace, + physical_group_tag::Int, + material::Material, +) + + # Create physical_groups if it doesn't exist + if !isdefined(workspace.core, :physical_groups) + workspace.core.physical_groups = Dict{Int, Material}() + end + + # Find or create the ID + if !haskey(workspace.core.physical_groups, physical_group_tag) + # New material - assign next available ID + workspace.core.physical_groups[physical_group_tag] = Material( + to_nominal(material.rho), + to_nominal(material.eps_r), + to_nominal(material.mu_r), + to_nominal(material.T0), + to_nominal(material.alpha), + to_nominal(material.kappa), + ) + end + +end +""" +$(TYPEDSIGNATURES) + +Generate a readable elementary name for a cable component. +Format: cable_X___layer__[_wire_N][_phase_M] + +# Arguments + +- `cable_idx`: Cable index \\[dimensionless\\]. +- `component_id`: Component ID (e.g., "core", "sheath") \\[dimensionless\\]. +- `group_type`: Group type (1=conductor, 2=insulator, 3=empty) \\[dimensionless\\]. +- `part_type`: Part type (e.g., "wire", "strip", "tubular") \\[dimensionless\\]. +- `layer_idx`: Layer index \\[dimensionless\\]. +- `wire_idx`: Optional wire index \\[dimensionless\\]. +- `phase`: Optional phase index \\[dimensionless\\]. + +# Returns + +- Human-readable physical name as a string. + +# Examples + +```julia +name = $(FUNCTIONNAME)( + cable_idx=1, + component_id="core", + group_type=1, + part_type="wire", + layer_idx=2, + wire_idx=3, + phase=1 +) +println(name) # Output: "cable_1_core_con_layer_2_wire_wire_3_phase_1" +``` +""" +function create_cable_elementary_name(; + cable_idx::Int, + component_id::String, + group_type::Int, # 1=conductor, 2=insulator, 3=air gap + part_type::String, + layer_idx::Union{Int, Nothing} = nothing, + wire_idx::Union{Int, Nothing} = nothing, + phase::Union{Int, Nothing} = nothing, +) + # Convert group_type to string + group_str = if group_type == 1 + "con" + elseif group_type == 2 + "ins" + else + Base.error("Invalid group_type: $group_type") + end + + # Base name without optional parts + name = "cable_$(cable_idx)_$(component_id)_$(group_str)" + + # Add layer index if provided + if !isnothing(layer_idx) + name *= "_layer_$(layer_idx)" + end + + name *= "_$(part_type)" + + # Add wire index if provided + if !isnothing(wire_idx) + name *= "_wire_$(wire_idx)" + end + + # Add phase if provided + if !isnothing(phase) && phase > 0 + name *= "_phase_$(phase)" + elseif !isnothing(phase) && phase == 0 + name *= "_ground" + end + + return name +end + +function create_physical_group_name(workspace::FEMWorkspace, tag::Int) + # Determine tag type by length + tag_str = string(tag) + + if length(tag_str) == 9 + # This is a physical group tag (SCCCOOGMM format) + return _create_surface_physical_name(workspace, tag) + elseif length(tag_str) == 7 && tag_str[1] == '1' + # This is a boundary tag (1CCCLSS format) + return _create_boundary_physical_name(workspace, tag) + else + # Unknown format - return generic name + return "group_$(tag)" + end +end + +function _create_surface_physical_name(workspace::FEMWorkspace, tag::Int) + # Decode the tag + surface_type, entity_num, component_num, material_group, material_id = + decode_physical_group_tag(tag) + + # Get material name if available + material_name = "unknown" + for (name, id) in workspace.core.material_registry + if id == material_id + material_name = name + break + end + end + + # Create base string based on surface type + base_str = if surface_type == 1 + # Cable component + # Try to get component name + component_name = "unknown" + if 1 <= entity_num <= length(workspace.core.system.cables) + cable = workspace.core.system.cables[entity_num] + + # Validate component_num is within range + if 1 <= component_num <= length(cable.design_data.components) + component = cable.design_data.components[component_num] + component_name = component.id + end + end + + group_str = material_group == 1 ? "con" : "ins" + "cable_$(entity_num)_$(component_name)_$(group_str)" + elseif surface_type == 2 + # Physical domain + layer_str = entity_num == 1 ? "air" : "earth" + group_str = material_group == 1 ? "con" : "ins" + "layer_$(entity_num)_$(layer_str)_$(group_str)" + elseif surface_type == 3 + # Infinite shell + layer_str = entity_num == 1 ? "air" : "earth" + group_str = material_group == 1 ? "con" : "ins" + "infshell_$(entity_num)_$(layer_str)_$(group_str)" + else + "surf_$(surface_type)" + end + + # Add material information + return "$(base_str)_$(material_name)" +end + +function _create_boundary_physical_name(workspace::FEMWorkspace, tag::Int) + # Decode the boundary tag + curve_type, layer_idx, sequence_num = decode_boundary_tag(tag) + + # Create boundary name based on curve type + base_str = if curve_type == 1 + # Domain boundary + layer_str = layer_idx == 1 ? "air" : "earth" + "boundary_domain_$(layer_str)" + elseif curve_type == 2 + # Domain to infinity + layer_str = layer_idx == 1 ? "air" : "earth" + "boundary_infinity_$(layer_str)" + elseif curve_type == 3 + # Layer interface + if layer_idx == 1 + "interface_air_earth" + else + "interface_earth_layers_$(layer_idx)_$(layer_idx+1)" + end + else + "boundary_unknown" + end + + # Add sequence number if more than one of the same type + if sequence_num > 1 + base_str *= "_$(sequence_num)" + end + + return base_str +end diff --git a/src/engine/fem/helpers.jl b/src/engine/fem/helpers.jl index 0de6a980..5f345e89 100644 --- a/src/engine/fem/helpers.jl +++ b/src/engine/fem/helpers.jl @@ -1,315 +1,325 @@ -""" -Utility functions for the FEMTools.jl module. -These functions provide various utilities for file management, logging, etc. -""" - - -""" -$(TYPEDSIGNATURES) - -Set up directory structure and file paths for a FEM simulation. - -# Arguments - -- `solver`: The [`FEMSolver`](@ref) containing the base path. -- `cable_system`: The [`LineCableSystem`](@ref) containing the case ID. - -# Returns - -- A dictionary of paths for the simulation. - -# Examples - -```julia -paths = $(FUNCTIONNAME)(solver, cable_system) -``` -""" -function setup_paths(cable_system::LineCableSystem, formulation::FEMFormulation) - - opts = formulation.options - # Create base output directory if it doesn't exist - if !isdir(opts.save_path) - mkpath(opts.save_path) - @info "Created base output directory: $(display_path(opts.save_path))" - end - - # Set up case-specific paths - case_id = cable_system.system_id - case_dir = joinpath(opts.save_path, case_id) - - # Create case directory if needed - if !isdir(case_dir) && (opts.force_remesh || opts.mesh_only) - mkpath(case_dir) - @info "Created case directory: $(display_path(case_dir))" - end - - # Create results directory path - results_dir = joinpath(case_dir, "results") - - # Define key file paths - mesh_file = joinpath(case_dir, "$(case_id).msh") - geo_file = joinpath(case_dir, "$(case_id).geo_unrolled") - # data_file = joinpath(case_dir, "$(case_id)_data.geo") - - impedance_res = lowercase(formulation.analysis_type[1].resolution_name) - impedance_file = joinpath(case_dir, "$(case_id)_$(impedance_res).pro") - - admittance_res = lowercase(formulation.analysis_type[2].resolution_name) - admittance_file = joinpath(case_dir, "$(case_id)_$(admittance_res).pro") - - # Return compiled dictionary of paths - paths = Dict{Symbol,String}( - :base_dir => opts.save_path, - :case_dir => case_dir, - :results_dir => results_dir, - :mesh_file => mesh_file, - :geo_file => geo_file, - :impedance_file => impedance_file, - :admittance_file => admittance_file, - ) - - @debug "Paths configured: $(join(["$(k): $(v)" for (k,v) in paths], ", "))" - - return paths -end - -""" -$(TYPEDSIGNATURES) - -Clean up files based on configuration flags. - -# Arguments - -- `paths`: Dictionary of paths for the simulation. -- `solver`: The [`FEMSolver`](@ref) containing the configuration flags. - -# Returns - -- Nothing. Deletes files as specified by the configuration. - -# Examples - -```julia -$(FUNCTIONNAME)(paths, solver) -``` -""" -function cleanup_files(paths::Dict{Symbol,String}, opts::NamedTuple) - if opts.force_remesh - # If force_remesh is true, delete mesh-related files - if isfile(paths[:mesh_file]) - rm(paths[:mesh_file], force=true) - @info "Removed existing mesh file: $(display_path(paths[:mesh_file]))" - end - - if isfile(paths[:geo_file]) - rm(paths[:geo_file], force=true) - @info "Removed existing geometry file: $(display_path(paths[:geo_file]))" - end - end - - if opts.overwrite_results && opts.run_solver - - # Add cleanup for .pro files in case_dir - for file in readdir(paths[:case_dir]) - if endswith(file, ".pro") - filepath = joinpath(paths[:case_dir], file) - rm(filepath, force=true) - @info "Removed existing problem file: $(display_path(filepath))" - end - end - - # If overwriting results and running solver, clear the results directory - if isdir(paths[:results_dir]) - for file in readdir(paths[:results_dir]) - filepath = joinpath(paths[:results_dir], file) - if isfile(filepath) - rm(filepath, force=true) - end - end - @info "Cleared existing results in: $(display_path(paths[:results_dir]))" - end - end -end - -function read_results_file( - fem_formulation::Union{AbstractImpedanceFormulation,AbstractAdmittanceFormulation}, - workspace::FEMWorkspace; - file::Union{String,Nothing}=nothing, -) - - results_path = - joinpath(workspace.paths[:results_dir], lowercase(fem_formulation.resolution_name)) - - if isnothing(file) - file = - fem_formulation isa AbstractImpedanceFormulation ? "Z.dat" : - fem_formulation isa AbstractAdmittanceFormulation ? "Y.dat" : - throw(ArgumentError("Invalid formulation type: $(typeof(fem_formulation))")) - end - - filepath = joinpath(results_path, file) - - isfile(filepath) || Base.error("File not found: $filepath") - - # Read all lines from file - lines = readlines(filepath) - n_rows = - sum([length(c.design_data.components) for c in workspace.problem_def.system.cables]) - - # Pre-allocate result matrix - matrix = zeros(ComplexF64, n_rows, n_rows) - - # Process each line (matrix row) - for (i, line) in enumerate(lines) - # Parse all numbers, dropping the initial 0 - values = parse.(Float64, split(line))[2:end] - - # Fill matrix row with complex values - for j in 1:n_rows - idx = 2j - 1 # Index for real part - matrix[i, j] = Complex(values[idx], values[idx+1]) - end - end - - return matrix -end - - -# Verbosity Levels in GetDP -# Level Output Description -# 0 Silent (no output) -# 1 Errors only -# 2 Errors + warnings -# 3 Errors + warnings + basic info -# 4 Detailed debugging -# 5 Full internal tracing -function map_verbosity_to_getdp(verbosity::Int) - if is_headless() # Prevent huge logs in CI/CD deploys - @info "Running in headless mode, suppressing GetDP output" - return 0 # Gmsh Silent level - elseif verbosity >= 2 # Debug - return 4 # GetDP Debug level - elseif verbosity == 1 # Info - return 3 # GetDP Info level - else # Warn - return 1 # GetDP Errors level - end -end - -# Verbosity Levels in Gmsh -# Level Output Description -# 0 Silent (no output) -# 1 Errors only -# 2 Warnings -# 3 Direct/Important info -# 4 Information -# 5 Status messages -# 99 Debug -function map_verbosity_to_gmsh(verbosity::Int) - if is_headless() # Prevent huge logs in CI/CD deploys - @info "Running in headless mode, suppressing Gmsh output" - return 0 # Gmsh Silent level - elseif verbosity >= 2 # Debug - return 99 # Gmsh Debug level - elseif verbosity == 1 # Info - return 4 # Gmsh Information level - else # Warn - return 1 # Gmsh Errors level - end -end - -function calc_domain_size( - earth_params::EarthModel, - f::Vector{<:Float64}; - min_radius=5.0, - max_radius=5000.0, -) - # Find the earth layer with the highest resistivity to determine the domain size - if isempty(earth_params.layers) - Base.error("EarthModel has no layers defined.") - end - - inds = 2:length(earth_params.layers) - max_rho_idx = inds[argmax([earth_params.layers[i].rho_g[1] for i in inds])] - - target_layer = earth_params.layers[max_rho_idx] - - rho_g = target_layer.rho_g[1] - mu_g = target_layer.mu_g[1] - freq = first(f) # Use the first frequency for the calculation - skin_depth_earth = abs(sqrt(rho_g / (1im * 2 * pi * freq * mu_g))) - return clamp(skin_depth_earth, min_radius, max_radius) -end - -function archive_frequency_results(workspace::FEMWorkspace, frequency::Float64) - try - results_dir = workspace.paths[:results_dir] - freq_dir = - joinpath(dirname(results_dir), "results_f=$(round(frequency, sigdigits=6))") - - if isdir(results_dir) - mv(results_dir, freq_dir, force=true) - @debug "Archived results for f=$frequency Hz" - end - - # Move solver files - for ext in [".res", ".pre"] - case_files = filter(f -> endswith(f, ext), - readdir(workspace.paths[:case_dir], join=true)) - for f in case_files - mv(f, joinpath(freq_dir, basename(f)), force=true) - end - end - catch e - @warn "Failed to archive results for frequency $frequency Hz" exception = e - end -end - -# Run a command quietly; return true if it starts and exits with code 0. -_run_ok(cmd::Cmd) = - try - success(pipeline(cmd; stdout=devnull, stderr=devnull)) - catch - false # covers "file not found", spawn failures, etc. - end - -# Does this path behave like a GetDP executable? -_is_valid_getdp_exe(path::AbstractString) = begin - @debug "Probing GetDP via -info" path = path - _run_ok(`$path -info`) -end - -# Resolve the GetDP path: -# 1) If user provided :getdp_executable and it runs with -info, use it. -# 2) Else ask GetDP.jl for its executable and use it if it runs with -info. -# 3) Else, error. -function _resolve_getdp_path(opts::NamedTuple) - user_path = get(opts, :getdp_executable, nothing) - @debug "Resolving GetDP path (simple probe)" user_path = user_path - - if user_path isa AbstractString - if _is_valid_getdp_exe(user_path) - @debug "Using user-specified GetDP executable" path = user_path - return user_path - else - @warn "User-specified GetDP executable failed when invoked with -info" path = user_path - end - else - @debug "No user-specified GetDP path" - end - - fallback = try - GetDP.get_getdp_executable() - catch - nothing - end - @debug "GetDP.get_getdp_executable() returned" path = fallback - - if fallback isa AbstractString && _is_valid_getdp_exe(fallback) - @debug "Using dependency-provided GetDP executable" path = fallback - return fallback - end - - Base.error("GetDP executable not found or not working (invocation with -info failed). " * - "Provide :getdp_executable in opts or ensure GetDP.jl is properly deployed.") +""" +Utility functions for the FEMTools.jl module. +These functions provide various utilities for file management, logging, etc. +""" + + +""" +$(TYPEDSIGNATURES) + +Set up directory structure and file paths for a FEM simulation. + +# Arguments + +- `solver`: The [`FEMSolver`](@ref) containing the base path. +- `cable_system`: The [`LineCableSystem`](@ref) containing the case ID. + +# Returns + +- A dictionary of paths for the simulation. + +# Examples + +```julia +paths = $(FUNCTIONNAME)(solver, cable_system) +``` +""" +function setup_paths(cable_system::LineCableSystem, formulation::FEMFormulation) + + opts = formulation.options + # Create base output directory if it doesn't exist + if !isdir(opts.save_path) + mkpath(opts.save_path) + @info "Created base output directory: $(display_path(opts.save_path))" + end + + # Set up case-specific paths + case_id = cable_system.system_id + case_dir = joinpath(opts.save_path, case_id) + + # Create case directory if needed + if !isdir(case_dir) && (opts.force_remesh || opts.mesh_only) + mkpath(case_dir) + @info "Created case directory: $(display_path(case_dir))" + end + + # Create results directory path + results_dir = joinpath(case_dir, "results") + + # Define key file paths + mesh_file = joinpath(case_dir, "$(case_id).msh") + geo_file = joinpath(case_dir, "$(case_id).geo_unrolled") + # data_file = joinpath(case_dir, "$(case_id)_data.geo") + if formulation.analysis_type isa AmpacityFormulation + analysis_file = joinpath(case_dir, "$(case_id)_$(get_resolution_name(formulation.analysis_type)).pro") + paths = Dict{Symbol,String}( + :base_dir => opts.save_path, + :case_dir => case_dir, + :results_dir => results_dir, + :mesh_file => mesh_file, + :geo_file => geo_file, + :analysis_file => analysis_file, + ) + + elseif formulation.analysis_type isa Tuple{AbstractImpedanceFormulation, AbstractAdmittanceFormulation} + impedance_res = get_resolution_name(formulation.analysis_type[1]) + impedance_file = joinpath(case_dir, "$(case_id)_$(impedance_res).pro") + + admittance_res = get_resolution_name(formulation.analysis_type[2]) + admittance_file = joinpath(case_dir, "$(case_id)_$(admittance_res).pro") + paths = Dict{Symbol,String}( + :base_dir => opts.save_path, + :case_dir => case_dir, + :results_dir => results_dir, + :mesh_file => mesh_file, + :geo_file => geo_file, + :impedance_file => impedance_file, + :admittance_file => admittance_file, + ) + end + + @debug "Paths configured: $(join(["$(k): $(v)" for (k,v) in paths], ", "))" + + return paths +end + +""" +$(TYPEDSIGNATURES) + +Clean up files based on configuration flags. + +# Arguments + +- `paths`: Dictionary of paths for the simulation. +- `solver`: The [`FEMSolver`](@ref) containing the configuration flags. + +# Returns + +- Nothing. Deletes files as specified by the configuration. + +# Examples + +```julia +$(FUNCTIONNAME)(paths, solver) +``` +""" +function cleanup_files(paths::Dict{Symbol,String}, opts::NamedTuple) + if opts.force_remesh + # If force_remesh is true, delete mesh-related files + if isfile(paths[:mesh_file]) + rm(paths[:mesh_file], force=true) + @info "Removed existing mesh file: $(display_path(paths[:mesh_file]))" + end + + if isfile(paths[:geo_file]) + rm(paths[:geo_file], force=true) + @info "Removed existing geometry file: $(display_path(paths[:geo_file]))" + end + end + + if opts.overwrite_results && opts.run_solver + + # Add cleanup for .pro files in case_dir + for file in readdir(paths[:case_dir]) + if endswith(file, ".pro") + filepath = joinpath(paths[:case_dir], file) + rm(filepath, force=true) + @info "Removed existing problem file: $(display_path(filepath))" + end + end + + # If overwriting results and running solver, clear the results directory + if isdir(paths[:results_dir]) + for file in readdir(paths[:results_dir]) + filepath = joinpath(paths[:results_dir], file) + if isfile(filepath) + rm(filepath, force=true) + end + end + @info "Cleared existing results in: $(display_path(paths[:results_dir]))" + end + end +end + +function read_results_file( + fem_formulation::Union{AbstractImpedanceFormulation,AbstractAdmittanceFormulation}, + workspace::FEMWorkspace; + file::Union{String,Nothing}=nothing, +) + + results_path = + joinpath(workspace.core.paths[:results_dir], lowercase(get_resolution_name(fem_formulation))) + + if isnothing(file) + file = + fem_formulation isa AbstractImpedanceFormulation ? "Z.dat" : + fem_formulation isa AbstractAdmittanceFormulation ? "Y.dat" : + throw(ArgumentError("Invalid formulation type: $(typeof(fem_formulation))")) + end + + filepath = joinpath(results_path, file) + + isfile(filepath) || Base.error("File not found: $filepath") + + # Read all lines from file + lines = readlines(filepath) + n_rows = + sum([length(c.design_data.components) for c in workspace.core.system.cables]) + + # Pre-allocate result matrix + matrix = zeros(ComplexF64, n_rows, n_rows) + + # Process each line (matrix row) + for (i, line) in enumerate(lines) + # Parse all numbers, dropping the initial 0 + values = parse.(Float64, split(line))[2:end] + + # Fill matrix row with complex values + for j in 1:n_rows + idx = 2j - 1 # Index for real part + matrix[i, j] = Complex(values[idx], values[idx+1]) + end + end + + return matrix +end + + +# Verbosity Levels in GetDP +# Level Output Description +# 0 Silent (no output) +# 1 Errors only +# 2 Errors + warnings +# 3 Errors + warnings + basic info +# 4 Detailed debugging +# 5 Full internal tracing +function map_verbosity_to_getdp(verbosity::Int) + if is_headless() # Prevent huge logs in CI/CD deploys + @info "Running in headless mode, suppressing GetDP output" + return 0 # Gmsh Silent level + elseif verbosity >= 2 # Debug + return 4 # GetDP Debug level + elseif verbosity == 1 # Info + return 3 # GetDP Info level + else # Warn + return 1 # GetDP Errors level + end +end + +# Verbosity Levels in Gmsh +# Level Output Description +# 0 Silent (no output) +# 1 Errors only +# 2 Warnings +# 3 Direct/Important info +# 4 Information +# 5 Status messages +# 99 Debug +function map_verbosity_to_gmsh(verbosity::Int) + if is_headless() # Prevent huge logs in CI/CD deploys + @info "Running in headless mode, suppressing Gmsh output" + return 0 # Gmsh Silent level + elseif verbosity >= 2 # Debug + return 99 # Gmsh Debug level + elseif verbosity == 1 # Info + return 4 # Gmsh Information level + else # Warn + return 1 # Gmsh Errors level + end +end + +function calc_domain_size( + earth_params::EarthModel, + f::Vector{<:Float64}; + min_radius=5.0, + max_radius=5000.0, +) + # Find the earth layer with the highest resistivity to determine the domain size + if isempty(earth_params.layers) + Base.error("EarthModel has no layers defined.") + end + + inds = 2:length(earth_params.layers) + max_rho_idx = inds[argmax([earth_params.layers[i].rho_g[1] for i in inds])] + + target_layer = earth_params.layers[max_rho_idx] + + rho_g = target_layer.rho_g[1] + mu_g = target_layer.mu_g[1] + freq = first(f) # Use the first frequency for the calculation + skin_depth_earth = abs(sqrt(rho_g / (1im * 2 * pi * freq * mu_g))) + return clamp(skin_depth_earth, min_radius, max_radius) +end + +function archive_frequency_results(workspace::FEMWorkspace, frequency::Float64) + try + results_dir = workspace.core.paths[:results_dir] + freq_dir = + joinpath(dirname(results_dir), "results_f=$(round(frequency, sigdigits=6))") + + if isdir(results_dir) + mv(results_dir, freq_dir, force=true) + @debug "Archived results for f=$frequency Hz" + end + + # Move solver files + for ext in [".res", ".pre", ".pos"] + case_files = filter(f -> endswith(f, ext), + readdir(workspace.core.paths[:case_dir], join=true)) + for f in case_files + mv(f, joinpath(freq_dir, basename(f)), force=true) + end + end + catch e + @warn "Failed to archive results for frequency $frequency Hz" exception = e + end +end + +# Run a command quietly; return true if it starts and exits with code 0. +_run_ok(cmd::Cmd) = + try + success(pipeline(cmd; stdout=devnull, stderr=devnull)) + catch + false # covers "file not found", spawn failures, etc. + end + +# Does this path behave like a GetDP executable? +_is_valid_getdp_exe(path::AbstractString) = begin + @debug "Probing GetDP via -info" path = path + _run_ok(`$path -info`) +end + +# Resolve the GetDP path: +# 1) If user provided :getdp_executable and it runs with -info, use it. +# 2) Else ask GetDP.jl for its executable and use it if it runs with -info. +# 3) Else, error. +function _resolve_getdp_path(opts::NamedTuple) + user_path = get(opts, :getdp_executable, nothing) + @debug "Resolving GetDP path (simple probe)" user_path = user_path + + if user_path isa AbstractString + if _is_valid_getdp_exe(user_path) + @debug "Using user-specified GetDP executable" path = user_path + return user_path + else + @warn "User-specified GetDP executable failed when invoked with -info" path = user_path + end + else + @debug "No user-specified GetDP path" + end + + fallback = try + GetDP.get_getdp_executable() + catch + nothing + end + @debug "GetDP.get_getdp_executable() returned" path = fallback + + if fallback isa AbstractString && _is_valid_getdp_exe(fallback) + @debug "Using dependency-provided GetDP executable" path = fallback + return fallback + end + + Base.error("GetDP executable not found or not working (invocation with -info failed). " * + "Provide :getdp_executable in opts or ensure GetDP.jl is properly deployed.") end \ No newline at end of file diff --git a/src/engine/fem/identification.jl b/src/engine/fem/identification.jl index b5c61b06..f21b7ad2 100644 --- a/src/engine/fem/identification.jl +++ b/src/engine/fem/identification.jl @@ -1,215 +1,215 @@ -""" -Entity identification functions for the FEMTools.jl module. -These functions handle the identification of entities after boolean operations. -""" - -""" -$(TYPEDSIGNATURES) - -Perform boolean fragmentation on all entities in the model. - -# Arguments - -- `workspace`: The [`FEMWorkspace`](@ref) containing the entities to fragment. - -# Returns - -- Nothing. Modifies the Gmsh model in place. - -# Examples - -```julia -$(FUNCTIONNAME)(workspace) -``` - -# Notes - -This function performs boolean fragmentation on all surfaces and curves in the model. -After fragmentation, the original entities are replaced with new entities that respect -the intersections between them. The original entity tags are no longer valid after -this operation. -""" -function process_fragments(workspace::FEMWorkspace) - - # Get all entities - surfaces = gmsh.model.get_entities(2) - curves = gmsh.model.get_entities(1) - points = gmsh.model.get_entities(0) - - @debug "Initial counts: $(length(surfaces)) surfaces, $(length(curves)) curves, $(length(points)) points" - - # Fragment points onto curves - if !isempty(curves) && !isempty(points) - @debug "Fragmenting points onto curves..." - gmsh.model.occ.fragment(curves, points) - gmsh.model.occ.synchronize() - end - - # Get updated entities after first fragmentation - updated_curves = gmsh.model.get_entities(1) - updated_points = gmsh.model.get_entities(0) - - @debug "After fragmenting points onto curves: $(length(updated_curves)) curves, $(length(updated_points)) points" - - # Fragment curves onto surfaces - if !isempty(surfaces) && !isempty(updated_curves) - @debug "Fragmenting curves onto surfaces..." - gmsh.model.occ.fragment(surfaces, updated_curves) - gmsh.model.occ.synchronize() - end - - # Remove duplicates - @debug "Removing duplicate entities..." - gmsh.model.occ.remove_all_duplicates() - gmsh.model.occ.synchronize() - - # Final counts - final_surfaces = gmsh.model.get_entities(2) - final_curves = gmsh.model.get_entities(1) - final_points = gmsh.model.get_entities(0) - - @info "Boolean fragmentation completed" - @debug "Before: $(length(surfaces)) surfaces, $(length(curves)) curves, $(length(points)) points" - @debug "After: $(length(final_surfaces)) surfaces, $(length(final_curves)) curves, $(length(final_points)) points" - @debug "Unique markers in workspace: $(length(workspace.unassigned_entities)) markers" - -end - -function identify_by_marker(workspace::FEMWorkspace) - - # Get all surfaces after fragmentation - all_surfaces = gmsh.model.get_entities(2) - - # Track statistics - total_entities = length(workspace.unassigned_entities) - identified_count = 0 - - # Copy keys to avoid modifying dict during iteration - markers = collect(keys(workspace.unassigned_entities)) - - # For each marker, find which surface contains it - for marker in markers - entity_data = workspace.unassigned_entities[marker] - physical_group_tag = entity_data.core.physical_group_tag - elementary_name = entity_data.core.elementary_name - - for (dim, tag) in all_surfaces - if !(entity_data isa CurveEntity) - # Check if marker is inside this surface - if gmsh.model.is_inside(dim, tag, marker) == 1 - fem_entity = GmshObject(tag, entity_data) - - # Place in appropriate container - if entity_data isa CablePartEntity - if entity_data.cable_part isa AbstractConductorPart - push!(workspace.conductors, fem_entity) - elseif entity_data.cable_part isa AbstractInsulatorPart - push!(workspace.insulators, fem_entity) - end - elseif entity_data isa SurfaceEntity - push!(workspace.space_regions, fem_entity) - end - - delete!(workspace.unassigned_entities, marker) - identified_count += 1 - @debug "Marker at $(marker) identified entity $(tag) as $(elementary_name) (tag: $(physical_group_tag))" - break - end - end - end - end - - # Get all remaining curves after fragmentation - all_curves = gmsh.model.get_entities(1) - - # Update keys to avoid modifying dict during iteration - markers = collect(keys(workspace.unassigned_entities)) - - # For each marker, find which surface contains it - for marker in markers - entity_data = workspace.unassigned_entities[marker] - physical_group_tag = entity_data.core.physical_group_tag - elementary_name = entity_data.core.elementary_name - - for (dim, tag) in all_curves - # Check if marker is inside this curve - if gmsh.model.is_inside(dim, tag, marker) == 1 - # Found match - create GmshObject and add to appropriate container - fem_entity = GmshObject(tag, entity_data) - - # Place in appropriate container - if entity_data isa CurveEntity - push!(workspace.boundaries, fem_entity) - end - - delete!(workspace.unassigned_entities, marker) - identified_count += 1 - @debug "Marker at $(marker) identified entity $(tag) as $(elementary_name) (tag: $(physical_group_tag))" - break - end - end - end - - # Report identification stats - @info "Entity identification completed: $(identified_count)/$(total_entities) entities identified" - - if !isempty(workspace.unassigned_entities) - @warn "$(length(workspace.unassigned_entities))/$(total_entities) markers could not be matched to entities" - end -end -function assign_physical_groups(workspace::FEMWorkspace) - # Group entities by physical tag and dimension - entities_by_physical_group_tag = Dict{Tuple{Int,Int},Vector{Int}}() - - # Process all entity containers - for container in [workspace.conductors, workspace.insulators, workspace.space_regions, workspace.boundaries] - for entity in container - physical_group_tag = entity.data.core.physical_group_tag - elementary_name = entity.data.core.elementary_name - dim = entity.data isa CurveEntity ? 1 : 2 - - # Key is now a tuple of (physical_group_tag, dimension) - group_key = (physical_group_tag, dim) - - if !haskey(entities_by_physical_group_tag, group_key) - entities_by_physical_group_tag[group_key] = Int[] - end - - # Add this entity to the collection for this physical tag - current_physical_group = gmsh.model.get_physical_groups_for_entity(dim, entity.tag) - if !isempty(current_physical_group) - @debug "Entity $(entity.tag) already has physical group: $(current_physical_group)" - end - push!(entities_by_physical_group_tag[group_key], entity.tag) - - if !isempty(elementary_name) - # Append the complete name to the shape - current_name = gmsh.model.get_entity_name(dim, entity.tag) - if !isempty(current_name) - @debug "Entity $(entity.tag) already has elementary name: $(current_name)" - end - gmsh.model.set_entity_name(dim, entity.tag, elementary_name) - end - end - end - - # Create physical groups for each physical tag - successful_groups = 0 - failed_groups = 0 - - for ((physical_group_tag, dim), entity_tags) in entities_by_physical_group_tag - try - physical_group_name = create_physical_group_name(workspace, physical_group_tag) - @debug "Creating physical group $(physical_group_name) (tag: $(physical_group_tag), dim: $(dim)) with $(length(entity_tags)) entities" - - # Use the correct dimension when creating the physical group - gmsh.model.add_physical_group(dim, entity_tags, physical_group_tag, physical_group_name) - successful_groups += 1 - catch e - @warn "Failed to create physical group tag: $(physical_group_tag), dim: $(dim): $(e)" - failed_groups += 1 - end - end - - @info "Physical groups assigned: $(successful_groups) successful, $(failed_groups) failed out of $(length(entities_by_physical_group_tag)) total" -end +""" +Entity identification functions for the FEMTools.jl module. +These functions handle the identification of entities after boolean operations. +""" + +""" +$(TYPEDSIGNATURES) + +Perform boolean fragmentation on all entities in the model. + +# Arguments + +- `workspace`: The [`FEMWorkspace`](@ref) containing the entities to fragment. + +# Returns + +- Nothing. Modifies the Gmsh model in place. + +# Examples + +```julia +$(FUNCTIONNAME)(workspace) +``` + +# Notes + +This function performs boolean fragmentation on all surfaces and curves in the model. +After fragmentation, the original entities are replaced with new entities that respect +the intersections between them. The original entity tags are no longer valid after +this operation. +""" +function process_fragments(workspace::FEMWorkspace) + + # Get all entities + surfaces = gmsh.model.get_entities(2) + curves = gmsh.model.get_entities(1) + points = gmsh.model.get_entities(0) + + @debug "Initial counts: $(length(surfaces)) surfaces, $(length(curves)) curves, $(length(points)) points" + + # Fragment points onto curves + if !isempty(curves) && !isempty(points) + @debug "Fragmenting points onto curves..." + gmsh.model.occ.fragment(curves, points) + gmsh.model.occ.synchronize() + end + + # Get updated entities after first fragmentation + updated_curves = gmsh.model.get_entities(1) + updated_points = gmsh.model.get_entities(0) + + @debug "After fragmenting points onto curves: $(length(updated_curves)) curves, $(length(updated_points)) points" + + # Fragment curves onto surfaces + if !isempty(surfaces) && !isempty(updated_curves) + @debug "Fragmenting curves onto surfaces..." + gmsh.model.occ.fragment(surfaces, updated_curves) + gmsh.model.occ.synchronize() + end + + # Remove duplicates + @debug "Removing duplicate entities..." + gmsh.model.occ.remove_all_duplicates() + gmsh.model.occ.synchronize() + + # Final counts + final_surfaces = gmsh.model.get_entities(2) + final_curves = gmsh.model.get_entities(1) + final_points = gmsh.model.get_entities(0) + + @info "Boolean fragmentation completed" + @debug "Before: $(length(surfaces)) surfaces, $(length(curves)) curves, $(length(points)) points" + @debug "After: $(length(final_surfaces)) surfaces, $(length(final_curves)) curves, $(length(final_points)) points" + @debug "Unique markers in workspace: $(length(workspace.core.unassigned_entities)) markers" + +end + +function identify_by_marker(workspace::FEMWorkspace) + + # Get all surfaces after fragmentation + all_surfaces = gmsh.model.get_entities(2) + + # Track statistics + total_entities = length(workspace.core.unassigned_entities) + identified_count = 0 + + # Copy keys to avoid modifying dict during iteration + markers = collect(keys(workspace.core.unassigned_entities)) + + # For each marker, find which surface contains it + for marker in markers + entity_data = workspace.core.unassigned_entities[marker] + physical_group_tag = entity_data.core.physical_group_tag + elementary_name = entity_data.core.elementary_name + + for (dim, tag) in all_surfaces + if !(entity_data isa CurveEntity) + # Check if marker is inside this surface + if gmsh.model.is_inside(dim, tag, marker) == 1 + fem_entity = GmshObject(tag, entity_data) + + # Place in appropriate container + if entity_data isa CablePartEntity + if entity_data.cable_part isa AbstractConductorPart + push!(workspace.core.conductors, fem_entity) + elseif entity_data.cable_part isa AbstractInsulatorPart + push!(workspace.core.insulators, fem_entity) + end + elseif entity_data isa SurfaceEntity + push!(workspace.core.space_regions, fem_entity) + end + + delete!(workspace.core.unassigned_entities, marker) + identified_count += 1 + @debug "Marker at $(marker) identified entity $(tag) as $(elementary_name) (tag: $(physical_group_tag))" + break + end + end + end + end + + # Get all remaining curves after fragmentation + all_curves = gmsh.model.get_entities(1) + + # Update keys to avoid modifying dict during iteration + markers = collect(keys(workspace.core.unassigned_entities)) + + # For each marker, find which surface contains it + for marker in markers + entity_data = workspace.core.unassigned_entities[marker] + physical_group_tag = entity_data.core.physical_group_tag + elementary_name = entity_data.core.elementary_name + + for (dim, tag) in all_curves + # Check if marker is inside this curve + if gmsh.model.is_inside(dim, tag, marker) == 1 + # Found match - create GmshObject and add to appropriate container + fem_entity = GmshObject(tag, entity_data) + + # Place in appropriate container + if entity_data isa CurveEntity + push!(workspace.core.boundaries, fem_entity) + end + + delete!(workspace.core.unassigned_entities, marker) + identified_count += 1 + @debug "Marker at $(marker) identified entity $(tag) as $(elementary_name) (tag: $(physical_group_tag))" + break + end + end + end + + # Report identification stats + @info "Entity identification completed: $(identified_count)/$(total_entities) entities identified" + + if !isempty(workspace.core.unassigned_entities) + @warn "$(length(workspace.core.unassigned_entities))/$(total_entities) markers could not be matched to entities" + end +end +function assign_physical_groups(workspace::FEMWorkspace) + # Group entities by physical tag and dimension + entities_by_physical_group_tag = Dict{Tuple{Int,Int},Vector{Int}}() + + # Process all entity containers + for container in [workspace.core.conductors, workspace.core.insulators, workspace.core.space_regions, workspace.core.boundaries] + for entity in container + physical_group_tag = entity.data.core.physical_group_tag + elementary_name = entity.data.core.elementary_name + dim = entity.data isa CurveEntity ? 1 : 2 + + # Key is now a tuple of (physical_group_tag, dimension) + group_key = (physical_group_tag, dim) + + if !haskey(entities_by_physical_group_tag, group_key) + entities_by_physical_group_tag[group_key] = Int[] + end + + # Add this entity to the collection for this physical tag + current_physical_group = gmsh.model.get_physical_groups_for_entity(dim, entity.tag) + if !isempty(current_physical_group) + @debug "Entity $(entity.tag) already has physical group: $(current_physical_group)" + end + push!(entities_by_physical_group_tag[group_key], entity.tag) + + if !isempty(elementary_name) + # Append the complete name to the shape + current_name = gmsh.model.get_entity_name(dim, entity.tag) + if !isempty(current_name) + @debug "Entity $(entity.tag) already has elementary name: $(current_name)" + end + gmsh.model.set_entity_name(dim, entity.tag, elementary_name) + end + end + end + + # Create physical groups for each physical tag + successful_groups = 0 + failed_groups = 0 + + for ((physical_group_tag, dim), entity_tags) in entities_by_physical_group_tag + try + physical_group_name = create_physical_group_name(workspace, physical_group_tag) + @debug "Creating physical group $(physical_group_name) (tag: $(physical_group_tag), dim: $(dim)) with $(length(entity_tags)) entities" + + # Use the correct dimension when creating the physical group + gmsh.model.add_physical_group(dim, entity_tags, physical_group_tag, physical_group_name) + successful_groups += 1 + catch e + @warn "Failed to create physical group tag: $(physical_group_tag), dim: $(dim): $(e)" + failed_groups += 1 + end + end + + @info "Physical groups assigned: $(successful_groups) successful, $(failed_groups) failed out of $(length(entities_by_physical_group_tag)) total" +end diff --git a/src/engine/fem/lineparamopts.jl b/src/engine/fem/lineparamopts.jl index e8eba978..0e4c154b 100644 --- a/src/engine/fem/lineparamopts.jl +++ b/src/engine/fem/lineparamopts.jl @@ -1,34 +1,34 @@ -Base.@kwdef struct FEMOptions <: AbstractFormulationOptions - common::LineParamOptions = LineParamOptions() - - "Build mesh only and preview (no solving)" - mesh_only::Bool = false - "Force mesh regeneration even if file exists" - force_remesh::Bool = false - "Generate field visualization outputs" - plot_field_maps::Bool = true - "Archive temporary files after each frequency run" - keep_run_files::Bool = false - - "Base path for output files" - save_path::String = joinpath(".", "fem_output") - "Path to GetDP executable" - getdp_executable::Union{String, Nothing} = nothing -end - -const _FEM_OWN = Tuple(s for s in fieldnames(FEMOptions) if s != :common) -@inline Base.hasproperty(::FEMOptions, s::Symbol) = - (s in _FEM_OWN) || (s in _COMMON_SYMS) || s === :common - -@inline function Base.getproperty(o::FEMOptions, s::Symbol) - s === :common && return getfield(o, :common) - (s in _FEM_OWN) && return getfield(o, s) # FEM-specific - (s in _COMMON_SYMS) && return getfield(o.common, s) # forwarded common - throw(ArgumentError("Unknown option $(s) for $(typeof(o))")) -end - -Base.propertynames(::FEMOptions, ::Bool = false) = (_COMMON_SYMS..., _FEM_OWN..., :common) -Base.get(o::FEMOptions, s::Symbol, default) = - hasproperty(o, s) ? getproperty(o, s) : default -asnamedtuple(o::FEMOptions) = (; (k=>getproperty(o, k) for k in propertynames(o))...) -# asnamedtuple(o::FEMOptions) = (; (k=>getproperty(o,k) for k in propertynames(o) if k != :common)...) +Base.@kwdef struct FEMOptions <: AbstractFormulationOptions + common::LineParamOptions = LineParamOptions() + + "Build mesh only and preview (no solving)" + mesh_only::Bool = false + "Force mesh regeneration even if file exists" + force_remesh::Bool = false + "Generate field visualization outputs" + plot_field_maps::Bool = true + "Archive temporary files after each frequency run" + keep_run_files::Bool = false + + "Base path for output files" + save_path::String = joinpath(".", "fem_output") + "Path to GetDP executable" + getdp_executable::Union{String, Nothing} = nothing +end + +const _FEM_OWN = Tuple(s for s in fieldnames(FEMOptions) if s != :common) +@inline Base.hasproperty(::FEMOptions, s::Symbol) = + (s in _FEM_OWN) || (s in _COMMON_SYMS) || s === :common + +@inline function Base.getproperty(o::FEMOptions, s::Symbol) + s === :common && return getfield(o, :common) + (s in _FEM_OWN) && return getfield(o, s) # FEM-specific + (s in _COMMON_SYMS) && return getfield(o.common, s) # forwarded common + throw(ArgumentError("Unknown option $(s) for $(typeof(o))")) +end + +Base.propertynames(::FEMOptions, ::Bool = false) = (_COMMON_SYMS..., _FEM_OWN..., :common) +Base.get(o::FEMOptions, s::Symbol, default) = + hasproperty(o, s) ? getproperty(o, s) : default +asnamedtuple(o::FEMOptions) = (; (k=>getproperty(o, k) for k in propertynames(o))...) +# asnamedtuple(o::FEMOptions) = (; (k=>getproperty(o,k) for k in propertynames(o) if k != :common)...) diff --git a/src/engine/fem/materialprops.jl b/src/engine/fem/materialprops.jl index 9599143b..9256d0c6 100644 --- a/src/engine/fem/materialprops.jl +++ b/src/engine/fem/materialprops.jl @@ -1,127 +1,134 @@ -""" -Material handling functions for the FEMTools.jl module. -These functions handle the management of material properties. -""" - -""" -$(TYPEDSIGNATURES) - -Get the name of a material from a materials library. - -# Arguments - -- `material`: The [`Material`](@ref) object to find. -- `library`: The [`MaterialsLibrary`](@ref) to search in. -- `tol`: Tolerance for floating-point comparisons \\[dimensionless\\]. Default: 1e-6. - -# Returns - -- The name of the material if found, or a hash-based name if not found. - -# Examples - -```julia -name = $(FUNCTIONNAME)(material, materials) -``` -""" -function get_material_name(material::Material, library::MaterialsLibrary; tol = 1e-6) - # If material has infinite resistivity, it's air - if isinf(to_nominal(material.rho)) - return "air" - end - - # Convert values to nominal (remove uncertainties) - rho = to_nominal(material.rho) - eps_r = to_nominal(material.eps_r) - mu_r = to_nominal(material.mu_r) - alpha = to_nominal(material.alpha) - - # Try to find an exact match - for (name, lib_material) in library - # Check if all properties match within tolerance - if isapprox(rho, to_nominal(lib_material.rho), rtol = tol) && - isapprox(eps_r, to_nominal(lib_material.eps_r), rtol = tol) && - isapprox(mu_r, to_nominal(lib_material.mu_r), rtol = tol) && - isapprox(alpha, to_nominal(lib_material.alpha), rtol = tol) - return name - end - end - - # If no match, create a unique hash-based name - return "material_" * hash_material_properties(material) -end - -""" -$(TYPEDSIGNATURES) - -Create a hash string based on material properties. - -# Arguments - -- `material`: The [`Material`](@ref) object to hash. - -# Returns - -- A string hash of the material properties. - -# Examples - -```julia -hash = $(FUNCTIONNAME)(material) -``` -""" -function hash_material_properties(material::Material) - # Create a deterministic hash based on material properties - rho = to_nominal(material.rho) - eps_r = to_nominal(material.eps_r) - mu_r = to_nominal(material.mu_r) - - rho_str = isinf(rho) ? "inf" : "$(round(rho, sigdigits=6))" - eps_str = "$(round(eps_r, sigdigits=6))" - mu_str = "$(round(mu_r, sigdigits=6))" - - return "rho=$(rho_str)_epsr=$(eps_str)_mu=$(mu_str)" -end - - -function get_earth_model_material(workspace::FEMWorkspace, layer_idx::Int) - - earth_props = workspace.problem_def.earth_props - num_layers = length(earth_props.layers) - - if layer_idx <= num_layers - - # Create a material with the earth properties - rho = to_nominal(earth_props.layers[layer_idx].base_rho_g) # Layer 1 is air, Layer 2 is first earth layer - eps_r = to_nominal(earth_props.layers[layer_idx].base_epsr_g) - mu_r = to_nominal(earth_props.layers[layer_idx].base_mur_g) - - return Material(rho, eps_r, mu_r, 20.0, 0.0) - else - # Default to bottom earth layer if layer_idx is out of bounds - - # Create a material with the earth properties - rho = to_nominal(earth_props.layers[end].base_rho_g) # Layer 1 is air, Layer 2 is first earth layer - eps_r = to_nominal(earth_props.layers[end].base_epsr_g) - mu_r = to_nominal(earth_props.layers[end].base_mur_g) - - return Material(rho, eps_r, mu_r, 20.0, 0.0) - end -end - -function get_air_material(workspace::FEMWorkspace) - if !isnothing(workspace.formulation.materials) - airm = get(workspace.formulation.materials, "air") - - if isnothing(airm) - @warn("Air material not found in database. Overriding with default properties.") - air_material = Material(Inf, 1.0, 1.0, 20.0, 0.0) - else - rho = to_nominal(airm.rho) - eps_r = to_nominal(airm.eps_r) - mu_r = to_nominal(airm.mu_r) - air_material = Material(rho, eps_r, mu_r, 20.0, 0.0) - end - end - return air_material -end +""" +Material handling functions for the FEMTools.jl module. +These functions handle the management of material properties. +""" + +""" +$(TYPEDSIGNATURES) + +Get the name of a material from a materials library. + +# Arguments + +- `material`: The [`Material`](@ref) object to find. +- `library`: The [`MaterialsLibrary`](@ref) to search in. +- `tol`: Tolerance for floating-point comparisons \\[dimensionless\\]. Default: 1e-6. + +# Returns + +- The name of the material if found, or a hash-based name if not found. + +# Examples + +```julia +name = $(FUNCTIONNAME)(material, materials) +``` +""" +function get_material_name(material::Material, library::MaterialsLibrary; tol = 1e-6) + # If material has infinite resistivity, it's air + if isinf(to_nominal(material.rho)) + return "air" + end + + # Convert values to nominal (remove uncertainties) + rho = to_nominal(material.rho) + eps_r = to_nominal(material.eps_r) + mu_r = to_nominal(material.mu_r) + alpha = to_nominal(material.alpha) + kappa = to_nominal(material.kappa) + + # Try to find an exact match + for (name, lib_material) in library + # Check if all properties match within tolerance + if isapprox(rho, to_nominal(lib_material.rho), rtol = tol) && + isapprox(eps_r, to_nominal(lib_material.eps_r), rtol = tol) && + isapprox(mu_r, to_nominal(lib_material.mu_r), rtol = tol) && + isapprox(alpha, to_nominal(lib_material.alpha), rtol = tol) && + isapprox(kappa, to_nominal(lib_material.kappa), rtol = tol) + return name + end + end + + # If no match, create a unique hash-based name + return "material_" * hash_material_properties(material) +end + +""" +$(TYPEDSIGNATURES) + +Create a hash string based on material properties. + +# Arguments + +- `material`: The [`Material`](@ref) object to hash. + +# Returns + +- A string hash of the material properties. + +# Examples + +```julia +hash = $(FUNCTIONNAME)(material) +``` +""" +function hash_material_properties(material::Material) + # Create a deterministic hash based on material properties + rho = to_nominal(material.rho) + eps_r = to_nominal(material.eps_r) + mu_r = to_nominal(material.mu_r) + kappa = to_nominal(material.kappa) + + rho_str = isinf(rho) ? "inf" : "$(round(rho, sigdigits=6))" + eps_str = "$(round(eps_r, sigdigits=6))" + mu_str = "$(round(mu_r, sigdigits=6))" + kappa_str = "$(round(kappa, sigdigits=6))" + + return "rho=$(rho_str)_epsr=$(eps_str)_mu=$(mu_str)_kappa=$(kappa_str)" +end + + +function get_earth_model_material(workspace::FEMWorkspace, layer_idx::Int) + + earth_props = workspace.core.earth_props + num_layers = length(earth_props.layers) + + if layer_idx <= num_layers + + # Create a material with the earth properties + rho = to_nominal(earth_props.layers[layer_idx].base_rho_g) # Layer 1 is air, Layer 2 is first earth layer + eps_r = to_nominal(earth_props.layers[layer_idx].base_epsr_g) + mu_r = to_nominal(earth_props.layers[layer_idx].base_mur_g) + kappa = to_nominal(earth_props.layers[layer_idx].base_kappa_g) + + return Material(rho, eps_r, mu_r, 20.0, 0.0, kappa) + else + # Default to bottom earth layer if layer_idx is out of bounds + + # Create a material with the earth properties + rho = to_nominal(earth_props.layers[end].base_rho_g) # Layer 1 is air, Layer 2 is first earth layer + eps_r = to_nominal(earth_props.layers[end].base_epsr_g) + mu_r = to_nominal(earth_props.layers[end].base_mur_g) + kappa = to_nominal(earth_props.layers[end].base_kappa_g) + + return Material(rho, eps_r, mu_r, 20.0, 0.0, kappa) + end +end + +function get_air_material(workspace::FEMWorkspace) + if !isnothing(workspace.core.formulation.materials) + airm = get(workspace.core.formulation.materials, "air") + + if isnothing(airm) + @warn("Air material not found in database. Overriding with default properties.") + air_material = Material(Inf, 1.0, 1.0, 20.0, 0.0) + else + rho = to_nominal(airm.rho) + eps_r = to_nominal(airm.eps_r) + mu_r = to_nominal(airm.mu_r) + kappa = to_nominal(airm.kappa) + air_material = Material(rho, eps_r, mu_r, 20.0, 0.0, kappa) + end + end + return air_material +end diff --git a/src/engine/fem/mesh.jl b/src/engine/fem/mesh.jl index 219dfcc4..4d12b3a1 100644 --- a/src/engine/fem/mesh.jl +++ b/src/engine/fem/mesh.jl @@ -1,340 +1,340 @@ -""" -Mesh generation functions for the FEMTools.jl module. -These functions handle the configuration and generation of the mesh. -""" - -""" -$(TYPEDSIGNATURES) - -Calculate the skin depth for a conductive material. - -# Arguments - -- `rho`: Electrical resistivity \\[Ω·m\\]. -- `mu_r`: Relative permeability \\[dimensionless\\]. -- `freq`: Frequency \\[Hz\\]. - -# Returns - -- Skin depth \\[m\\]. - -# Examples - -```julia -depth = $(FUNCTIONNAME)(1.7241e-8, 1.0, 50.0) -``` - -# Notes - -```math -\\delta = \\sqrt{\\frac{\\rho}{\\pi \\cdot f \\cdot \\mu_0 \\cdot \\mu_r}} -``` - -where \\(\\mu_0 = 4\\pi \\times 10^{-7}\\) H/m is the vacuum permeability. -""" -function calc_skin_depth(rho::Number, mu_r::Number, freq::Number) - # Convert to nominal values in case of Measurement types - rho = to_nominal(rho) - mu_r = to_nominal(mu_r) - - # Constants - mu_0 = 4e-7 * π # Vacuum permeability - - # Calculate skin depth - # δ = sqrt(ρ / (π * f * μ_0 * μ_r)) - return sqrt(rho / (π * freq * mu_0 * mu_r)) -end - -function _calc_mesh_size(part::AbstractCablePart, workspace::FEMWorkspace) - - # Extract geometric properties - radius_in = to_nominal(part.radius_in) - radius_ext = to_nominal(part.radius_ext) - thickness = radius_ext - radius_in - - # Extract formulation parameters - formulation = workspace.formulation - - # Calculate mesh size based on part type and properties - scale_length = thickness - if part isa WireArray - # For wire arrays, consider the wire radius - scale_length = to_nominal(part.radius_wire) * 2 - num_elements = formulation.elements_per_length_conductor - elseif part isa AbstractConductorPart - num_elements = formulation.elements_per_length_conductor - elseif part isa Insulator - num_elements = formulation.elements_per_length_insulator - elseif part isa Semicon - num_elements = formulation.elements_per_length_semicon - end - - # Apply bounds from configuration - mesh_size = scale_length / num_elements - mesh_size = max(mesh_size, formulation.mesh_size_min) - mesh_size = min(mesh_size, formulation.mesh_size_max) - - return mesh_size -end - -function _calc_mesh_size(radius_in::Number, radius_ext::Number, material::Material, num_elements::Int, workspace::FEMWorkspace) - # Extract geometric properties - thickness = radius_ext - radius_in - - # Extract problem_def parameters - formulation = workspace.formulation - mesh_size = thickness / num_elements - - # Apply bounds from configuration - mesh_size = max(mesh_size, formulation.mesh_size_min) - mesh_size = min(mesh_size, formulation.mesh_size_max) - - return mesh_size -end - -""" -$(TYPEDSIGNATURES) - -Configure mesh sizes for all entities in the model. - -# Arguments - -- `workspace`: The [`FEMWorkspace`](@ref) containing the entities. - -# Returns - -- Nothing. Updates the mesh size map in the workspace. - -# Examples - -```julia -$(FUNCTIONNAME)(workspace) -``` -""" -function config_mesh_options(workspace::FEMWorkspace) - - - gmsh.option.set_number("General.InitialModule", 2) - - # Set mesh algorithm - gmsh.option.set_number("Mesh.Algorithm", workspace.formulation.mesh_algorithm) - gmsh.option.set_number("Mesh.AlgorithmSwitchOnFailure", 1) - # Set mesh optimization parameters - gmsh.option.set_number("Mesh.Optimize", 0) - gmsh.option.set_number("Mesh.OptimizeNetgen", 0) - - # Set mesh globals - gmsh.option.set_number("Mesh.SaveAll", 1) # Mesh all regions - gmsh.option.set_number("Mesh.MaxRetries", workspace.formulation.mesh_max_retries) - gmsh.option.set_number("Mesh.MeshSizeMin", workspace.formulation.mesh_size_min) - gmsh.option.set_number("Mesh.MeshSizeMax", workspace.formulation.mesh_size_max) - gmsh.option.set_number("Mesh.MeshSizeFromPoints", 1) - gmsh.option.set_number("Mesh.MeshSizeFromParametricPoints", 0) - - gmsh.option.set_number("Mesh.MeshSizeExtendFromBoundary", 1) - gmsh.option.set_number("Mesh.MeshSizeFromCurvature", workspace.formulation.points_per_circumference) - - - @debug "Mesh algorithm: $(workspace.formulation.mesh_algorithm)" - @debug "Mesh size range: [$(workspace.formulation.mesh_size_min), $(workspace.formulation.mesh_size_max)]" -end - -""" -$(TYPEDSIGNATURES) - -Generate the mesh. - -# Arguments - -- `workspace`: The [`FEMWorkspace`](@ref) containing the model. - -# Returns - -- Nothing. Generates the mesh in the Gmsh model. - -# Examples - -```julia -$(FUNCTIONNAME)(workspace) -``` -""" -function generate_mesh(workspace::FEMWorkspace) - # Generate 2D mesh - gmsh.model.mesh.generate(2) - - # Get mesh statistics - nodes = gmsh.model.mesh.get_nodes() - elements = gmsh.model.mesh.get_elements() - - num_nodes = length(nodes[1]) - num_elements = sum(length.(elements[2])) - - @info "Mesh generation completed" - @info "Created mesh with $(num_nodes) nodes and $(num_elements) elements" -end - -""" -$(TYPEDSIGNATURES) - -Initialize a Gmsh model with appropriate settings. - -# Arguments - -- `case_id`: Identifier for the model. -- `problem_def`: The [`FEMFormulation`](@ref) containing mesh parameters. -- `solver`: The [`FEMSolver`](@ref) containing visualization parameters. - -# Returns - -- Nothing. Initializes the Gmsh model. - -# Examples - -```julia -$(FUNCTIONNAME)("test_case", problem_def, solver) -``` -""" -function initialize_gmsh(workspace::FEMWorkspace) - # Create a new model - system_id = workspace.problem_def.system.system_id - gmsh.model.add(system_id) - - # Module launched on startup (0: automatic, 1: geometry, 2: mesh, 3: solver, 4: post-processing) - gmsh.option.set_number("General.InitialModule", 0) - gmsh.option.set_string("General.DefaultFileName", system_id * ".geo") - - # Define verbosity level - gmsh_verbosity = map_verbosity_to_gmsh(workspace.opts.verbosity) - gmsh.option.set_number("General.Verbosity", gmsh_verbosity) - - # Set OCC model healing options - gmsh.option.set_number("Geometry.AutoCoherence", 1) - gmsh.option.set_number("Geometry.OCCFixDegenerated", 1) - gmsh.option.set_number("Geometry.OCCFixSmallEdges", 1) - gmsh.option.set_number("Geometry.OCCFixSmallFaces", 1) - gmsh.option.set_number("Geometry.OCCSewFaces", 1) - gmsh.option.set_number("Geometry.OCCMakeSolids", 1) - - # Log settings based on verbosity - @info "Initialized Gmsh model: $system_id" - -end - -function _do_make_mesh!(workspace::FEMWorkspace) - - # Initialize Gmsh model and set parameters - initialize_gmsh(workspace) - - # Create geometry - @info "Creating domain boundaries..." - make_space_geometry(workspace) - - @info "Creating cable geometry..." - make_cable_geometry(workspace) - - # Synchronize the model - gmsh.model.occ.synchronize() - - # Boolean operations - @info "Performing boolean operations..." - process_fragments(workspace) - - # Entity identification and entity assignment - @info "Identifying entities after fragmentation..." - identify_by_marker(workspace) - - # Physical group assignment - @info "Assigning physical groups..." - assign_physical_groups(workspace) - - # Mesh sizing - @info "Setting up mesh sizing..." - config_mesh_options(workspace) - - # Mesh generation - @info "Generating mesh..." - generate_mesh(workspace) - - # Save mesh - @info "Saving mesh to file: $(display_path(workspace.paths[:mesh_file]))" - gmsh.write(workspace.paths[:mesh_file]) - - # Save geometry - @info "Saving geometry to file: $(display_path(workspace.paths[:geo_file]))" - gmsh.write(workspace.paths[:geo_file]) -end - -function mesh_exists(workspace::FEMWorkspace) - mesh_file = workspace.paths[:mesh_file] - - # Force remesh overrides everything - if workspace.opts.force_remesh - @debug "Force remesh requested" - return false - end - - # If workspace is empty (no entities), force remesh regardless of file existence - if isempty(workspace.conductors) && isempty(workspace.insulators) && isempty(workspace.space_regions) && isempty(workspace.boundaries) && isempty(workspace.physical_groups) && isempty(workspace.material_registry) - @warn "Empty workspace detected - forcing remesh" - return false - end - - # Check if mesh file exists - if !isfile(mesh_file) - @debug "No existing mesh file found" - return false - end - - # Mesh exists - can reuse - @debug "Existing mesh found and will be reused" - return true -end - -function make_mesh!(workspace::FEMWorkspace) - # If mesh exists and we are not forcing a remesh, do nothing and continue. - if mesh_exists(workspace) - @info "Using existing mesh" - return false # Signal to continue to solver - end - - # --- Mesh generation is required from this point on --- - @info "Building mesh for system: $(workspace.problem_def.system.system_id)" - - try - # Ensure Gmsh is initialized - if gmsh.is_initialized() == 0 - gmsh.initialize() - end - - # Perform the actual meshing - _do_make_mesh!(workspace) - @info "Mesh generation completed" - - # Handle mesh-only mode: preview the mesh and stop. - # The Gmsh session is still active here. - if workspace.opts.mesh_only - @info "Mesh-only mode: Opening preview. Close the preview window to continue." - preview_mesh(workspace) - @info "Preview closed. Halting computation as per mesh_only=true." - return true # Signal to stop computation - end - - catch e - @error "An error occurred during mesh generation or preview" exception = e - rethrow(e) - finally - # CRITICAL: Finalize Gmsh only after all operations, including the - # potential preview, are complete. This ensures the session is - # always closed cleanly. - if gmsh.is_initialized() == 1 - try - gmsh.finalize() - catch fin_err - @warn "Gmsh finalization error" exception = fin_err - end - end - end - - # If we are not in mesh_only mode, signal to continue to the solver. - return false +""" +Mesh generation functions for the FEMTools.jl module. +These functions handle the configuration and generation of the mesh. +""" + +""" +$(TYPEDSIGNATURES) + +Calculate the skin depth for a conductive material. + +# Arguments + +- `rho`: Electrical resistivity \\[Ω·m\\]. +- `mu_r`: Relative permeability \\[dimensionless\\]. +- `freq`: Frequency \\[Hz\\]. + +# Returns + +- Skin depth \\[m\\]. + +# Examples + +```julia +depth = $(FUNCTIONNAME)(1.7241e-8, 1.0, 50.0) +``` + +# Notes + +```math +\\delta = \\sqrt{\\frac{\\rho}{\\pi \\cdot f \\cdot \\mu_0 \\cdot \\mu_r}} +``` + +where \\(\\mu_0 = 4\\pi \\times 10^{-7}\\) H/m is the vacuum permeability. +""" +function calc_skin_depth(rho::Number, mu_r::Number, freq::Number) + # Convert to nominal values in case of Measurement types + rho = to_nominal(rho) + mu_r = to_nominal(mu_r) + + # Constants + mu_0 = 4e-7 * π # Vacuum permeability + + # Calculate skin depth + # δ = sqrt(ρ / (π * f * μ_0 * μ_r)) + return sqrt(rho / (π * freq * mu_0 * mu_r)) +end + +function _calc_mesh_size(part::AbstractCablePart, workspace::FEMWorkspace) + + # Extract geometric properties + radius_in = to_nominal(part.radius_in) + radius_ext = to_nominal(part.radius_ext) + thickness = radius_ext - radius_in + + # Extract formulation parameters + formulation = workspace.core.formulation + + # Calculate mesh size based on part type and properties + scale_length = thickness + if part isa WireArray + # For wire arrays, consider the wire radius + scale_length = to_nominal(part.radius_wire) * 2 + num_elements = formulation.elements_per_length_conductor + elseif part isa AbstractConductorPart + num_elements = formulation.elements_per_length_conductor + elseif part isa Insulator + num_elements = formulation.elements_per_length_insulator + elseif part isa Semicon + num_elements = formulation.elements_per_length_semicon + end + + # Apply bounds from configuration + mesh_size = scale_length / num_elements + mesh_size = max(mesh_size, formulation.mesh_size_min) + mesh_size = min(mesh_size, formulation.mesh_size_max) + + return mesh_size +end + +function _calc_mesh_size(radius_in::Number, radius_ext::Number, material::Material, num_elements::Int, workspace::FEMWorkspace) + # Extract geometric properties + thickness = radius_ext - radius_in + + # Extract problem_def parameters + formulation = workspace.core.formulation + mesh_size = thickness / num_elements + + # Apply bounds from configuration + mesh_size = max(mesh_size, formulation.mesh_size_min) + mesh_size = min(mesh_size, formulation.mesh_size_max) + + return mesh_size +end + +""" +$(TYPEDSIGNATURES) + +Configure mesh sizes for all entities in the model. + +# Arguments + +- `workspace`: The [`FEMWorkspace`](@ref) containing the entities. + +# Returns + +- Nothing. Updates the mesh size map in the workspace. + +# Examples + +```julia +$(FUNCTIONNAME)(workspace) +``` +""" +function config_mesh_options(workspace::FEMWorkspace) + + + gmsh.option.set_number("General.InitialModule", 2) + + # Set mesh algorithm + gmsh.option.set_number("Mesh.Algorithm", workspace.core.formulation.mesh_algorithm) + gmsh.option.set_number("Mesh.AlgorithmSwitchOnFailure", 1) + # Set mesh optimization parameters + gmsh.option.set_number("Mesh.Optimize", 0) + gmsh.option.set_number("Mesh.OptimizeNetgen", 0) + + # Set mesh globals + gmsh.option.set_number("Mesh.SaveAll", 1) # Mesh all regions + gmsh.option.set_number("Mesh.MaxRetries", workspace.core.formulation.mesh_max_retries) + gmsh.option.set_number("Mesh.MeshSizeMin", workspace.core.formulation.mesh_size_min) + gmsh.option.set_number("Mesh.MeshSizeMax", workspace.core.formulation.mesh_size_max) + gmsh.option.set_number("Mesh.MeshSizeFromPoints", 1) + gmsh.option.set_number("Mesh.MeshSizeFromParametricPoints", 0) + + gmsh.option.set_number("Mesh.MeshSizeExtendFromBoundary", 1) + gmsh.option.set_number("Mesh.MeshSizeFromCurvature", workspace.core.formulation.points_per_circumference) + + + @debug "Mesh algorithm: $(workspace.core.formulation.mesh_algorithm)" + @debug "Mesh size range: [$(workspace.core.formulation.mesh_size_min), $(workspace.core.formulation.mesh_size_max)]" +end + +""" +$(TYPEDSIGNATURES) + +Generate the mesh. + +# Arguments + +- `workspace`: The [`FEMWorkspace`](@ref) containing the model. + +# Returns + +- Nothing. Generates the mesh in the Gmsh model. + +# Examples + +```julia +$(FUNCTIONNAME)(workspace) +``` +""" +function generate_mesh(workspace::FEMWorkspace) + # Generate 2D mesh + gmsh.model.mesh.generate(2) + + # Get mesh statistics + nodes = gmsh.model.mesh.get_nodes() + elements = gmsh.model.mesh.get_elements() + + num_nodes = length(nodes[1]) + num_elements = sum(length.(elements[2])) + + @info "Mesh generation completed" + @info "Created mesh with $(num_nodes) nodes and $(num_elements) elements" +end + +""" +$(TYPEDSIGNATURES) + +Initialize a Gmsh model with appropriate settings. + +# Arguments + +- `case_id`: Identifier for the model. +- `problem_def`: The [`FEMFormulation`](@ref) containing mesh parameters. +- `solver`: The [`FEMSolver`](@ref) containing visualization parameters. + +# Returns + +- Nothing. Initializes the Gmsh model. + +# Examples + +```julia +$(FUNCTIONNAME)("test_case", problem_def, solver) +``` +""" +function initialize_gmsh(workspace::FEMWorkspace) + # Create a new model + system_id = workspace.core.system.system_id + gmsh.model.add(system_id) + + # Module launched on startup (0: automatic, 1: geometry, 2: mesh, 3: solver, 4: post-processing) + gmsh.option.set_number("General.InitialModule", 0) + gmsh.option.set_string("General.DefaultFileName", system_id * ".geo") + + # Define verbosity level + gmsh_verbosity = map_verbosity_to_gmsh(workspace.core.opts.verbosity) + gmsh.option.set_number("General.Verbosity", gmsh_verbosity) + + # Set OCC model healing options + gmsh.option.set_number("Geometry.AutoCoherence", 1) + gmsh.option.set_number("Geometry.OCCFixDegenerated", 1) + gmsh.option.set_number("Geometry.OCCFixSmallEdges", 1) + gmsh.option.set_number("Geometry.OCCFixSmallFaces", 1) + gmsh.option.set_number("Geometry.OCCSewFaces", 1) + gmsh.option.set_number("Geometry.OCCMakeSolids", 1) + + # Log settings based on verbosity + @info "Initialized Gmsh model: $system_id" + +end + +function _do_make_mesh!(workspace::FEMWorkspace) + + # Initialize Gmsh model and set parameters + initialize_gmsh(workspace) + + # Create geometry + @info "Creating domain boundaries..." + make_space_geometry(workspace) + + @info "Creating cable geometry..." + make_cable_geometry(workspace) + + # Synchronize the model + gmsh.model.occ.synchronize() + + # Boolean operations + @info "Performing boolean operations..." + process_fragments(workspace) + + # Entity identification and entity assignment + @info "Identifying entities after fragmentation..." + identify_by_marker(workspace) + + # Physical group assignment + @info "Assigning physical groups..." + assign_physical_groups(workspace) + + # Mesh sizing + @info "Setting up mesh sizing..." + config_mesh_options(workspace) + + # Mesh generation + @info "Generating mesh..." + generate_mesh(workspace) + + # Save mesh + @info "Saving mesh to file: $(display_path(workspace.core.paths[:mesh_file]))" + gmsh.write(workspace.core.paths[:mesh_file]) + + # Save geometry + @info "Saving geometry to file: $(display_path(workspace.core.paths[:geo_file]))" + gmsh.write(workspace.core.paths[:geo_file]) +end + +function mesh_exists(workspace::FEMWorkspace) + mesh_file = workspace.core.paths[:mesh_file] + + # Force remesh overrides everything + if workspace.core.opts.force_remesh + @debug "Force remesh requested" + return false + end + + # If workspace is empty (no entities), force remesh regardless of file existence + if isempty(workspace.core.conductors) && isempty(workspace.core.insulators) && isempty(workspace.core.space_regions) && isempty(workspace.core.boundaries) && isempty(workspace.core.physical_groups) && isempty(workspace.core.material_registry) + @warn "Empty workspace detected - forcing remesh" + return false + end + + # Check if mesh file exists + if !isfile(mesh_file) + @debug "No existing mesh file found" + return false + end + + # Mesh exists - can reuse + @debug "Existing mesh found and will be reused" + return true +end + +function make_mesh!(workspace::FEMWorkspace) + # If mesh exists and we are not forcing a remesh, do nothing and continue. + if mesh_exists(workspace) + @info "Using existing mesh" + return false # Signal to continue to solver + end + + # --- Mesh generation is required from this point on --- + @info "Building mesh for system: $(workspace.core.system.system_id)" + + try + # Ensure Gmsh is initialized + if gmsh.is_initialized() == 0 + gmsh.initialize() + end + + # Perform the actual meshing + _do_make_mesh!(workspace) + @info "Mesh generation completed" + + # Handle mesh-only mode: preview the mesh and stop. + # The Gmsh session is still active here. + if workspace.core.opts.mesh_only + @info "Mesh-only mode: Opening preview. Close the preview window to continue." + preview_mesh(workspace) + @info "Preview closed. Halting computation as per mesh_only=true." + return true # Signal to stop computation + end + + catch e + @error "An error occurred during mesh generation or preview" exception = e + rethrow(e) + finally + # CRITICAL: Finalize Gmsh only after all operations, including the + # potential preview, are complete. This ensures the session is + # always closed cleanly. + if gmsh.is_initialized() == 1 + try + gmsh.finalize() + catch fin_err + @warn "Gmsh finalization error" exception = fin_err + end + end + end + + # If we are not in mesh_only mode, signal to continue to the solver. + return false end \ No newline at end of file diff --git a/src/engine/fem/meshtransitions.jl b/src/engine/fem/meshtransitions.jl index 1ef9bed8..6a3b7632 100644 --- a/src/engine/fem/meshtransitions.jl +++ b/src/engine/fem/meshtransitions.jl @@ -1,108 +1,95 @@ -""" -$(TYPEDEF) - -Defines a mesh transition region for improved mesh quality in earth/air regions around cable systems. - -$(TYPEDFIELDS) -""" -struct MeshTransition - "Center coordinates (x, y) [m]" - center::Tuple{Float64, Float64} - "Minimum radius (must be ≥ bounding radius of cables) [m]" - r_min::Float64 - "Maximum radius [m]" - r_max::Float64 - "Minimum mesh size factor at r_min [m]" - mesh_factor_min::Float64 - "Maximum mesh size factor at r_max [m]" - mesh_factor_max::Float64 - "Number of transition regions [dimensionless]" - n_regions::Int - "Earth layer index (1=air, 2+=earth layers from top to bottom, nothing=auto-detect)" - earth_layer::Union{Int, Nothing} - - function MeshTransition( - center, - r_min, - r_max, - mesh_factor_min, - mesh_factor_max, - n_regions, - earth_layer, - ) - # Basic validation - r_min >= 0 || Base.error("r_min must be greater than or equal to 0") - r_max > r_min || Base.error("r_max must be greater than r_min") - mesh_factor_min > 0 || Base.error("mesh_factor_min must be positive") - mesh_factor_max <= 1 || - Base.error("mesh_factor_max must be smaller than or equal to 1") - mesh_factor_max > mesh_factor_min || - Base.error("mesh_factor_max must be > mesh_factor_min") - n_regions >= 1 || Base.error("n_regions must be at least 1") - - # Validate earth_layer if provided - if !isnothing(earth_layer) - earth_layer >= 1 || - Base.error("earth_layer must be >= 1 (1=air, 2+=earth layers)") - end - - new(center, r_min, r_max, mesh_factor_min, mesh_factor_max, n_regions, earth_layer) - end -end - -# Convenience constructor -function MeshTransition( - cable_system::LineCableSystem, - cable_indices::Vector{Int}; - r_min::Number, - r_length::Number, - mesh_factor_min::Number, - mesh_factor_max::Number, - n_regions::Int = 3, - earth_layer::Union{Int, Nothing} = nothing, -) - (r_min, r_length, mesh_factor_min, mesh_factor_max) = - to_nominal.((r_min, r_length, mesh_factor_min, mesh_factor_max)) - - # Validate cable indices - all(1 <= idx <= length(cable_system.cables) for idx in cable_indices) || - Base.error("Cable indices out of bounds") - - isempty(cable_indices) && Base.error("Cable indices cannot be empty") - - # Get centroid and bounding radius - cx, cy, bounding_radius, _ = - to_nominal.(get_system_centroid(cable_system, cable_indices)) - - # Calculate parameters - if r_min < bounding_radius - @warn "r_min ($r_min m) is smaller than bounding radius ($bounding_radius m). Adjusting r_min to match." - r_min = bounding_radius - end - - r_max = r_min + r_length - - # Auto-detect layer if not specified - if isnothing(earth_layer) - # Simple detection: y >= 0 is air (layer 1), y < 0 is first earth layer (layer 2) - earth_layer = cy >= 0 ? 1 : 2 - @debug "Auto-detected earth_layer=$earth_layer for transition at ($cx, $cy)" - end - - # Validate no surface crossing for underground transitions - if earth_layer > 1 && cy + r_max > 0 - Base.error( - "Transition region would cross earth surface (y=0). Reduce r_length or use separate transition regions.", - ) - end - - return MeshTransition( - (cx, cy), - r_min, - r_max, - mesh_factor_min, - mesh_factor_max, - n_regions, - earth_layer, - ) -end +""" +$(TYPEDEF) + +Defines a mesh transition region for improved mesh quality in earth/air regions around cable systems. + +$(TYPEDFIELDS) +""" +struct MeshTransition + "Center coordinates (x, y) [m]" + center::Tuple{Float64, Float64} + "Minimum radius (must be ≥ bounding radius of cables) [m]" + r_min::Float64 + "Maximum radius [m]" + r_max::Float64 + "Minimum mesh size factor at r_min [m]" + mesh_factor_min::Float64 + "Maximum mesh size factor at r_max [m]" + mesh_factor_max::Float64 + "Number of transition regions [dimensionless]" + n_regions::Int + "Earth layer index (1=air, 2+=earth layers from top to bottom, nothing=auto-detect)" + earth_layer::Union{Int, Nothing} + + function MeshTransition( + center, + r_min, + r_max, + mesh_factor_min, + mesh_factor_max, + n_regions, + earth_layer, + ) + # Basic validation + r_min >= 0 || Base.error("r_min must be greater than or equal to 0") + r_max > r_min || Base.error("r_max must be greater than r_min") + mesh_factor_min > 0 || Base.error("mesh_factor_min must be positive") + mesh_factor_max <= 1 || + Base.error("mesh_factor_max must be smaller than or equal to 1") + mesh_factor_max > mesh_factor_min || + Base.error("mesh_factor_max must be > mesh_factor_min") + n_regions >= 1 || Base.error("n_regions must be at least 1") + + # Validate earth_layer if provided + if !isnothing(earth_layer) + earth_layer >= 1 || + Base.error("earth_layer must be >= 1 (1=air, 2+=earth layers)") + end + + new(center, r_min, r_max, mesh_factor_min, mesh_factor_max, n_regions, earth_layer) + end +end + +# Convenience constructor +function MeshTransition( + cable_system::LineCableSystem, + cable_indices::Vector{Int}; + r_min::Number, + r_length::Number, + mesh_factor_min::Number, + mesh_factor_max::Number, + n_regions::Int = 3, + earth_layer::Union{Int, Nothing} = nothing, +) + (r_min, r_length, mesh_factor_min, mesh_factor_max) = + to_nominal.((r_min, r_length, mesh_factor_min, mesh_factor_max)) + + # Validate cable indices + all(1 <= idx <= length(cable_system.cables) for idx in cable_indices) || + Base.error("Cable indices out of bounds") + + isempty(cable_indices) && Base.error("Cable indices cannot be empty") + + # Get centroid and bounding radius + cx, cy, bounding_radius, _ = + to_nominal.(get_system_centroid(cable_system, cable_indices)) + + # Calculate parameters + if r_min < bounding_radius + @warn "r_min ($r_min m) is smaller than bounding radius ($bounding_radius m). Adjusting r_min to match." + r_min = bounding_radius + end + + r_max = r_min + r_length + + + return MeshTransition( + (cx, cy), + r_min, + r_max, + mesh_factor_min, + mesh_factor_max, + n_regions, + earth_layer, + ) +end diff --git a/src/engine/fem/problemdefs.jl b/src/engine/fem/problemdefs.jl index 70b6022f..9d6cec41 100644 --- a/src/engine/fem/problemdefs.jl +++ b/src/engine/fem/problemdefs.jl @@ -1,190 +1,385 @@ - -# @kwdef struct FEMOptions <: AbstractFormulationOptions -# "Build mesh only and preview (no solving)" -# mesh_only::Bool = false -# "Force mesh regeneration even if file exists" -# force_remesh::Bool = false -# "Skip user confirmation for overwriting results" -# force_overwrite::Bool = false -# "Generate field visualization outputs" -# plot_field_maps::Bool = true -# "Archive temporary files after each frequency run" -# keep_run_files::Bool = false -# "Reduce bundle conductors to equivalent single conductor" -# reduce_bundle::Bool = true -# "Eliminate grounded conductors from the system (Kron reduction)" -# kron_reduction::Bool = true -# "Enforce ideal transposition transposition/snaking" -# ideal_transposition::Bool = true -# "Temperature correction" -# temperature_correction::Bool = true -# "Base path for output files" -# save_path::String = joinpath(".", "fem_output") -# "Path to GetDP executable" -# getdp_executable::Union{String, Nothing} = nothing -# "Verbosity level" -# verbosity::Int = 0 -# "Log file path" -# logfile::Union{String, Nothing} = nothing -# end - -# # The one-line constructor to "promote" a NamedTuple -# FEMOptions(opts::NamedTuple) = FEMOptions(; opts...) - - - -""" -$(TYPEDEF) - -Abstract problem definition type for FEM simulation parameters. -This contains the physics-related parameters of the simulation. - -$(TYPEDFIELDS) -""" -struct FEMFormulation <: AbstractFormulationSet - "Radius of the physical domain \\[m\\]." - domain_radius::Float64 - "Outermost radius to apply the infinity transform \\[m\\]." - domain_radius_inf::Float64 - "Elements per characteristic length for conductors \\[dimensionless\\]." - elements_per_length_conductor::Int - "Elements per characteristic length for insulators \\[dimensionless\\]." - elements_per_length_insulator::Int - "Elements per characteristic length for semiconductors \\[dimensionless\\]." - elements_per_length_semicon::Int - "Elements per characteristic length for interfaces \\[dimensionless\\]." - elements_per_length_interfaces::Int - "Points per circumference length (2π radians) \\[dimensionless\\]." - points_per_circumference::Int - "Analysis types to perform \\[dimensionless\\]." - analysis_type::Tuple{AbstractImpedanceFormulation, AbstractAdmittanceFormulation} - "Minimum mesh size \\[m\\]." - mesh_size_min::Float64 - "Maximum mesh size \\[m\\]." - mesh_size_max::Float64 - "Default mesh size \\[m\\]." - mesh_size_default::Float64 - "Mesh transition regions for improved mesh quality" - mesh_transitions::Vector{MeshTransition} - "Mesh algorithm to use \\[dimensionless\\]." - mesh_algorithm::Int - "Maximum meshing retries and number of recursive subdivisions \\[dimensionless\\]." - mesh_max_retries::Int - "Materials database." - materials::MaterialsLibrary - "Solver options for FEM simulations." - options::FEMOptions - """ - $(TYPEDSIGNATURES) - - Constructs a [`FEMFormulation`](@ref) instance with default values. - - # Arguments - - - `domain_radius`: Domain radius for the simulation \\[m\\]. Default: 5.0. - - `elements_per_length_conductor`: Elements per scale length for conductors \\[dimensionless\\]. Default: 3.0. - - `elements_per_length_insulator`: Elements per scale length for insulators \\[dimensionless\\]. Default: 2.0. - - `elements_per_length_semicon`: Elements per scale length for semiconductors \\[dimensionless\\]. Default: 4.0. - - `elements_per_length_interfaces`: Elements per scale length for interfaces \\[dimensionless\\]. Default: 0.1. - - `analysis_type`: - - `mesh_size_min`: Minimum mesh size \\[m\\]. Default: 1e-4. - - `mesh_size_max`: Maximum mesh size \\[m\\]. Default: 1.0. - - `mesh_size_default`: Default mesh size \\[m\\]. Default: `domain_radius/10`. - - `mesh_algorithm`: Mesh algorithm to use \\[dimensionless\\]. Default: 6. - - `materials`: Materials database. Default: MaterialsLibrary(). - - # Returns - - - A [`FEMFormulation`](@ref) instance with the specified parameters. - - # Examples - - ```julia - # Create a problem definition with default parameters - formulation = $(FUNCTIONNAME)() - - # Create a problem definition with custom parameters - formulation = $(FUNCTIONNAME)( - domain_radius=10.0, - elements_per_length_conductor=5.0, - mesh_algorithm=2 - ) - ``` - """ - function FEMFormulation(; - impedance::AbstractImpedanceFormulation, - admittance::AbstractAdmittanceFormulation, - domain_radius::Float64, - domain_radius_inf::Float64, - elements_per_length_conductor::Int, - elements_per_length_insulator::Int, - elements_per_length_semicon::Int, - elements_per_length_interfaces::Int, - points_per_circumference::Int, - mesh_size_min::Float64, - mesh_size_max::Float64, - mesh_size_default::Float64, - mesh_transitions::Vector{MeshTransition}, - mesh_algorithm::Int, - mesh_max_retries::Int, - materials::MaterialsLibrary, - options::FEMOptions, - ) - - return new( - domain_radius, domain_radius_inf, - elements_per_length_conductor, elements_per_length_insulator, - elements_per_length_semicon, elements_per_length_interfaces, - points_per_circumference, (impedance, admittance), - mesh_size_min, mesh_size_max, mesh_size_default, - mesh_transitions, mesh_algorithm, mesh_max_retries, materials, - options, - ) - end -end - -# Wrapper function to create a FEMFormulation -function FormulationSet(::Val{:FEM}; impedance::AbstractImpedanceFormulation = Darwin(), - admittance::AbstractAdmittanceFormulation = Electrodynamics(), - domain_radius::Float64 = 5.0, - domain_radius_inf::Float64 = 6.25, - elements_per_length_conductor::Int = 3, - elements_per_length_insulator::Int = 2, - elements_per_length_semicon::Int = 4, - elements_per_length_interfaces::Int = 3, - points_per_circumference::Int = 16, - mesh_size_min::Float64 = 1e-4, - mesh_size_max::Float64 = 1.0, - mesh_size_default::Float64 = domain_radius / 10, - mesh_transitions::Vector{MeshTransition} = MeshTransition[], - mesh_algorithm::Int = 5, - mesh_max_retries::Int = 20, - materials::MaterialsLibrary = MaterialsLibrary(), - options = (;), -) - # Resolve solver path - validated_path = _resolve_getdp_path(options) - - # Create a new NamedTuple with the validated path overwriting any user value - final_opts = merge(options, (getdp_executable = validated_path,)) - fem_opts = build_options(FEMOptions, final_opts; strict = true) - - return FEMFormulation(; impedance = impedance, - admittance = admittance, - domain_radius = domain_radius, - domain_radius_inf = domain_radius_inf, - elements_per_length_conductor = elements_per_length_conductor, - elements_per_length_insulator = elements_per_length_insulator, - elements_per_length_semicon = elements_per_length_semicon, - elements_per_length_interfaces = elements_per_length_interfaces, - points_per_circumference = points_per_circumference, - mesh_size_min = mesh_size_min, - mesh_size_max = mesh_size_max, - mesh_size_default = mesh_size_default, - mesh_transitions = mesh_transitions, - mesh_algorithm = mesh_algorithm, - mesh_max_retries = mesh_max_retries, - materials = materials, - options = fem_opts, - ) -end +# @kwdef struct FEMOptions <: AbstractFormulationOptions +# "Build mesh only and preview (no solving)" +# mesh_only::Bool = false +# "Force mesh regeneration even if file exists" +# force_remesh::Bool = false +# "Skip user confirmation for overwriting results" +# force_overwrite::Bool = false +# "Generate field visualization outputs" +# plot_field_maps::Bool = true +# "Archive temporary files after each frequency run" +# keep_run_files::Bool = false +# "Reduce bundle conductors to equivalent single conductor" +# reduce_bundle::Bool = true +# "Eliminate grounded conductors from the system (Kron reduction)" +# kron_reduction::Bool = true +# "Enforce ideal transposition transposition/snaking" +# ideal_transposition::Bool = true +# "Temperature correction" +# temperature_correction::Bool = true +# "Base path for output files" +# save_path::String = joinpath(".", "fem_output") +# "Path to GetDP executable" +# getdp_executable::Union{String, Nothing} = nothing +# "Verbosity level" +# verbosity::Int = 0 +# "Log file path" +# logfile::Union{String, Nothing} = nothing +# end + +# # The one-line constructor to "promote" a NamedTuple +# FEMOptions(opts::NamedTuple) = FEMOptions(; opts...) + + + +""" +$(TYPEDEF) + +Abstract problem definition type for FEM simulation parameters. +This contains the physics-related parameters of the simulation. + +$(TYPEDFIELDS) +""" +@kwdef struct FEMFormulation <: AbstractFormulationSet + "Radius of the physical domain \\[m\\]." + domain_radius::Float64 + "Outermost radius to apply the infinity transform \\[m\\]." + domain_radius_inf::Float64 + "Elements per characteristic length for conductors \\[dimensionless\\]." + elements_per_length_conductor::Int + "Elements per characteristic length for insulators \\[dimensionless\\]." + elements_per_length_insulator::Int + "Elements per characteristic length for semiconductors \\[dimensionless\\]." + elements_per_length_semicon::Int + "Elements per characteristic length for interfaces \\[dimensionless\\]." + elements_per_length_interfaces::Int + "Points per circumference length (2π radians) \\[dimensionless\\]." + points_per_circumference::Int + "Analysis types to perform \\[dimensionless\\]." + analysis_type::Union{Tuple{AbstractImpedanceFormulation, AbstractAdmittanceFormulation}, AmpacityFormulation} + "Minimum mesh size \\[m\\]." + mesh_size_min::Float64 + "Maximum mesh size \\[m\\]." + mesh_size_max::Float64 + "Default mesh size \\[m\\]." + mesh_size_default::Float64 + "Mesh transition regions for improved mesh quality" + mesh_transitions::Vector{MeshTransition} + "Mesh algorithm to use \\[dimensionless\\]." + mesh_algorithm::Int + "Maximum meshing retries and number of recursive subdivisions \\[dimensionless\\]." + mesh_max_retries::Int + "Materials database." + materials::MaterialsLibrary + "Solver options for FEM simulations." + options::FEMOptions +end + +""" +$(TYPEDSIGNATURES) + +Inner constructor for `FEMFormulation` for impedance/admittance analysis. + +This constructor is intended for internal use, called by wrapper functions like `FormulationSet`. +It accepts all parameters positionally and performs no default value setting or path validation. + +# Arguments + +- impedance: Impedance formulation to use`. +- admittance: Admittance formulation to use`. +- domain_radius: Domain radius for the simulation \\[m\\]. Default: 5.0. +- elements_per_length_conductor: Elements per scale length for conductors \\[dimensionless\\]. Default: 3.0. +- elements_per_length_insulator: Elements per scale length for insulators \\[dimensionless\\]. Default: 2.0. +- elements_per_length_semicon: Elements per scale length for semiconductors \\[dimensionless\\]. Default: 4.0. +- elements_per_length_interfaces: Elements per scale length for interfaces \\[dimensionless\\]. Default: 0.1. +- analysis_type: +- mesh_size_min: Minimum mesh size \\[m\\]. Default: 1e-4. +- mesh_size_max: Maximum mesh size \\[m\\]. Default: 1.0. +- mesh_size_default: Default mesh size \\[m\\]. Default: domain_radius/10. +- mesh_algorithm: Mesh algorithm to use \\[dimensionless\\]. Default: 6. +- materials: Materials database. Default: MaterialsLibrary(). + +# Returns + +- A [FEMFormulation](@ref) instance with the specified parameters. + +# Examples + +julia +# Create a problem definition with default parameters +formulation = $(FUNCTIONNAME)() + +# Create a problem definition with custom parameters +formulation = $(FUNCTIONNAME)( + domain_radius=10.0, + elements_per_length_conductor=5.0, + mesh_algorithm=2 +) + +""" +function FEMFormulation(impedance::AbstractImpedanceFormulation, + admittance::AbstractAdmittanceFormulation, + domain_radius::Float64, + domain_radius_inf::Float64, + elements_per_length_conductor::Int, + elements_per_length_insulator::Int, + elements_per_length_semicon::Int, + elements_per_length_interfaces::Int, + points_per_circumference::Int, + mesh_size_min::Float64, + mesh_size_max::Float64, + mesh_size_default::Float64, + mesh_transitions::Vector{MeshTransition}, + mesh_algorithm::Int, + mesh_max_retries::Int, + materials::MaterialsLibrary, + options::FEMOptions, +) + + return new( + domain_radius, domain_radius_inf, + elements_per_length_conductor, elements_per_length_insulator, + elements_per_length_semicon, elements_per_length_interfaces, + points_per_circumference, (impedance, admittance), + mesh_size_min, mesh_size_max, mesh_size_default, + mesh_transitions, mesh_algorithm, mesh_max_retries, materials, + options, + ) +end +""" +$(TYPEDSIGNATURES) + +Inner constructor for `FEMFormulation` for ampacity analysis. + +This constructor is intended for internal use, called by wrapper functions like `FormulationSet`. +It accepts all parameters positionally, resolves the GetDP solver path, and builds the final `FEMOptions`. + +# Arguments +- `analysis_spec`: Ampacity formulation (e.g., `MagnetoThermal()`). +- `domain_radius`: Domain radius for the simulation [m]. +- `domain_radius_inf`: Domain radius for the infinite element transformation [m]. +- `elements_per_length_conductor`: Elements per scale length for conductors. +- `elements_per_length_insulator`: Elements per scale length for insulators. +- `elements_per_length_semicon`: Elements per scale length for semiconductors. +- `elements_per_length_interfaces`: Elements per scale length for interfaces. +- `points_per_circumference`: Number of points to discretize circular boundaries. +- `mesh_size_min`: Minimum mesh size [m]. +- `mesh_size_max`: Maximum mesh size [m]. +- `mesh_size_default`: Default mesh size [m]. +- `mesh_transitions`: Vector of `MeshTransition` objects for mesh refinement. +- `mesh_algorithm`: Mesh algorithm to use. +- `mesh_max_retries`: Maximum number of mesh generation retries. +- `materials`: `MaterialsLibrary` instance. +- `options`: `FEMOptions` instance. + +# Returns +- A new `FEMFormulation` instance. +""" +function FEMFormulation(analysis_spec::AmpacityFormulation, # analysis specification + domain_radius::Float64, + domain_radius_inf::Float64, + elements_per_length_conductor::Int, + elements_per_length_insulator::Int, + elements_per_length_semicon::Int, + elements_per_length_interfaces::Int, + points_per_circumference::Int, + mesh_size_min::Float64, + mesh_size_max::Float64, + mesh_size_default::Float64, + mesh_transitions::Vector{MeshTransition}, + mesh_algorithm::Int, + mesh_max_retries::Int, + materials::MaterialsLibrary, + options::FEMOptions, +) + + return new( + analysis_type = analysis_spec, + domain_radius, domain_radius_inf, + elements_per_length_conductor, elements_per_length_insulator, + elements_per_length_semicon, elements_per_length_interfaces, + points_per_circumference, analysis_spec, + mesh_size_min, mesh_size_max, mesh_size_default, + mesh_transitions, mesh_algorithm, mesh_max_retries, materials, + options = fem_opts, + ) +end + + +""" +$(TYPEDSIGNATURES) + +Constructs a `FEMFormulation` for impedance and admittance analysis. + +This function acts as a user-facing constructor, providing default values for +common simulation parameters. + +# Arguments +- `impedance`: Impedance formulation to use. Default: `Darwin()`. +- `admittance`: Admittance formulation to use. Default: `Electrodynamics()`. +- `domain_radius`: Domain radius for the simulation [m]. Default: `5.0`. +- `domain_radius_inf`: Domain radius for the infinite element transformation [m]. Default: `6.25`. +- `elements_per_length_conductor`: Elements per scale length for conductors. Default: `3`. +- `elements_per_length_insulator`: Elements per scale length for insulators. Default: `2`. +- `elements_per_length_semicon`: Elements per scale length for semiconductors. Default: `4`. +- `elements_per_length_interfaces`: Elements per scale length for interfaces. Default: `3`. +- `points_per_circumference`: Number of points to discretize circular boundaries. Default: `16`. +- `mesh_size_min`: Minimum mesh size [m]. Default: `1e-4`. +- `mesh_size_max`: Maximum mesh size [m]. Default: `1.0`. +- `mesh_size_default`: Default mesh size [m]. Default: `domain_radius / 10`. +- `mesh_transitions`: Vector of `MeshTransition` objects for mesh refinement. Default: `MeshTransition[]`. +- `mesh_algorithm`: Mesh algorithm to use. Default: `5`. +- `mesh_max_retries`: Maximum number of mesh generation retries. Default: `20`. +- `materials`: Materials database. Default: `MaterialsLibrary()`. +- `options`: `NamedTuple` of options to be converted to `FEMOptions`. Default: `(; )`. + +# Returns +- A `FEMFormulation` instance configured for FEM analysis. + +# Examples +```julia +# Create a FEM formulation with default parameters +formulation_fem = $(FUNCTIONNAME)(Val(:FEM)) + +# Create a FEM formulation with custom parameters +formulation_fem_custom = $(FUNCTIONNAME)( + Val(:FEM); + domain_radius=10.0, + elements_per_length_conductor=5, + mesh_algorithm=2 +) +``` +""" +function FormulationSet(::Val{:FEM}; impedance::AbstractImpedanceFormulation = Darwin(), + admittance::AbstractAdmittanceFormulation = Electrodynamics(), + domain_radius::Float64 = 5.0, + domain_radius_inf::Float64 = 6.25, + elements_per_length_conductor::Int = 3, + elements_per_length_insulator::Int = 2, + elements_per_length_semicon::Int = 4, + elements_per_length_interfaces::Int = 3, + points_per_circumference::Int = 16, + mesh_size_min::Float64 = 1e-4, + mesh_size_max::Float64 = 1.0, + mesh_size_default::Float64 = domain_radius / 10, + mesh_transitions::Vector{MeshTransition} = MeshTransition[], + mesh_algorithm::Int = 5, + mesh_max_retries::Int = 20, + materials::MaterialsLibrary = MaterialsLibrary(), + options = (;), +) + # Resolve solver path + validated_path = _resolve_getdp_path(options) + + # Create a new NamedTuple with the validated path overwriting any user value + final_opts = merge(options, (getdp_executable = validated_path,)) + fem_opts = build_options(FEMOptions, final_opts; strict = true) + + return FEMFormulation( + analysis_type = (impedance, admittance), + domain_radius = domain_radius, + domain_radius_inf = domain_radius_inf, + elements_per_length_conductor = elements_per_length_conductor, + elements_per_length_insulator = elements_per_length_insulator, + elements_per_length_semicon = elements_per_length_semicon, + elements_per_length_interfaces = elements_per_length_interfaces, + points_per_circumference = points_per_circumference, + mesh_size_min = mesh_size_min, + mesh_size_max = mesh_size_max, + mesh_size_default = mesh_size_default, + mesh_transitions = mesh_transitions, + mesh_algorithm = mesh_algorithm, + mesh_max_retries = mesh_max_retries, + materials = materials, + options = fem_opts, + ) +end + + +""" +$(TYPEDSIGNATURES) + +Constructs a `FEMFormulation` for ampacity analysis. + +This function acts as a user-facing constructor, providing default values for +common simulation parameters. + +# Arguments +- `analysis_type`: Ampacity formulation to use. Default: `MagnetoThermal()`. +- `domain_radius`: Domain radius for the simulation [m]. Default: `5.0`. +- `domain_radius_inf`: Domain radius for the infinite element transformation [m]. Default: `6.25`. +- `elements_per_length_conductor`: Elements per scale length for conductors. Default: `3`. +- `elements_per_length_insulator`: Elements per scale length for insulators. Default: `2`. +- `elements_per_length_semicon`: Elements per scale length for semiconductors. Default: `4`. +- `elements_per_length_interfaces`: Elements per scale length for interfaces. Default: `3`. +- `points_per_circumference`: Number of points to discretize circular boundaries. Default: `16`. +- `mesh_size_min`: Minimum mesh size [m]. Default: `1e-4`. +- `mesh_size_max`: Maximum mesh size [m]. Default: `1.0`. +- `mesh_size_default`: Default mesh size [m]. Default: `domain_radius / 10`. +- `mesh_transitions`: Vector of `MeshTransition` objects for mesh refinement. Default: `MeshTransition[]`. +- `mesh_algorithm`: Mesh algorithm to use. Default: `5`. +- `mesh_max_retries`: Maximum number of mesh generation retries. Default: `20`. +- `materials`: Materials database. Default: `MaterialsLibrary()`. +- `options`: `NamedTuple` of options to be converted to `FEMOptions`. Default: `(; )`. + +# Returns +- A `FEMFormulation` instance configured for Ampacity analysis. + +# Examples +```julia +# Create an Ampacity formulation with default parameters +formulation_amp = $(FUNCTIONNAME)(Val(:Ampacity)) + +# Create an Ampacity formulation with custom parameters +formulation_amp_custom = $(FUNCTIONNAME)( + Val(:Ampacity); + domain_radius=10.0, + elements_per_length_conductor=5, + analysis_type=MagnetoThermal() +) +``` +""" +function FormulationSet(::Val{:Ampacity}; + analysis_type::AmpacityFormulation = MagnetoThermal(), + domain_radius::Float64 = 5.0, + domain_radius_inf::Float64 = 6.25, + elements_per_length_conductor::Int = 3, + elements_per_length_insulator::Int = 2, + elements_per_length_semicon::Int = 4, + elements_per_length_interfaces::Int = 3, + points_per_circumference::Int = 16, + mesh_size_min::Float64 = 1e-4, + mesh_size_max::Float64 = 1.0, + mesh_size_default::Float64 = domain_radius / 10, + mesh_transitions::Vector{MeshTransition} = MeshTransition[], + mesh_algorithm::Int = 5, + mesh_max_retries::Int = 20, + materials::MaterialsLibrary = MaterialsLibrary(), + options = (;), +) + # Resolve solver path + validated_path = _resolve_getdp_path(options) + + # Create a new NamedTuple with the validated path overwriting any user value + final_opts = merge(options, (getdp_executable = validated_path,)) + fem_opts = build_options(FEMOptions, final_opts; strict = true) + + return FEMFormulation(analysis_type = analysis_type, + domain_radius = domain_radius, + domain_radius_inf = domain_radius_inf, + elements_per_length_conductor = elements_per_length_conductor, + elements_per_length_insulator = elements_per_length_insulator, + elements_per_length_semicon = elements_per_length_semicon, + elements_per_length_interfaces = elements_per_length_interfaces, + points_per_circumference = points_per_circumference, + mesh_size_min = mesh_size_min, + mesh_size_max = mesh_size_max, + mesh_size_default = mesh_size_default, + mesh_transitions = mesh_transitions, + mesh_algorithm = mesh_algorithm, + mesh_max_retries = mesh_max_retries, + materials = materials, + options = fem_opts, + ) +end + diff --git a/src/engine/fem/solver.jl b/src/engine/fem/solver.jl index f8de49d2..abcc4dba 100644 --- a/src/engine/fem/solver.jl +++ b/src/engine/fem/solver.jl @@ -1,32 +1,83 @@ -function make_fem_problem!( - fem_formulation::Union{AbstractImpedanceFormulation, AbstractAdmittanceFormulation}, - frequency::Float64, - workspace::FEMWorkspace, + +@inline function get_output_filename(::AbstractImpedanceFormulation, workspace::FEMWorkspace) + return workspace.core.paths[:impedance_file] +end + +@inline function get_output_filename(::AbstractAdmittanceFormulation, workspace::FEMWorkspace) + return workspace.core.paths[:admittance_file] +end + +@inline function get_output_filename(::AmpacityFormulation, workspace::FEMWorkspace) + return workspace.core.paths[:analysis_file] +end + +struct DefineJacobian end +struct DefineIntegration end +struct DefineMaterialProps end +struct DefineConstants end +struct DefineDomainGroups end +struct DefineConstraint end +struct DefineResolution end + +@inline function (f::Union{AbstractImpedanceFormulation, AbstractAdmittanceFormulation})( + frequency::Float64, + workspace::FEMWorkspace, + active_cond::Int, ) + # Create and build the problem + getdp_problem = GetDP.Problem() + + DefineJacobian()(getdp_problem, workspace) + DefineIntegration()(getdp_problem) + DefineMaterialProps()(getdp_problem, workspace) + DefineConstants()(getdp_problem, frequency) + DefineDomainGroups()(getdp_problem, f, workspace, active_cond) + DefineConstraint()(getdp_problem, f) + DefineResolution()(getdp_problem, f) + + make_problem!(getdp_problem) + + # Set the filename on the problem object based on formulation type + getdp_problem.filename = get_output_filename(f, workspace) + + write_file(getdp_problem) - fem_formulation.problem = GetDP.Problem() - define_jacobian!(fem_formulation.problem, workspace) - define_integration!(fem_formulation.problem) - define_material_props!(fem_formulation.problem, workspace) - define_constants!(fem_formulation.problem, fem_formulation, frequency) - define_domain_groups!(fem_formulation.problem, fem_formulation, workspace) - define_constraint!(fem_formulation.problem, fem_formulation, workspace) - define_resolution!(fem_formulation.problem, fem_formulation, workspace) - - make_problem!(fem_formulation.problem) - fem_formulation.problem.filename = - fem_formulation isa AbstractImpedanceFormulation ? - workspace.paths[:impedance_file] : workspace.paths[:admittance_file] - write_file(fem_formulation.problem) + return getdp_problem end -function define_jacobian!(problem::GetDP.Problem, workspace::FEMWorkspace) +@inline function (f::AmpacityFormulation)( + frequency::Float64, + workspace::FEMWorkspace, +) + # Create and build the problem + getdp_problem = GetDP.Problem() + + DefineJacobian()(getdp_problem, workspace) + DefineIntegration()(getdp_problem) + DefineMaterialProps()(getdp_problem, workspace) + DefineConstants()(getdp_problem, frequency, workspace) + DefineDomainGroups()(getdp_problem, f, workspace) + DefineConstraint()(getdp_problem, workspace) + DefineResolution()(getdp_problem, f) + + make_problem!(getdp_problem) + + # Set the filename on the problem object based on formulation type + getdp_problem.filename = get_output_filename(f, workspace) + + write_file(getdp_problem) + + return getdp_problem +end + + +@inline function (f::DefineJacobian)(problem::GetDP.Problem, workspace::FEMWorkspace) # Initialize Jacobian jac = Jacobian() - Rint = workspace.formulation.domain_radius - Rext = workspace.formulation.domain_radius_inf + Rint = workspace.core.formulation.domain_radius + Rext = workspace.core.formulation.domain_radius_inf # Add Vol Jacobian vol = add!(jac, "Vol") @@ -53,7 +104,7 @@ function define_jacobian!(problem::GetDP.Problem, workspace::FEMWorkspace) problem.jacobian = jac end -function define_integration!(problem::GetDP.Problem) +@inline function (f::DefineIntegration)(problem::GetDP.Problem) # Initialize Integration integ = Integration() i1 = add!(integ, "I1") @@ -67,11 +118,11 @@ function define_integration!(problem::GetDP.Problem) end -function define_material_props!(problem::GetDP.Problem, workspace::FEMWorkspace) +@inline function (f::DefineMaterialProps)(problem::GetDP.Problem, workspace::FEMWorkspace) # Create material properties function func = GetDP.Function() - for (tag, mat) in workspace.physical_groups + for (tag, mat) in workspace.core.physical_groups if tag > 10^8 # Add material properties for this region add_comment!( @@ -88,28 +139,46 @@ function define_material_props!(problem::GetDP.Problem, workspace::FEMWorkspace) region = [tag], ) add!(func, "epsilon", expression = mat.eps_r * ε₀, region = [tag]) + add!(func, "k", expression = mat.kappa, region = [tag]) end end push!(problem.function_obj, func) end -function define_constants!( + +@inline function (f::DefineConstants)( problem::GetDP.Problem, - fem_formulation::Union{AbstractImpedanceFormulation, AbstractAdmittanceFormulation}, frequency::Float64, ) func = GetDP.Function() add_constant!(func, "Freq", frequency) add_constant!(func, "UnitAmplitude", 1.0) + + push!(problem.function_obj, func) +end + +@inline function (f::DefineConstants)( + problem::GetDP.Problem, + frequency::Float64, + workspace::FEMWorkspace, +) + func = GetDP.Function() + + add_constant!(func, "Freq", frequency) + add_constant!(func, "Tambient[]", workspace.core.temp+273.15) # Kelvin + add_constant!(func, "V_wind", workspace.wind_velocity) # m/s + add_constant!(func, "h[]", "7.371 + 6.43*V_wind^0.75") # Convective coefficient [W/(m^2 K)] + push!(problem.function_obj, func) end -function define_domain_groups!( +@inline function (f::DefineDomainGroups)( problem::GetDP.Problem, fem_formulation::Union{AbstractImpedanceFormulation, AbstractAdmittanceFormulation}, workspace::FEMWorkspace, + active_cond::Int, ) material_reg = Dict{Symbol, Vector{Int}}( @@ -122,10 +191,9 @@ function define_domain_groups!( boundary_reg = Int[] add_raw_code!(problem, """ - DefineConstant[ - active_con = {1, Choices{1,9999}, Name "Input/Active conductor", Visible 1}]; + active_con = $active_cond; """) - for tag in keys(workspace.physical_groups) + for tag in keys(workspace.core.physical_groups) if tag > 10^8 # Decode tag information surface_type, entity_num, component_num, material_group, _ = @@ -204,6 +272,7 @@ function define_domain_groups!( if fem_formulation isa AbstractAdmittanceFormulation add!(group, "Domain_Ele", ["DomainCC", "DomainC"], "Region") add!(group, "Sur_Dirichlet_Ele", boundary_reg, "Region") + else # Add domain groups add!(group, "Domain_Mag", ["DomainCC", "DomainC"], "Region") @@ -213,14 +282,109 @@ function define_domain_groups!( problem.group = group end -function define_constraint!( +@inline function (f::DefineDomainGroups)( problem::GetDP.Problem, - fem_formulation::Union{AbstractImpedanceFormulation, AbstractAdmittanceFormulation}, + fem_formulation::MagnetoThermal, workspace::FEMWorkspace, ) + + material_reg = Dict{Symbol, Vector{Int}}( + :DomainC => Int[], + :DomainCC => Int[], + :DomainInf => Int[], + ) + inds_reg = Int[] + cables_reg = Dict{Int, Vector{Int}}() + boundary_reg = Int[] + is_magneto_thermal = fem_formulation isa MagnetoThermal + + for tag in keys(workspace.core.physical_groups) + if tag > 10^8 + # Decode tag information + surface_type, entity_num, component_num, material_group, _ = + decode_physical_group_tag(tag) + + # Categorize regions + if surface_type == 1 + push!(get!(cables_reg, entity_num, Int[]), tag) + if material_group == 1 && (!is_magneto_thermal || component_num == 1) + push!(inds_reg, tag) + end + end + if material_group == 1 + push!(material_reg[:DomainC], tag) + elseif material_group == 2 + push!(material_reg[:DomainCC], tag) + end + + surface_type == 3 && push!(material_reg[:DomainInf], tag) + + else + decode_boundary_tag(tag)[1] == 2 && push!(boundary_reg, tag) + end + end + inds_reg = sort(inds_reg) + material_reg[:DomainC] = sort(material_reg[:DomainC]) + material_reg[:DomainCC] = sort(material_reg[:DomainCC]) + + # Create and configure groups + group = GetDP.Group() + + # Add common domains + add!( + group, + "DomainInf", + material_reg[:DomainInf], + "Region", + comment = "Domain transformation to infinity", + ) + + for (key, tag) in enumerate(inds_reg) + add!(group, "Con_$key", [tag], "Region"; + comment = "$(create_physical_group_name(workspace, tag))") + end + + add!(group, "Conductors", inds_reg, "Region") + + # Add standard FEM domains + domain_configs = [ + ("DomainC", Int[], "All conductor materials"), + ("DomainCC", Int[], "All non-conductor materials"), + ] + + for (name, regions, comment) in domain_configs + add!(group, name, regions, "Region"; comment = comment) + end + + for tag in material_reg[:DomainC] + add!(group, "DomainC", [tag], "Region"; + operation = "+=", + comment = "$(create_physical_group_name(workspace, tag))") + end + + for tag in material_reg[:DomainCC] + add!(group, "DomainCC", [tag], "Region"; + operation = "+=", + comment = "$(create_physical_group_name(workspace, tag))") + end + + + add!(group, "Domain_Mag", ["DomainCC", "DomainC"], "Region") + add!(group, "Sur_Dirichlet_Mag", boundary_reg, "Region") + add!(group, "Sur_Dirichlet_The", boundary_reg, "Region") + add!(group, "Sur_Convection_Thermal", [], "Region") + + problem.group = group +end + + +@inline function (f::DefineConstraint)( + problem::GetDP.Problem, + fem_formulation::Union{AbstractImpedanceFormulation, AbstractAdmittanceFormulation}, +) constraint = GetDP.Constraint() - # num_cores = workspace.problem_def.system.num_cables + # num_cores = workspace.core.system.num_cables if fem_formulation isa AbstractAdmittanceFormulation # ScalarPotential_2D @@ -250,13 +414,38 @@ function define_constraint!( end -function define_resolution!( +@inline function (f::DefineConstraint)( problem::GetDP.Problem, - formulation::Electrodynamics, workspace::FEMWorkspace, ) - resolution_name = formulation.resolution_name - num_sources = workspace.problem_def.system.num_cables + constraint = GetDP.Constraint() + + # MagneticVectorPotential_2D + mvp = assign!(constraint, "MagneticVectorPotential_2D") + case!(mvp, "Sur_Dirichlet_Mag", value = "0.0") + + # Voltage_2D (placeholder) + voltage = assign!(constraint, "Voltage_2D") + case!(voltage, "") + + # Current_2D + current = assign!(constraint, "Current_2D") + for (idx, curr) in enumerate(workspace.energizations) + case!(current, "Con_$idx", value = "Complex[$(real(curr)), $(imag(curr))]") + end + + temp = assign!(constraint, "DirichletTemp") + case!(temp, "Sur_Dirichlet_The", value = "Tambient[]") # Ambient temperature + + problem.constraint = constraint + +end + + +@inline function (f::DefineResolution)( + problem::GetDP.Problem, + formulation_type::Electrodynamics, +) # FunctionSpace section functionspace = FunctionSpace() @@ -314,13 +503,15 @@ function define_resolution!( problem.formulation = formulation # Resolution section + resolution_name = get_resolution_name(formulation_type) output_dir = joinpath("results", lowercase(resolution_name)) output_dir = replace(output_dir, "\\" => "/") # for compatibility with Windows paths resolution = Resolution() - add!(resolution, resolution_name, "Sys_Ele", - NameOfFormulation = "Electrodynamics_v", - Type = "Complex", - Frequency = "Freq", + sys_ele = SystemItem("Sys_Ele", "Electrodynamics_v"; + Type="Complex", + Frequency="Freq" + ) + add!(resolution, resolution_name, [sys_ele], Operation = [ "CreateDir[\"$(output_dir)\"]", "Generate[Sys_Ele]", @@ -345,12 +536,12 @@ function define_resolution!( ("j", "-sigma[] * {d v}", Dict()), ("jm", "Norm[-sigma[] * {d v}]", Dict()), ] - q = add!(pp, name) + q = add_post_quantity_term!(pp, name) add!(q, "Term", expr; In = "Domain_Ele", Jacobian = "Vol", options...) end # Add jtot (combination of j and d) - q = add!(pp, "jtot") + q = add_post_quantity_term!(pp, "jtot") add!( q, "Term", @@ -368,13 +559,13 @@ function define_resolution!( Jacobian = "Vol", ) - q = add!(pp, "U") + q = add_post_quantity_term!(pp, "U") add!(q, "Term", "{U}"; In = "Domain_Ele") - q = add!(pp, "Q") + q = add_post_quantity_term!(pp, "Q") add!(q, "Term", "{Q}"; In = "Domain_Ele") - q = add!(pp, "Y") + q = add_post_quantity_term!(pp, "Y") add!(q, "Term", "-{Q}"; In = "Domain_Ele") problem.postprocessing = postprocessing @@ -414,14 +605,11 @@ function define_resolution!( end -function define_resolution!( +@inline function (f::DefineResolution)( problem::GetDP.Problem, - formulation::Darwin, - workspace::FEMWorkspace, + formulation_type::Darwin, ) - resolution_name = formulation.resolution_name - # Create a new Problem instance functionspace = FunctionSpace() @@ -545,13 +733,15 @@ function define_resolution!( # Define Resolution resolution = Resolution() - + sys_mag = SystemItem("Sys_Mag", "Darwin_a_2D"; + Type="Complex", + Frequency="Freq" + ) # Add a resolution + resolution_name = get_resolution_name(formulation_type) output_dir = joinpath("results", lowercase(resolution_name)) output_dir = replace(output_dir, "\\" => "/") # for compatibility with Windows paths - add!(resolution, resolution_name, "Sys_Mag", - NameOfFormulation = "Darwin_a_2D", - Type = "Complex", Frequency = "Freq", + add!(resolution, resolution_name, [sys_mag], Operation = [ "CreateDir[\"$(output_dir)\"]", "InitSolution[Sys_Mag]", @@ -568,34 +758,34 @@ function define_resolution!( postprocessing = PostProcessing() pp = add!(postprocessing, "Darwin_a_2D", "Darwin_a_2D") - q = add!(pp, "a") + q = add_post_quantity_term!(pp, "a") add!(q, "Term", "{a}"; In = "Domain_Mag", Jacobian = "Vol") - q = add!(pp, "az") + q = add_post_quantity_term!(pp, "az") add!(q, "Term", "CompZ[{a}]"; In = "Domain_Mag", Jacobian = "Vol") - q = add!(pp, "b") + q = add_post_quantity_term!(pp, "b") add!(q, "Term", "{d a}"; In = "Domain_Mag", Jacobian = "Vol") - q = add!(pp, "bm") + q = add_post_quantity_term!(pp, "bm") add!(q, "Term", "Norm[{d a}]"; In = "Domain_Mag", Jacobian = "Vol") - q = add!(pp, "j") + q = add_post_quantity_term!(pp, "j") add!(q, "Term", "-sigma[]*(Dt[{a}]+{ur})"; In = "DomainC", Jacobian = "Vol") - q = add!(pp, "jz") + q = add_post_quantity_term!(pp, "jz") add!(q, "Term", "CompZ[-sigma[]*(Dt[{a}]+{ur})]"; In = "DomainC", Jacobian = "Vol") - q = add!(pp, "jm") + q = add_post_quantity_term!(pp, "jm") add!(q, "Term", "Norm[-sigma[]*(Dt[{a}]+{ur})]"; In = "DomainC", Jacobian = "Vol") - q = add!(pp, "d") + q = add_post_quantity_term!(pp, "d") add!(q, "Term", "epsilon[] * Dt[Dt[{a}]+{ur}]"; In = "DomainC", Jacobian = "Vol") - q = add!(pp, "dz") + q = add_post_quantity_term!(pp, "dz") add!(q, "Term", "CompZ[epsilon[] * Dt[Dt[{a}]+{ur}]]"; In = "DomainC", Jacobian = "Vol") - q = add!(pp, "dm") + q = add_post_quantity_term!(pp, "dm") add!(q, "Term", "Norm[epsilon[] * Dt[Dt[{a}]+{ur}]]"; In = "DomainC", Jacobian = "Vol") - q = add!(pp, "rhoj2") + q = add_post_quantity_term!(pp, "rhoj2") add!(q, "Term", "0.5*sigma[]*SquNorm[Dt[{a}]+{ur}]"; In = "DomainC", Jacobian = "Vol") - q = add!(pp, "U") + q = add_post_quantity_term!(pp, "U") add!(q, "Term", "{U}"; In = "DomainC") - q = add!(pp, "I") + q = add_post_quantity_term!(pp, "I") add!(q, "Term", "{I}"; In = "DomainC") - q = add!(pp, "Z") + q = add_post_quantity_term!(pp, "Z") add!(q, "Term", "-{U}"; In = "DomainC") problem.postprocessing = postprocessing @@ -647,181 +837,374 @@ function define_resolution!( problem.postoperation = postoperation end +@inline function (f::DefineResolution)( + problem::GetDP.Problem, + formulation_type::MagnetoThermal, +) + # Create a new Problem instance + functionspace = FunctionSpace() + + # FunctionSpace section + fs1 = add!(functionspace, "Hcurl_a_Mag_2D", nothing, nothing, Type = "Form1P") + add_basis_function!( + functionspace, + "se", + "ae", + "BF_PerpendicularEdge"; + Support = "Domain_Mag", + Entity = "NodesOf[ All ]", + ) + add_constraint!(functionspace, "ae", "NodesOf", "MagneticVectorPotential_2D") -function run_getdp(workspace::FEMWorkspace, fem_formulation::AbstractFormulationSet) - # Initialize Gmsh if not already initialized - if gmsh.is_initialized() == 0 - gmsh.initialize() - end + fs3 = add!(functionspace, "Hregion_u_Mag_2D", nothing, nothing, Type = "Form1P") + add_basis_function!( + functionspace, + "sr", + "ur", + "BF_RegionZ"; + Support = "DomainC", + Entity = "DomainC", + ) + add_global_quantity!(functionspace, "U", "AliasOf"; NameOfCoef = "ur") + add_global_quantity!(functionspace, "I", "AssociatedWith"; NameOfCoef = "ur") + add_constraint!(functionspace, "U", "Region", "Voltage_2D") + add_constraint!(functionspace, "I", "Region", "Current_2D") - # Number of iterations (from the original function) - n_phases = - sum([length(c.design_data.components) for c in workspace.problem_def.system.cables]) + fs1 = add!(functionspace, "Hgrad_Thermal", nothing, nothing, Type = "Form0") + add_basis_function!( + functionspace, + "sn", + "t", + "BF_Node"; + Support = "Domain_Mag", + Entity = "NodesOf[ All ]", + ) - # Flag to track if all solves are successful - all_success = true + add_constraint!(functionspace, "t", "NodesOf", "DirichletTemp") - # Map verbosity to Gmsh/GetDP level - gmsh_verbosity = map_verbosity_to_gmsh(workspace.opts.verbosity) - gmsh.option.set_number("General.Verbosity", gmsh_verbosity) + problem.functionspace = functionspace + + # Define Formulation + formulation = GetDP.Formulation() + + form = add!(formulation, "Darwin_a_2D", "FemEquation") + add_quantity!(form, "a", Type = "Local", NameOfSpace = "Hcurl_a_Mag_2D") + add_quantity!(form, "ur", Type = "Local", NameOfSpace = "Hregion_u_Mag_2D") + add_quantity!(form, "T", Type = "Local", NameOfSpace = "Hgrad_Thermal") + add_quantity!(form, "I", Type = "Global", NameOfSpace = "Hregion_u_Mag_2D [I]") + add_quantity!(form, "U", Type = "Global", NameOfSpace = "Hregion_u_Mag_2D [U]") + + eq = add_equation!(form) + + add!( + eq, + "Galerkin", + "[ nu[] * Dof{d a} , {d a} ]", + In = "Domain_Mag", + Jacobian = "Vol", + Integration = "I1", + ) + add!( + eq, + "Galerkin", + "DtDof [ sigma[{T}] * Dof{a} , {a} ]", + In = "DomainC", + Jacobian = "Vol", + Integration = "I1", + ) + add!( + eq, + "Galerkin", + "[ sigma[{T}] * Dof{ur}, {a} ]", + In = "DomainC", + Jacobian = "Vol", + Integration = "I1", + ) + add!( + eq, + "Galerkin", + "DtDof [ sigma[{T}] * Dof{a} , {ur} ]", + In = "DomainC", + Jacobian = "Vol", + Integration = "I1", + ) + add!( + eq, + "Galerkin", + "[ sigma[{T}] * Dof{ur}, {ur}]", + In = "DomainC", + Jacobian = "Vol", + Integration = "I1", + ) + add!( + eq, + "Galerkin", + "DtDtDof [ epsilon[] * Dof{a} , {a}]", + In = "DomainC", + Jacobian = "Vol", + Integration = "I1", + comment = " Darwin approximation term", + ) + add!( + eq, + "Galerkin", + "DtDof[ epsilon[] * Dof{ur}, {a} ]", + In = "DomainC", + Jacobian = "Vol", + Integration = "I1", + ) + add!( + eq, + "Galerkin", + "DtDtDof [ epsilon[] * Dof{a} , {ur}]", + In = "DomainC", + Jacobian = "Vol", + Integration = "I1", + ) + add!( + eq, + "Galerkin", + "DtDof[ epsilon[] * Dof{ur}, {ur} ]", + In = "DomainC", + Jacobian = "Vol", + Integration = "I1", + ) + add!(eq, "GlobalTerm", "[ Dof{I} , {U} ]", In = "Conductors") #DomainActive + form = add!(formulation, "ThermalSta", "FemEquation") + add_quantity!(form, "T", Type = "Local", NameOfSpace = "Hgrad_Thermal") + add_quantity!(form, "a", Type = "Local", NameOfSpace = "Hcurl_a_Mag_2D") + add_quantity!(form, "ur", Type = "Local", NameOfSpace = "Hregion_u_Mag_2D") - getdp_verbosity = map_verbosity_to_getdp(workspace.opts.verbosity) + eq = add_equation!(form) - # Loop over each active_ind from 1 to n_phases - for i in 1:n_phases - # Construct solver command with -setnumber active_ind i - solve_cmd = "$(workspace.opts.getdp_executable) $(fem_formulation.problem.filename) -msh $(workspace.paths[:mesh_file]) -solve $(fem_formulation.resolution_name) -setnumber active_con $i -v2 -verbose $(getdp_verbosity)" + add!( + eq, + "Galerkin", + "[ k[] * Dof{d T} , {d T} ]", + In = "Domain_Mag", + Jacobian = "Vol", + Integration = "I1", + ) - # Log the current solve attempt - @info "Solving for source conductor $i... (Resolution = $(fem_formulation.resolution_name))" + add!( + eq, + "Galerkin", + "[ -0.5*sigma[{T}] * [ SquNorm[Dt[{a}]+{ur}] ], {T} ]", + In = "DomainC", + Jacobian = "Vol", + Integration = "I1", + ) - # Attempt to run the solver - try - gmsh.onelab.run("GetDP", solve_cmd) + add!( + eq, + "Galerkin", + "[ h[] * Dof{T} , {T} ]", + In = "Sur_Convection_Thermal", + Jacobian = "Sur", + Integration = "I1", + comment = " Convection boundary condition", + ) - if workspace.opts.plot_field_maps - @info "Building field maps for source conductor $i... (Resolution = $(fem_formulation.resolution_name))" + add!( + eq, + "Galerkin", + "[-h[] * Tambient[] , {T} ]", + In = "Sur_Convection_Thermal", + Jacobian = "Sur", + Integration = "I1", + ) + # Add the formulation to the problem + problem.formulation = formulation - post_cmd = "$(workspace.opts.getdp_executable) $(fem_formulation.problem.filename) -msh $(workspace.paths[:mesh_file]) -pos Field_Maps -setnumber active_con $i -v2 -verbose $(getdp_verbosity)" + # Define Resolution + resolution = Resolution() - gmsh.onelab.run("GetDP", post_cmd) - end + sys_mag = SystemItem("Sys_Mag", "Darwin_a_2D"; Type="Complex", Frequency="Freq") + sys_the = SystemItem("Sys_The", "ThermalSta") - @info "Solve successful for source conductor $(i)!" - catch e - # Log the error and update the success flag - @error "Solver failed for source conductor $i: $e" - all_success = false - # Continue to the next iteration even if this one fails - end - end + # Add a resolution + resolution_name = get_resolution_name(formulation_type) + output_dir = joinpath("results", lowercase(resolution_name)) + output_dir = replace(output_dir, "\\" => "/") # for compatibility with Windows paths - # Return true only if all solves were successful - return all_success -end + # Construct the final Operation vector + add!(resolution, resolution_name, [sys_mag, sys_the], + Operation = [ + "CreateDir[\"$(output_dir)\"]", + "InitSolution[Sys_Mag]", + "InitSolution[Sys_The]", + "Generate[Sys_Mag]", + "Solve[Sys_Mag]", + "Generate[Sys_The]", + "Solve[Sys_The]", + "SaveSolution[Sys_Mag]", + "SaveSolution[Sys_The]", + "PostOperation[LineParams]", + ] + ) + # Add the resolution to the problem + problem.resolution = resolution -using LinearAlgebra: BLAS, BlasFloat + # PostProcessing section + postprocessing = PostProcessing() + + pp = add!(postprocessing, "Darwin_a_2D", "Darwin_a_2D") + + q = add_post_quantity_term!(pp, "a") + add!(q, "Term", "{a}"; In = "Domain_Mag", Jacobian = "Vol") + q = add_post_quantity_term!(pp, "az") + add!(q, "Term", "CompZ[{a}]"; In = "Domain_Mag", Jacobian = "Vol") + q = add_post_quantity_term!(pp, "b") + add!(q, "Term", "{d a}"; In = "Domain_Mag", Jacobian = "Vol") + q = add_post_quantity_term!(pp, "bm") + add!(q, "Term", "Norm[{d a}]"; In = "Domain_Mag", Jacobian = "Vol") + q = add_post_quantity_term!(pp, "j") + add!(q, "Term", "-sigma[]*(Dt[{a}]+{ur})"; In = "DomainC", Jacobian = "Vol") + q = add_post_quantity_term!(pp, "jz") + add!(q, "Term", "CompZ[-sigma[{T}]*(Dt[{a}]+{ur})]"; In = "DomainC", Jacobian = "Vol") + q = add_post_quantity_term!(pp, "jm") + add!(q, "Term", "Norm[-sigma[{T}]*(Dt[{a}]+{ur})]"; In = "DomainC", Jacobian = "Vol") + q = add_post_quantity_term!(pp, "d") + add!(q, "Term", "epsilon[] * Dt[Dt[{a}]+{ur}]"; In = "DomainC", Jacobian = "Vol") + q = add_post_quantity_term!(pp, "dz") + add!(q, "Term", "CompZ[epsilon[] * Dt[Dt[{a}]+{ur}]]"; In = "DomainC", Jacobian = "Vol") + q = add_post_quantity_term!(pp, "dm") + add!(q, "Term", "Norm[epsilon[] * Dt[Dt[{a}]+{ur}]]"; In = "DomainC", Jacobian = "Vol") + q = add_post_quantity_term!(pp, "rhoj2") + add!(q, "Term", "0.5*sigma[{T}]*SquNorm[Dt[{a}]+{ur}]"; In = "DomainC", Jacobian = "Vol") -function run_solver!(workspace::FEMWorkspace) - problem = workspace.problem_def - formulation = workspace.formulation + q = add_post_quantity_term!(pp, "U") + add!(q, "Term", "{U}"; In = "DomainC") + q = add_post_quantity_term!(pp, "I") + add!(q, "Term", "{I}"; In = "DomainC") + q = add_post_quantity_term!(pp, "Z") + add!(q, "Term", "-{U}"; In = "DomainC") - n_phases = workspace.n_phases - n_frequencies = workspace.n_frequencies - phase_map = workspace.phase_map - # --- index plan (once) --- - perm = reorder_indices(phase_map) # encounter-ordered: first of each phase, then tails, then zeros - map_r = phase_map[perm] # reordered map (constant across k) + pp_the = add!(postprocessing, "ThermalSta", "ThermalSta") + q = add_quantity_term!(pp_the, "T") + add!(q, "Local", "{T}"; In = "Domain_Mag", Jacobian = "Vol") - # --- outputs: size decided by kron_map (here: map_r after merge_bundles! zeros tails) + q = add_quantity_term!(pp_the, "TinC") + add!(q, "Local", "{T}-273.15"; In = "Domain_Mag", Jacobian = "Vol") - # Probe the keep-size once using a scratch (no heavy cost). - _probe = Matrix{ComplexF64}(I, n_phases, n_phases) - _, reduced_map = merge_bundles!(copy(_probe), map_r) - n_keep = count(!=(0), reduced_map) + q = add_quantity_term!(pp_the, "q") + add!(q, "Local", "-k[]*{d T}"; In = "Domain_Mag", Jacobian = "Vol") - Zr = zeros(ComplexF64, n_keep, n_keep, n_frequencies) - Yr = zeros(ComplexF64, n_keep, n_keep, n_frequencies) + problem.postprocessing = postprocessing - # --- scratch buffers (reused every k) --- - Zbuf = Matrix{ComplexF64}(undef, n_phases, n_phases) # reordered + merged target - Ybuf = Matrix{ComplexF64}(undef, n_phases, n_phases) - Pf = Matrix{ComplexF64}(undef, n_phases, n_phases) # potentials (for Y path) + # PostOperation section + postoperation = PostOperation() - # tiny gather helper: reorder src[:,:,k] into dest without temp allocs - @inline function _reorder_into!(dest::StridedMatrix{ComplexF64}, - src::Array{ComplexF64, 3}, - perm::Vector{Int}, k::Int) - n = length(perm) - @inbounds for j in 1:n, i in 1:n - dest[i, j] = src[perm[i], perm[j], k] - end - return dest - end + # Add post-operation items + po1 = add!(postoperation, "Field_Maps", "ThermalSta") + op1 = add_operation!(po1) + add_operation!(op1, "Print[ TinC, OnElementsOf Domain_Mag, Name \"T [°C] around cable\", File StrCat[ \"$(joinpath(output_dir,"TinC"))\", \".pos\" ] ];") + add_operation!(op1, "Print[ q , OnElementsOf Domain_Mag, Name \"heat flux [W/m²] around cable\", File StrCat[ \"$(joinpath(output_dir,"q"))\", \".pos\" ] ];") - # --- big loop --- - for (k, frequency) in enumerate(workspace.freq) - @info "Solving frequency $k/$n_frequencies: $frequency Hz" + po2 = add!(postoperation, "LineParams", "ThermalSta") + op2 = add_operation!(po2) + add_operation!(op2, "Print[ TinC, OnElementsOf Domain_Mag, Name \"T [°C] around cable\", File StrCat[ \"$(joinpath(output_dir,"TinC"))\", \".pos\" ] ];") + add_operation!(op2, "Print[ q , OnElementsOf Domain_Mag, Name \"heat flux [W/m²] around cable\", File StrCat[ \"$(joinpath(output_dir,"q"))\", \".pos\" ] ];") + # add_operation!(op2, "Print[ Z, OnRegion Conductors, Format Table, File \"$(joinpath(output_dir,"Z.dat"))\", AppendToExistingFile (active_con > 1 ? 1 : 0) ];") - # Fill Z,Y (original ordering) for this slice - _do_run_solver!(k, workspace) - # REORDER → Z - _reorder_into!(Zbuf, workspace.Z, perm, k) - # symtrans!(Zbuf) + # Add the post-operation to the problem + problem.postoperation = postoperation - # MERGE bundles (in-place on Zbuf) and get reduced map (tails → 0) - Zm, reduced_map = merge_bundles!(Zbuf, map_r) +end - # KRON on Z - Zred = kronify(Zm, reduced_map) - symtrans!(Zred) - formulation.options.ideal_transposition || line_transpose!(Zred) - @inbounds Zr[:, :, k] .= Zred +function run_getdp(workspace::FEMWorkspace, frequency::Float64, + fem_formulation::Union{AbstractImpedanceFormulation, AbstractAdmittanceFormulation}, active_con::Int) - # Y path goes via potentials: Pf = inv(Y/(jω)) - w = 2π * frequency - # REORDER → Y - _reorder_into!(Ybuf, workspace.Y, perm, k) - # symtrans!(Ybuf) + resolution_name = get_resolution_name(fem_formulation) + @debug "Processing $(resolution_name) formulation" + getdp_problem = fem_formulation(frequency, workspace, active_con) + getdp_verbosity = map_verbosity_to_getdp(workspace.core.opts.verbosity) + solve_cmd = "$(workspace.core.opts.getdp_executable) $(getdp_problem.filename) -msh $(workspace.core.paths[:mesh_file]) -solve $(resolution_name) -v2 -verbose $(getdp_verbosity)" - # Pf = inv(Ybuf / (jω)) without extra temps - @inbounds @views begin - Pf .= Ybuf - Pf ./= (1im*w) - end - Pf .= inv(Pf) + @info "Solving for source conductor $active_con... (Resolution = $(resolution_name))" - # MERGE bundles for Pf (same reduced_map semantics) - Pfm, reduced_map = merge_bundles!(Pf, map_r) + # Attempt to run the solver + success = true + try + gmsh.onelab.run("GetDP", solve_cmd) - # KRON on Pf, then invert back to Y - Pr = kronify(Pfm, reduced_map) - Yrk = (1im*w) * inv(Pr) - symtrans!(Yrk) - formulation.options.ideal_transposition || line_transpose!(Yrk) - @inbounds Yr[:, :, k] .= Yrk + if workspace.core.opts.plot_field_maps + @info "Building field maps for source conductor $active_con... (Resolution = $(resolution_name))" - # Archive if requested - if workspace.opts.keep_run_files - archive_frequency_results(workspace, frequency) - end - end + post_cmd = "$(workspace.core.opts.getdp_executable) $(getdp_problem.filename) -msh $(workspace.core.paths[:mesh_file]) -pos Field_Maps -v2 -verbose $(getdp_verbosity)" + + gmsh.onelab.run("GetDP", post_cmd) + end + + @info "Solve successful for source conductor $(active_con)!" + catch e + # Log the error and update the success flag + @error "Solver failed for source conductor $active_con: $e" + success = false + end + + return success - return LineParameters(Zr, Yr, workspace.freq) end +function run_getdp(workspace::FEMWorkspace, problem::GetDP.Problem, fem_formulation::AmpacityFormulation) + # Initialize Gmsh if not already initialized + if gmsh.is_initialized() == 0 + gmsh.initialize() + end + # Number of iterations (from the original function) + n_phases = + sum([length(c.design_data.components) for c in workspace.core.system.cables]) -function _do_run_solver!(freq_idx::Int, - workspace::FEMWorkspace) # Z::Array{ComplexF64, 3}, Y::Array{ComplexF64, 3}) + # Flag to track if all solves are successful + all_success = true - # Get formulation from workspace - formulation = workspace.formulation - # Z, Y = workspace.Z, workspace.Y - frequency = workspace.freq[freq_idx] + # Map verbosity to Gmsh/GetDP level + gmsh_verbosity = map_verbosity_to_gmsh(workspace.core.opts.verbosity) + gmsh.option.set_number("General.Verbosity", gmsh_verbosity) - # Build and solve both formulations - for fem_formulation in formulation.analysis_type - @debug "Processing $(fem_formulation.resolution_name) formulation" - make_fem_problem!(fem_formulation, frequency, workspace) + getdp_verbosity = map_verbosity_to_getdp(workspace.core.opts.verbosity) + resolution_name = get_resolution_name(fem_formulation) - if !run_getdp(workspace, fem_formulation) - Base.error("$(fem_formulation.resolution_name) solver failed") + # Construct solver command with -setnumber active_ind i + solve_cmd = "$(workspace.core.opts.getdp_executable) $(problem.filename) -msh $(workspace.core.paths[:mesh_file]) -solve $(resolution_name) -v2 -verbose $(getdp_verbosity)" + + # Log the current solve attempt + @info "Solving multiphysics... (Resolution = $(resolution_name))" + + # Attempt to run the solver + try + gmsh.onelab.run("GetDP", solve_cmd) + + if workspace.core.opts.plot_field_maps + @info "Building field maps multiphysics... (Resolution = $(resolution_name))" + + post_cmd = "$(workspace.core.opts.getdp_executable) $(problem.filename) -msh $(workspace.core.paths[:mesh_file]) -pos Field_Maps -v2 -verbose $(getdp_verbosity)" + + gmsh.onelab.run("GetDP", post_cmd) end + + @info "Solve successful!" + catch e + # Log the error and update the success flag + @error "Solver failed: $e" + all_success = false + # Continue to the next iteration even if this one fails end - # Extract results into preallocated arrays - workspace.Z[:, :, freq_idx] = - read_results_file(formulation.analysis_type[1], workspace) - workspace.Y[:, :, freq_idx] = - read_results_file(formulation.analysis_type[2], workspace) + # Return true only if all solves were successful + return all_success end + +using LinearAlgebra: BLAS, BlasFloat + """ $(TYPEDSIGNATURES) @@ -845,12 +1228,166 @@ Main function to run the FEM simulation workflow for a cable system. workspace = $(FUNCTIONNAME)(cable_system, formulation, solver) ``` """ + function compute!(problem::LineParametersProblem, + formulation::FEMFormulation, + workspace::Union{FEMWorkspace, Nothing} = nothing) + + opts = formulation.options + + # Initialize workspace + workspace = init_workspace(problem, formulation, workspace) + + # Meshing phase: make_mesh! decides if it needs to run. + # It returns true if the process should stop (e.g., mesh_only=true). + mesh_only_flag = make_mesh!(workspace) + + # Only proceed with solver if not mesh_only + if !mesh_only_flag + @info "Starting FEM solver" + + # Extract necessary variables from workspace + n_phases = workspace.core.n_phases + n_frequencies = workspace.core.n_frequencies + phase_map = workspace.core.phase_map + + # --- Index plan (once) --- + perm = reorder_indices(phase_map) # encounter-ordered: first of each phase, then tails, then zeros + map_r = phase_map[perm] # reordered map (constant across k) + + # --- Outputs: size decided by kron_map (here: map_r after merge_bundles! zeros tails) --- + # Probe the keep-size once using a scratch (no heavy cost) + _probe = Matrix{ComplexF64}(I, n_phases, n_phases) + _, reduced_map = merge_bundles!(copy(_probe), map_r) + n_keep = count(!=(0), reduced_map) + + Zr = zeros(ComplexF64, n_keep, n_keep, n_frequencies) + Yr = zeros(ComplexF64, n_keep, n_keep, n_frequencies) + + # --- Scratch buffers (reused every k) --- + Zbuf = Matrix{ComplexF64}(undef, n_phases, n_phases) # reordered + merged target + Ybuf = Matrix{ComplexF64}(undef, n_phases, n_phases) + Pf = Matrix{ComplexF64}(undef, n_phases, n_phases) # potentials (for Y path) + + # Tiny gather helper: reorder src[:,:,k] into dest without temp allocs + @inline function _reorder_into!(dest::StridedMatrix{ComplexF64}, + src::Array{ComplexF64, 3}, + perm::Vector{Int}, k::Int) + n = length(perm) + @inbounds for j in 1:n, i in 1:n + dest[i, j] = src[perm[i], perm[j], k] + end + dest + end + + # --- Big loop over frequencies --- + for (k, frequency) in enumerate(workspace.core.freq) + @info "Solving frequency $k/$n_frequencies: $frequency Hz" + + # Build and solve both formulations + for fem_formulation_item in formulation.analysis_type + + # Initialize Gmsh if not already initialized + if gmsh.is_initialized() == 0 + gmsh.initialize() + end + + # Number of iterations + n_phases_inner = sum([length(c.design_data.components) for c in workspace.core.system.cables]) + + # Flag to track if all solves are successful + all_success = true + + # Map verbosity to Gmsh/GetDP level + gmsh_verbosity = map_verbosity_to_gmsh(workspace.core.opts.verbosity) + gmsh.option.set_number("General.Verbosity", gmsh_verbosity) + + # Loop over each active_ind from 1 to n_phases + for i in 1:n_phases_inner + # Construct solver command with -setnumber active_ind i + all_success = run_getdp(workspace, frequency, fem_formulation_item, i) + end + + # Check if solve was successful + if !all_success + Base.error("$(get_resolution_name(fem_formulation_item)) solver failed") + end + end + + # Extract results into preallocated arrays + workspace.Z[:, :, k] = read_results_file(formulation.analysis_type[1], workspace) + workspace.Y[:, :, k] = read_results_file(formulation.analysis_type[2], workspace) + + # REORDER → Z + _reorder_into!(Zbuf, workspace.Z, perm, k) + + # MERGE bundles (in-place on Zbuf) and get reduced map (tails → 0) + Zm, reduced_map = merge_bundles!(Zbuf, map_r) + + # KRON on Z + Zred = kronify(Zm, reduced_map) + symtrans!(Zred) + formulation.options.ideal_transposition || line_transpose!(Zred) + @inbounds Zr[:, :, k] .= Zred + + # Y path goes via potentials: Pf = inv(Y/(jω)) + w = 2π * frequency + # REORDER → Y + _reorder_into!(Ybuf, workspace.Y, perm, k) + + # Pf = inv(Ybuf / (jω)) without extra temps + @inbounds @views begin + Pf .= Ybuf + Pf ./= (1im * w) + end + Pf .= inv(Pf) + + # MERGE bundles for Pf (same reduced_map semantics) + Pfm, reduced_map = merge_bundles!(Pf, map_r) + + # KRON on Pf, then invert back to Y + Pr = kronify(Pfm, reduced_map) + Yrk = (1im * w) * inv(Pr) + symtrans!(Yrk) + formulation.options.ideal_transposition || line_transpose!(Yrk) + @inbounds Yr[:, :, k] .= Yrk + + # Archive if requested + if workspace.core.opts.keep_run_files + archive_frequency_results(workspace, frequency) + end + end + + ZY = LineParameters(Zr, Yr, workspace.core.freq) + @info "FEM computation completed successfully" + end + + return workspace, ZY +end +""" +$(TYPEDSIGNATURES) + +Main function to run the FEM simulation workflow for a cable system. + +# Arguments +- `problem`: Ampacity problem definition. +- `formulation`: Formulation parameters. +- `workspace`: (Optional) Pre-initialized [`FEMWorkspace`](@ref) instance. + +# Returns +- A [`FEMWorkspace`](@ref) instance with the simulation results. + +# Examples +```julia +# Run a FEM simulation +workspace = $(FUNCTIONNAME)(problem, formulation) +``` +""" +function compute!(problem::AmpacityProblem, formulation::FEMFormulation, workspace::Union{FEMWorkspace, Nothing} = nothing) opts = formulation.options - # Initialize workspace workspace = init_workspace(problem, formulation, workspace) @@ -860,11 +1397,25 @@ function compute!(problem::LineParametersProblem, return workspace, nothing end + fem_formulation = workspace.core.formulation.analysis_type + # Solving phase - always runs unless mesh_only @info "Starting FEM solver" - ZY = run_solver!(workspace) + for freq in workspace.core.freq + @debug "Processing $(workspace.core.formulation.resolution_name) formulation" + + getdp_problem = fem_formulation(freq, workspace) + + if !run_getdp(workspace, getdp_problem, fem_formulation) + Base.error("$(fem_formulation.resolution_name) solver failed") + end + # Archive if requested + if workspace.core.opts.keep_run_files + archive_frequency_results(workspace, freq) + end + end @info "FEM computation completed successfully" - return workspace, ZY + return workspace end diff --git a/src/engine/fem/space.jl b/src/engine/fem/space.jl index 51b771ea..e43845ff 100644 --- a/src/engine/fem/space.jl +++ b/src/engine/fem/space.jl @@ -1,367 +1,639 @@ -""" -Domain creation functions for the FEMTools.jl module. -These functions handle the creation of domain boundaries and earth interfaces. -""" - -""" -$(TYPEDSIGNATURES) - -Create the domain boundaries (inner solid disk and outer annular region) for the simulation. - -# Arguments - -- `workspace`: The [`FEMWorkspace`](@ref) containing the model parameters. - -# Returns - -- Nothing. Updates the boundaries vector in the workspace. - -# Examples - -```julia -$(FUNCTIONNAME)(workspace) -``` -""" -function make_space_geometry(workspace::FEMWorkspace) - @info "Creating domain boundaries..." - - # Extract parameters - formulation = workspace.formulation - domain_radius = formulation.domain_radius - domain_radius_inf = formulation.domain_radius_inf # External radius for boundary transform - mesh_size_default = formulation.mesh_size_default - mesh_size_domain = formulation.mesh_size_max - mesh_size_inf = 1.25 * formulation.mesh_size_max - - # Center coordinates - x_center = 0.0 - y_center = 0.0 - - # Create inner domain disk - num_points_circumference = formulation.points_per_circumference - @debug "Creating inner domain disk with radius $(domain_radius) m" - _, _, air_region_marker, domain_boundary_markers = draw_disk( - x_center, - y_center, - domain_radius, - mesh_size_domain, - num_points_circumference, - ) - - # Create outer domain annular region - @debug "Creating outer domain annular region with radius $(domain_radius_inf) m" - _, _, air_infshell_marker, domain_infty_markers = draw_annular( - x_center, - y_center, - domain_radius, - domain_radius_inf, - mesh_size_inf, - num_points_circumference, - ) - - # Get earth model from workspace - earth_props = workspace.problem_def.earth_props - air_layer_idx = 1 # air layer is 1 by default - num_earth_layers = length(earth_props.layers) # Number of earth layers - earth_layer_idx = num_earth_layers - - # Air layer (Layer 1) - air_material = get_earth_model_material(workspace, air_layer_idx) - air_material_id = get_or_register_material_id(workspace, air_material) - air_material_group = get_material_group(earth_props, air_layer_idx) # Will return 2 (insulator) - - # Physical domain air tag - air_region_tag = encode_physical_group_tag( - 2, # Surface type 2 = physical domain - air_layer_idx, # Layer 1 = air - 0, # Component 0 (not a cable component) - air_material_group, # Material group 2 (insulator) - air_material_id, # Material ID - ) - air_region_name = create_physical_group_name(workspace, air_region_tag) - - # Infinite shell air tag - air_infshell_tag = encode_physical_group_tag( - 3, # Surface type 3 = infinite shell - air_layer_idx, # Layer 1 = air - 0, # Component 0 (not a cable component) - air_material_group, # Material group 2 (insulator) - air_material_id, # Material ID - ) - air_infshell_name = create_physical_group_name(workspace, air_infshell_tag) - - - # Earth layer (Layer 2+) - earth_material = get_earth_model_material(workspace, earth_layer_idx) - earth_material_id = get_or_register_material_id(workspace, earth_material) - earth_material_group = get_material_group(earth_props, earth_layer_idx) # Will return 1 (conductor) - - # Physical domain earth tag - earth_region_tag = encode_physical_group_tag( - 2, # Surface type 2 = physical domain - earth_layer_idx, # Layer 2 = first earth layer - 0, # Component 0 (not a cable component) - earth_material_group, # Material group 1 (conductor) - earth_material_id, # Material ID - ) - earth_region_name = create_physical_group_name(workspace, earth_region_tag) - - # Infinite shell earth tag - earth_infshell_tag = encode_physical_group_tag( - 3, # Surface type 3 = infinite shell - earth_layer_idx, # Layer 2 = first earth layer - 0, # Component 0 (not a cable component) - earth_material_group, # Material group 1 (conductor) - earth_material_id, # Material ID - ) - earth_infshell_name = create_physical_group_name(workspace, earth_infshell_tag) - - - # Create group tags for boundary curves - above ground (air) - inner domain - air_boundary_tag = encode_boundary_tag(1, air_layer_idx, 1) - air_boundary_name = create_physical_group_name(workspace, air_boundary_tag) - air_boundary_marker = [0.0, domain_radius, 0.0] - - # Below ground (earth) - inner domain - earth_boundary_tag = encode_boundary_tag(1, earth_layer_idx, 1) - earth_boundary_name = create_physical_group_name(workspace, earth_boundary_tag) - earth_boundary_marker = [0.0, -domain_radius, 0.0] - - # Above ground (air) - domain -> infinity - air_infty_tag = encode_boundary_tag(2, air_layer_idx, 1) - air_infty_name = create_physical_group_name(workspace, air_infty_tag) - air_infty_marker = [0.0, domain_radius_inf, 0.0] - - # Below ground (earth) - domain -> infinity - earth_infty_tag = encode_boundary_tag(2, earth_layer_idx, 1) - earth_infty_name = create_physical_group_name(workspace, earth_infty_tag) - earth_infty_marker = [0.0, -domain_radius_inf, 0.0] - - # Create markers for the domain surfaces - earth_region_marker = [0.0, -domain_radius * 0.99, 0.0] - marker_tag = gmsh.model.occ.add_point( - earth_region_marker[1], - earth_region_marker[2], - earth_region_marker[3], - mesh_size_domain, - ) - gmsh.model.set_entity_name( - 0, - marker_tag, - "marker_$(round(mesh_size_domain, sigdigits=6))", - ) - - earth_infshell_marker = - [0.0, -(domain_radius + 0.99 * (domain_radius_inf - domain_radius)), 0.0] - marker_tag = gmsh.model.occ.add_point( - earth_infshell_marker[1], - earth_infshell_marker[2], - earth_infshell_marker[3], - mesh_size_inf, - ) - gmsh.model.set_entity_name(0, marker_tag, "marker_$(round(mesh_size_inf, sigdigits=6))") - - # Create boundary curves - air_boundary_entity = CurveEntity( - CoreEntityData(air_boundary_tag, air_boundary_name, mesh_size_domain), - air_material, - ) - - earth_boundary_entity = CurveEntity( - CoreEntityData(earth_boundary_tag, earth_boundary_name, mesh_size_domain), - earth_material, - ) - - air_infty_entity = CurveEntity( - CoreEntityData(air_infty_tag, air_infty_name, mesh_size_inf), - air_material, - ) - - earth_infty_entity = CurveEntity( - CoreEntityData(earth_infty_tag, earth_infty_name, mesh_size_inf), - earth_material, - ) - - # Add curves to the workspace - workspace.unassigned_entities[air_boundary_marker] = air_boundary_entity - workspace.unassigned_entities[air_infty_marker] = air_infty_entity - workspace.unassigned_entities[earth_boundary_marker] = earth_boundary_entity - workspace.unassigned_entities[earth_infty_marker] = earth_infty_entity - - @debug "Domain boundary markers:" - for point_marker in domain_boundary_markers - target_entity = point_marker[2] > 0 ? air_boundary_entity : earth_boundary_entity - workspace.unassigned_entities[point_marker] = target_entity - @debug " Point $point_marker: ($(point_marker[1]), $(point_marker[2]), $(point_marker[3]))" - end - - @debug "Domain -> infinity markers:" - for point_marker in domain_infty_markers - target_entity = point_marker[2] > 0 ? air_infty_entity : earth_infty_entity - workspace.unassigned_entities[point_marker] = target_entity - @debug " Point $point_marker: ($(point_marker[1]), $(point_marker[2]), $(point_marker[3]))" - end - - # Add physical groups to the workspace - register_physical_group!(workspace, air_region_tag, air_material) - register_physical_group!(workspace, earth_region_tag, earth_material) - register_physical_group!(workspace, air_infshell_tag, air_material) - register_physical_group!(workspace, earth_infshell_tag, earth_material) - - # Physical groups for Dirichlet boundary - register_physical_group!(workspace, air_infty_tag, air_material) - register_physical_group!(workspace, earth_infty_tag, earth_material) - - # Create domain surfaces - air_region_entity = SurfaceEntity( - CoreEntityData(air_region_tag, air_region_name, mesh_size_default), - air_material, - ) - - air_infshell_entity = SurfaceEntity( - CoreEntityData(air_infshell_tag, air_infshell_name, mesh_size_default), - air_material, - ) - - # Earth regions will be created after boolean fragmentation - earth_region_entity = SurfaceEntity( - CoreEntityData(earth_region_tag, earth_region_name, mesh_size_default), - earth_material, - ) - - earth_infshell_entity = SurfaceEntity( - CoreEntityData(earth_infshell_tag, earth_infshell_name, mesh_size_default), - earth_material, - ) - - # Add surfaces to the workspace - workspace.unassigned_entities[air_region_marker] = air_region_entity - workspace.unassigned_entities[air_infshell_marker] = air_infshell_entity - workspace.unassigned_entities[earth_region_marker] = earth_region_entity - workspace.unassigned_entities[earth_infshell_marker] = earth_infshell_entity - - @info "Domain boundaries created" - - # Create earth interface line (y=0) - @debug "Creating earth interface line at y=0" - - # Create line from -domain_radius to +domain_radius at y=0 - num_elements = formulation.elements_per_length_interfaces - earth_interface_mesh_size = - _calc_mesh_size(0, domain_radius, earth_material, num_elements, workspace) - - _, _, earth_interface_markers = draw_line( - -domain_radius_inf, - 0.0, - domain_radius_inf, - 0.0, - earth_interface_mesh_size, - round(Int, domain_radius), - ) - - # Create physical tag for the earth interface - interface_idx = 1 # Earth interface index - earth_interface_tag = encode_boundary_tag(3, interface_idx, 1) - earth_interface_name = create_physical_group_name(workspace, earth_interface_tag) - - # Create domain entity - earth_interface_entity = CurveEntity( - CoreEntityData( - earth_interface_tag, - earth_interface_name, - earth_interface_mesh_size, - ), - get_earth_model_material(workspace, earth_layer_idx), # Earth material - ) - - # Create mesh transitions if specified - if !isempty(workspace.formulation.mesh_transitions) - @info "Creating $(length(workspace.formulation.mesh_transitions)) mesh transition regions" - - for (idx, transition) in enumerate(workspace.formulation.mesh_transitions) - cx, cy = transition.center - - # Use provided layer or auto-detect - layer_idx = if !isnothing(transition.earth_layer) - transition.earth_layer - else - # Fallback auto-detection (should rarely happen due to constructor) - cy >= 0 ? 1 : 2 - end - - # Validate layer index exists in earth model - if layer_idx > num_earth_layers - Base.error( - "Earth layer $layer_idx does not exist in earth model (max: $(num_earth_layers))", - ) - end - - # Get material for this earth layer - transition_material = get_earth_model_material(workspace, layer_idx) - material_id = get_or_register_material_id(workspace, transition_material) - material_group = get_material_group(earth_props, layer_idx) - - # Create physical tag for this transition - transition_tag = encode_physical_group_tag( - 2, # Surface type 2 = physical domain - layer_idx, # Earth layer index - 0, # Component 0 (not a cable component) - material_group, # Material group (1=conductor for earth, 2=insulator for air) - material_id, # Material ID - ) - - layer_name = layer_idx == 1 ? "air" : "earth_$(layer_idx-1)" - transition_name = "mesh_transition_$(idx)_$(layer_name)" - - # Calculate radii and mesh sizes - mesh_size_min = transition.mesh_factor_min * earth_interface_mesh_size - mesh_size_max = transition.mesh_factor_max * earth_interface_mesh_size - - transition_radii = - collect(LinRange(transition.r_min, transition.r_max, transition.n_regions)) - transition_mesh = - collect(LinRange(mesh_size_min, mesh_size_max, transition.n_regions)) - @debug "Transition $(idx): radii=$(transition_radii), mesh sizes=$(transition_mesh)" - - # Draw the transition regions - _, _, transition_markers = draw_transition_region( - cx, cy, - transition_radii, - transition_mesh, - num_points_circumference, - ) - - # Register each transition region - for k in 1:transition.n_regions - transition_region = SurfaceEntity( - CoreEntityData( - transition_tag, - "$(transition_name)_region_$(k)", - transition_mesh[k], - ), - transition_material, - ) - workspace.unassigned_entities[transition_markers[k]] = transition_region - - @debug "Created transition region $k at ($(cx), $(cy)) with radius $(transition_radii[k]) m in layer $layer_idx" - end - - # Register physical group - register_physical_group!(workspace, transition_tag, transition_material) - end - - @info "Mesh transition regions created" - else - @debug "No mesh transitions specified" - end - - # Add interface to the workspace - @debug "Domain -> infinity markers:" - for point_marker in earth_interface_markers - workspace.unassigned_entities[point_marker] = earth_interface_entity - @debug " Point $point_marker: ($(point_marker[1]), $(point_marker[2]), $(point_marker[3]))" - end - - @info "Earth interfaces created" - -end +""" +Domain creation functions for the FEMTools.jl module. +These functions handle the creation of domain boundaries and earth interfaces. +""" + +""" +$(TYPEDSIGNATURES) + +Create the domain boundaries (inner solid disk and outer annular region) for the simulation. + +# Arguments + +- `workspace`: The [`FEMWorkspace`](@ref) containing the model parameters. + +# Returns + +- Nothing. Updates the boundaries vector in the workspace. + +# Examples + +```julia +$(FUNCTIONNAME)(workspace) +``` +""" +function make_space_geometry(workspace::FEMWorkspace) + @info "Creating domain boundaries..." + + # Extract parameters + formulation = workspace.core.formulation + domain_radius = formulation.domain_radius + domain_radius_inf = formulation.domain_radius_inf # External radius for boundary transform + mesh_size_default = formulation.mesh_size_default + mesh_size_domain = formulation.mesh_size_max + mesh_size_inf = 1.25 * formulation.mesh_size_max + + # Center coordinates + x_center = 0.0 + y_center = 0.0 + eps = 1e-6 + + # Create inner domain disk + num_points_circumference = formulation.points_per_circumference + @debug "Creating inner domain disk with radius $(domain_radius) m" + _, _, air_region_marker, domain_boundary_markers = draw_disk( + x_center, + y_center, + domain_radius, + mesh_size_domain, + num_points_circumference, + ) + + # Create outer domain annular region + @debug "Creating outer domain annular region with radius $(domain_radius_inf) m" + _, _, air_infshell_marker, domain_infty_markers = draw_annular( + x_center, + y_center, + domain_radius, + domain_radius_inf, + mesh_size_inf, + num_points_circumference, + ) + + # Get earth model from workspace + earth_props = workspace.core.earth_props + air_layer_idx = 1 # air layer is 1 by default + num_earth_layers = length(earth_props.layers) # Number of earth layers + + # Air layer (Layer 1) + air_material = get_earth_model_material(workspace, air_layer_idx) + air_material_id = get_or_register_material_id(workspace, air_material) + air_material_group = get_material_group(earth_props, air_layer_idx) # Will return 2 (insulator) + + # Physical domain air tag + air_region_tag = encode_physical_group_tag( + 2, # Surface type 2 = physical domain + air_layer_idx, # Layer 1 = air + 0, # Component 0 (not a cable component) + air_material_group, # Material group 2 (insulator) + air_material_id, # Material ID + ) + air_region_name = create_physical_group_name(workspace, air_region_tag) + + # Infinite shell air tag + air_infshell_tag = encode_physical_group_tag( + 3, # Surface type 3 = infinite shell + air_layer_idx, # Layer 1 = air + 0, # Component 0 (not a cable component) + air_material_group, # Material group 2 (insulator) + air_material_id, # Material ID + ) + air_infshell_name = create_physical_group_name(workspace, air_infshell_tag) + + # Create group tags for boundary curves - above ground (air) - inner domain + air_boundary_tag = encode_boundary_tag(1, air_layer_idx, 1) + air_boundary_name = create_physical_group_name(workspace, air_boundary_tag) + air_boundary_marker = [0.0, domain_radius, 0.0] + + # Above ground (air) - domain -> infinity + air_infty_tag = encode_boundary_tag(2, air_layer_idx, 1) + air_infty_name = create_physical_group_name(workspace, air_infty_tag) + air_infty_marker = [0.0, domain_radius_inf, 0.0] + + # Create boundary curves + air_boundary_entity = CurveEntity( + CoreEntityData(air_boundary_tag, air_boundary_name, mesh_size_domain), + air_material, + ) + + air_infty_entity = CurveEntity( + CoreEntityData(air_infty_tag, air_infty_name, mesh_size_inf), + air_material, + ) + # Add curves to the workspace + workspace.core.unassigned_entities[air_boundary_marker] = air_boundary_entity + workspace.core.unassigned_entities[air_infty_marker] = air_infty_entity + + + # Create domain surfaces + air_region_entity = SurfaceEntity( + CoreEntityData(air_region_tag, air_region_name, mesh_size_default), + air_material, + ) + + air_infshell_entity = SurfaceEntity( + CoreEntityData(air_infshell_tag, air_infshell_name, mesh_size_default), + air_material, + ) + # Add surfaces to the workspace + workspace.core.unassigned_entities[air_region_marker] = air_region_entity + workspace.core.unassigned_entities[air_infshell_marker] = air_infshell_entity + + # Add physical groups to the workspace + register_physical_group!(workspace, air_region_tag, air_material) + register_physical_group!(workspace, air_infshell_tag, air_material) + + # Below ground (earth) - inner domain + earth_material = get_earth_model_material(workspace, num_earth_layers) + earth_boundary_tag = encode_boundary_tag(1, num_earth_layers, 1) + earth_boundary_name = create_physical_group_name(workspace, earth_boundary_tag) + earth_boundary_entity = CurveEntity( + CoreEntityData(earth_boundary_tag, earth_boundary_name, mesh_size_domain), + earth_material, + ) + + # Below ground (earth) - domain -> infinity + earth_infty_tag = encode_boundary_tag(2, num_earth_layers, 1) + earth_infty_name = create_physical_group_name(workspace, earth_infty_tag) + earth_infty_entity = CurveEntity( + CoreEntityData(earth_infty_tag, earth_infty_name, mesh_size_inf), + earth_material, + ) + + @debug "Domain boundary markers:" + for point_marker in domain_boundary_markers + target_entity = point_marker[2] > 0 ? air_boundary_entity : earth_boundary_entity + workspace.core.unassigned_entities[point_marker] = target_entity + @debug " Point $point_marker: ($(point_marker[1]), $(point_marker[2]), $(point_marker[3]))" + end + + @debug "Domain -> infinity markers:" + for point_marker in domain_infty_markers + target_entity = point_marker[2] > 0 ? air_infty_entity : earth_infty_entity + workspace.core.unassigned_entities[point_marker] = target_entity + @debug " Point $point_marker: ($(point_marker[1]), $(point_marker[2]), $(point_marker[3]))" + end + + # Physical groups for Dirichlet boundary + register_physical_group!(workspace, earth_infty_tag, earth_material) + register_physical_group!(workspace, air_infty_tag, air_material) + + # Interface Earth Air + num_elements = formulation.elements_per_length_interfaces + + # Earth layer + current_x_start = -domain_radius + current_y_start = 0.0 + + for layer_idx in 2:num_earth_layers + # Material properties + earth_material = get_earth_model_material(workspace, layer_idx) + earth_material_id = get_or_register_material_id(workspace, earth_material) + earth_material_group = get_material_group(earth_props, layer_idx) # Will return 1 (conductor) + + ## Physical domain earth tag + earth_region_tag = encode_physical_group_tag( + 2, # Surface type 2 = physical domain + layer_idx, # Layer 2 = first earth layer + 0, # Component 0 (not a cable component) + earth_material_group, # Material group 1 (conductor) + earth_material_id, # Material ID + ) + earth_region_name = create_physical_group_name(workspace, earth_region_tag) + + ## Infinite shell earth tag + earth_infshell_tag = encode_physical_group_tag( + 3, # Surface type 3 = infinite shell + layer_idx, # Layer 2 = first earth layer + 0, # Component 0 (not a cable component) + earth_material_group, # Material group 1 (conductor) + earth_material_id, # Material ID + ) + earth_infshell_name = create_physical_group_name(workspace, earth_infshell_tag) + + # Add physical groups to the workspace + register_physical_group!(workspace, earth_region_tag, earth_material) + register_physical_group!(workspace, earth_infshell_tag, earth_material) + + # Determine layer thickness + layer_thickness = earth_props.layers[layer_idx].t == Inf ? domain_radius : earth_props.layers[layer_idx].t + + if earth_props.vertical_layers + + next_x_start = clamp(current_x_start + layer_thickness, 0, domain_radius) + @assert next_x_start <= domain_radius "Layer $layer_idx extends beyond domain radius $next_x_start" + + earth_boundary_marker = (layer_idx == 2) ? + [next_x_start-eps, -sqrt(domain_radius^2 - (next_x_start-eps)^2), 0.0] : + [next_x_start+eps, -sqrt(domain_radius^2 - (next_x_start-eps)^2), 0.0] + + earth_infty_marker = (layer_idx == 2) ? + [next_x_start-eps, -sqrt(domain_radius_inf^2 - (next_x_start-eps)^2), 0.0] : + [next_x_start+eps, -sqrt(domain_radius_inf^2 - (next_x_start-eps)^2), 0.0] + + earth_region_marker = [next_x_start-eps, -eps, 0.0] + + workspace.core.unassigned_entities[earth_boundary_marker] = earth_boundary_entity + workspace.core.unassigned_entities[earth_infty_marker] = earth_infty_entity + earth_infshell_marker = [[0.99*(next_x_start-eps), -0.99*sqrt(domain_radius_inf^2 - (next_x_start-eps)^2), 0.0]] + + interface_idx = layer_idx + earth_interface_tag = encode_boundary_tag(3, interface_idx, 1) + earth_interface_name = create_physical_group_name(workspace, earth_interface_tag) + if layer_idx < num_earth_layers + earth_interface_mesh_size = _calc_mesh_size(0, domain_radius, earth_material, num_elements, workspace) + y_pos = sqrt(domain_radius^2 - (next_x_start)^2) + y_pos_inf = sqrt(domain_radius_inf^2 - (next_x_start)^2) + + # Interface in earth layer + _, _, earth_interface_markers = draw_line( + next_x_start, + 0.0, + next_x_start, + -y_pos, + mesh_size_domain, + round(Int, y_pos), + ) + # Interface in infinite shell + _, _, earth_inter_markers_2 = draw_line( + next_x_start, + -y_pos, + next_x_start, + -y_pos_inf, + mesh_size_domain, + round(Int, y_pos_inf-y_pos), + ) + + append!(earth_interface_markers, earth_inter_markers_2) + earth_interface_entity = CurveEntity( + CoreEntityData( + earth_interface_tag, + earth_interface_name, + mesh_size_domain, + ), + get_earth_model_material(workspace, layer_idx), # Earth material + ) + + _ = gmsh.model.occ.add_point(next_x_start, 0.0, 0.0, earth_interface_mesh_size) + _ = gmsh.model.occ.add_point(next_x_start, -y_pos, 0.0, earth_interface_mesh_size) + _ = gmsh.model.occ.add_point(next_x_start, -y_pos_inf, 0.0, earth_interface_mesh_size) + + @debug "Domain interface vertical layers markers:" + for point_marker in earth_interface_markers + workspace.core.unassigned_entities[point_marker] = earth_interface_entity + @debug " Point $point_marker: ($(point_marker[1]), $(point_marker[2]), $(point_marker[3]))" + end + current_x_start = next_x_start + end + + else # Horizontal and Uniform layers + + next_y_start = clamp(current_y_start - layer_thickness, -domain_radius, 0.0) + @assert next_y_start >= -domain_radius "Layer $layer_idx extends beyond domain radius" + + x_pos_inf_current = sqrt(domain_radius_inf^2 - (current_y_start - eps)^2) + + earth_boundary_marker = [[sqrt(domain_radius^2 - (next_y_start/2)^2), next_y_start/2, 0.0]] + push!(earth_boundary_marker, [-sqrt(domain_radius^2 - (next_y_start/2)^2), (next_y_start/2), 0.0]) + + earth_infty_marker = [[sqrt(domain_radius_inf^2 - (next_y_start/2)^2), next_y_start/2, 0.0]] + push!(earth_infty_marker, [-sqrt(domain_radius_inf^2 - (next_y_start/2)^2), (next_y_start/2), 0.0]) + + + for marker in earth_boundary_marker + workspace.core.unassigned_entities[marker] = earth_boundary_entity + end + for marker in earth_infty_marker + workspace.core.unassigned_entities[marker] = earth_infty_entity + end + + earth_region_marker = [0.0, current_y_start - eps, 0.0] + + # For the outer domain (the infinite shell) + earth_infshell_marker = [[-x_pos_inf_current+eps, current_y_start - eps, 0.0]] + push!(earth_infshell_marker, [x_pos_inf_current-eps, current_y_start - eps, 0.0]) + + if layer_idx < num_earth_layers + x_pos = sqrt(domain_radius^2 - next_y_start^2) + x_pos_inf = sqrt(domain_radius_inf^2 - next_y_start^2) + + interface_idx = layer_idx # The interface belongs to the layer below it + earth_interface_tag = encode_boundary_tag(3, interface_idx, 1) + earth_interface_name = create_physical_group_name(workspace, earth_interface_tag) + earth_interface_mesh_size = _calc_mesh_size(0, domain_radius, earth_material, num_elements, workspace) + + # Interface between -domain_radius to domain_radius + _, _, earth_interface_markers = draw_line( + -x_pos, + next_y_start, + x_pos, + next_y_start, + earth_interface_mesh_size, + round(Int, 2 * 2*x_pos / 10), + ) + # Interface between -domain_radius_inf to -domain_radius + _, _, earth_inter_markers_2 = draw_line( + - x_pos_inf, + next_y_start, + - x_pos, + next_y_start, + earth_interface_mesh_size, + round(Int, 2 * (x_pos_inf - x_pos) / 10), + ) + # Interface between +domain_radius_inf to +domain_radius + _, _, earth_inter_markers_3 = draw_line( + + x_pos_inf, + next_y_start, + + x_pos, + next_y_start, + earth_interface_mesh_size, + round(Int, 2 * (x_pos_inf - x_pos) / 10), + ) + append!(earth_interface_markers, earth_inter_markers_2) + append!(earth_interface_markers, earth_inter_markers_3) + marker_tag = gmsh.model.occ.add_point(-x_pos_inf, next_y_start, 0.0, earth_interface_mesh_size) + marker_tag = gmsh.model.occ.add_point(-x_pos, next_y_start, 0.0, earth_interface_mesh_size) + marker_tag = gmsh.model.occ.add_point( x_pos, next_y_start, 0.0, earth_interface_mesh_size) + marker_tag = gmsh.model.occ.add_point( x_pos_inf, next_y_start, 0.0, earth_interface_mesh_size) + + # Creates the interface curve entity + interface_entity = CurveEntity( + CoreEntityData( + earth_interface_tag, + earth_interface_name, + earth_interface_mesh_size, + ), + get_earth_model_material(workspace, layer_idx), # Material of the next layer + ) + + # Associates the line points with the interface entity + @debug "Domain interface horizontal layer markers at y = $(next_y_start):" + for point_marker in earth_interface_markers + workspace.core.unassigned_entities[point_marker] = interface_entity + @debug " Point $point_marker: ($(point_marker[1]), $(point_marker[2]), $(point_marker[3]))" + end + end + + current_y_start = next_y_start + end + marker_tag = gmsh.model.occ.add_point( + earth_region_marker[1], + earth_region_marker[2], + earth_region_marker[3], + mesh_size_domain, + ) + gmsh.model.set_entity_name( + 0, + marker_tag, + "marker_$(round(mesh_size_domain, sigdigits=6))", + ) + # Create markers for all the infinite shell + for point in earth_infshell_marker + marker_tag = gmsh.model.occ.add_point( + point[1], + point[2], + point[3], + mesh_size_inf, + ) + + gmsh.model.set_entity_name(0, marker_tag, "marker_$(round(mesh_size_inf, sigdigits=6))") + end + earth_region_entity = SurfaceEntity( + CoreEntityData(earth_region_tag, earth_region_name, mesh_size_default), + earth_material, + ) + + earth_infshell_entity = SurfaceEntity( + CoreEntityData(earth_infshell_tag, earth_infshell_name, mesh_size_default), + earth_material, + ) + + workspace.core.unassigned_entities[earth_region_marker] = earth_region_entity + for marker in earth_infshell_marker + workspace.core.unassigned_entities[marker] = earth_infshell_entity + end + + + end + earth_material = get_earth_model_material(workspace, num_earth_layers) + earth_interface_mesh_size = _calc_mesh_size(0, domain_radius, earth_material, num_elements, workspace) + _, _, earth_interface_markers = draw_line( + -domain_radius_inf, + 0.0, + domain_radius_inf, + 0.0, + earth_interface_mesh_size, + round(Int, domain_radius), + ) + + interface_idx = 1 # Earth interface index + earth_interface_tag = encode_boundary_tag(3, interface_idx, 1) + earth_interface_name = create_physical_group_name(workspace, earth_interface_tag) + + # Create domain entity + earth_interface_entity = CurveEntity( + CoreEntityData( + earth_interface_tag, + earth_interface_name, + earth_interface_mesh_size, + ), + get_earth_model_material(workspace, num_earth_layers), # Earth material + ) + + + # Create mesh transitions if specified + if !isempty(workspace.core.formulation.mesh_transitions) + @info "Creating $(length(workspace.core.formulation.mesh_transitions)) mesh transition regions" + + for (idx, transition) in enumerate(workspace.core.formulation.mesh_transitions) + cx, cy = transition.center + transition_radii = + collect(LinRange(transition.r_min, transition.r_max, transition.n_regions)) + # Use provided layer or auto-detect + layer_idx = if !isnothing(transition.earth_layer) + transition.earth_layer + else + # Fallback auto-detection (should rarely happen due to constructor) + get_cable_layer(cx, cy, transition_radii[end], earth_props, domain_radius) + end + + # Validate layer index exists in earth model + if layer_idx > num_earth_layers + Base.error( + "Earth layer $layer_idx does not exist in earth model (max: $(num_earth_layers))", + ) + end + + # Get material for this earth layer + transition_material = get_earth_model_material(workspace, layer_idx) + material_id = get_or_register_material_id(workspace, transition_material) + material_group = get_material_group(earth_props, layer_idx) + + # Create physical tag for this transition + transition_tag = encode_physical_group_tag( + 2, # Surface type 2 = physical domain + layer_idx, # Earth layer index + 0, # Component 0 (not a cable component) + material_group, # Material group (1=conductor for earth, 2=insulator for air) + material_id, # Material ID + ) + + layer_name = layer_idx == 1 ? "air" : "earth_$(layer_idx-1)" + transition_name = "mesh_transition_$(idx)_$(layer_name)" + + # Calculate radii and mesh sizes + mesh_size_min = transition.mesh_factor_min * earth_interface_mesh_size + mesh_size_max = transition.mesh_factor_max * earth_interface_mesh_size + + transition_mesh = + collect(LinRange(mesh_size_min, mesh_size_max, transition.n_regions)) + @debug "Transition $(idx): radii=$(transition_radii), mesh sizes=$(transition_mesh)" + + # Draw the transition regions + _, _, transition_markers = draw_transition_region( + cx, cy, + transition_radii, + transition_mesh, + num_points_circumference, + ) + + # Register each transition region + for k in 1:transition.n_regions + transition_region = SurfaceEntity( + CoreEntityData( + transition_tag, + "$(transition_name)_region_$(k)", + transition_mesh[k], + ), + transition_material, + ) + workspace.core.unassigned_entities[transition_markers[k]] = transition_region + + @debug "Created transition region $k at ($(cx), $(cy)) with radius $(transition_radii[k]) m in layer $layer_idx" + end + + # Register physical group + register_physical_group!(workspace, transition_tag, transition_material) + end + + @info "Mesh transition regions created" + else + @debug "No mesh transitions specified" + end + + # Add interface to the workspace + @debug "Domain -> infinity markers:" + for point_marker in earth_interface_markers + workspace.core.unassigned_entities[point_marker] = earth_interface_entity + @debug " Point $point_marker: ($(point_marker[1]), $(point_marker[2]), $(point_marker[3]))" + end + + @info "Earth interfaces created" +end + +""" + get_cable_layer(x::Number, y::Number, earth_model::EarthModel) -> Int + +Determines the index of the soil layer from the `EarthModel` where the cable +centroid located at `(x, y)` is situated. + +The function assumes Layer 1 is always air for any `y >= 0`. It then checks if the +model uses vertical or horizontal layering to determine the correct layer index +for `y < 0`. + +# Arguments +- `x::Number`: The horizontal coordinate of the cable's centroid. +- `y::Number`: The vertical coordinate of the cable's centroid (depth, negative). +- `outermost_radius::Float64`: The outermost radius of the cable. +- `earth_model::EarthModel`: The earth model structure, which must contain a + `layers` vector and a `vertical_layers` boolean. +- `domain_radius::Float64`: The radius of the simulation domain. + +# Returns +- `Int`: The index of the layer (1 for air, 2 for the first earth layer, etc.). +""" +function get_cable_layer(x::Number, y::Number, outermost_radius::Float64, earth_model::EarthModel, domain_radius::Float64) + + number_of_points = round(Int, 2 * pi * outermost_radius / 0.01) + layer_idx_in_circle = Int64[] + for θ in LinRange(0, 2*pi, number_of_points) + x_p = x + outermost_radius * cos(θ) + y_p = y + outermost_radius * sin(θ) + + if (x_p^2 + y_p^2) > domain_radius^2 + # This point is irrelevant to the simulation. Skip to the next one. + Base.error("Mesh Transition outside domain radius!") + end + # Air layer (above ground) + if y_p >= 0.0 + push!(layer_idx_in_circle, 1) + continue + end + + num_layers = length(earth_model.layers) + + # Homogeneous earth (only 1 earth layer) + if num_layers == 2 + push!(layer_idx_in_circle, 2) + continue + end + + # Multi-layer earth + if earth_model.vertical_layers + # Vertical layers: extend horizontally + current_x = 0.0 # Starting boundary for layer 2 + layer_found = false + for layer_idx in 2:num_layers + layer_thickness = earth_model.layers[layer_idx].t + + # Determine next boundary + next_x = isinf(layer_thickness) ? 0.0 : current_x + layer_thickness + + if layer_idx == 2 + if x_p >= -domain_radius && x_p <= next_x + push!(layer_idx_in_circle, layer_idx) + layer_found = true + break + end + else + if x_p > current_x && x_p <= next_x + push!(layer_idx_in_circle, layer_idx) + layer_found = true + break + end + end + + # Update boundary for next iteration + current_x = next_x + end + + if !layer_found + # Fallback: return last layer + push!(layer_idx_in_circle, num_layers) + end + + else + # Horizontal layers: extend vertically downward + current_y = 0.0 # Starting depth + layer_found = false + for layer_idx in 2:num_layers + layer_thickness = earth_model.layers[layer_idx].t + + # Determine next depth boundary + next_y = isinf(layer_thickness) ? -domain_radius : current_y - layer_thickness + + if y_p <= current_y && y_p >= next_y + push!(layer_idx_in_circle, layer_idx) + layer_found = true + break + end + + # Update boundary for next iteration + current_y = next_y + end + if !layer_found + # Fallback: return last layer + push!(layer_idx_in_circle, num_layers) + end + + end + end + if !isempty(layer_idx_in_circle) && all(==(layer_idx_in_circle[1]), layer_idx_in_circle) + # The condition passed, do nothing or proceed. + @debug "All mesh elements are self-contained in a single layer." + return layer_idx_in_circle[1] + else + # The condition failed, throw an error. + Base.error("Not all mesh elements are self-contained in a single layer!") + end +end \ No newline at end of file diff --git a/src/engine/fem/types.jl b/src/engine/fem/types.jl index 1710f6e3..bd3da3f9 100644 --- a/src/engine/fem/types.jl +++ b/src/engine/fem/types.jl @@ -1,141 +1,130 @@ - -""" -$(TYPEDEF) - -Abstract base type for workspace containers in the FEM simulation framework. -Workspace containers maintain the complete state of a simulation, including -intermediate data structures, identification mappings, and results. - -Concrete implementations should provide state tracking for all phases of the -simulation process from geometry creation through results analysis. -""" -abstract type AbstractWorkspace end - -""" -$(TYPEDEF) - -Abstract type for entity data to be stored within the FEMWorkspace. -""" -abstract type AbstractEntityData end - -""" -$(TYPEDEF) - -Core entity data structure containing common properties for all entity types. - -$(TYPEDFIELDS) -""" -struct CoreEntityData - "Encoded physical tag \\[dimensionless\\]." - physical_group_tag::Int - "Name of the elementary surface." - elementary_name::String - "Target mesh size \\[m\\]." - mesh_size::Float64 -end - -""" -$(TYPEDEF) - -Entity data structure for cable parts. - -$(TYPEDFIELDS) -""" -struct CablePartEntity{T <: AbstractCablePart} <: AbstractEntityData - "Core entity data." - core::CoreEntityData - "Reference to original cable part." - cable_part::T -end - -""" -$(TYPEDEF) - -Entity data structure for domain surfaces external to cable parts. - -$(TYPEDFIELDS) -""" -struct SurfaceEntity <: AbstractEntityData - "Core entity data." - core::CoreEntityData - "Material properties of the domain." - material::Material -end - -""" -$(TYPEDEF) - -Entity data structure for domain curves (boundaries and layer interfaces). - -$(TYPEDFIELDS) -""" -struct CurveEntity <: AbstractEntityData - "Core entity data." - core::CoreEntityData - "Material properties of the domain." - material::Material -end - -""" -$(TYPEDEF) - -Entity container that associates Gmsh entity with metadata. - -$(TYPEDFIELDS) -""" -struct GmshObject{T <: AbstractEntityData} - "Gmsh entity tag (will be defined after boolean fragmentation)." - tag::Int32 - "Entity-specific data." - data::T -end - -""" -$(TYPEDSIGNATURES) - -Constructs a [`GmshObject`](@ref) instance with automatic type conversion. - -# Arguments - -- `tag`: Gmsh entity tag (will be converted to Int32) -- `data`: Entity-specific data conforming to [`AbstractEntityData`](@ref) - -# Returns - -- A [`GmshObject`](@ref) instance with the specified tag and data. - -# Notes - -This constructor automatically converts any integer tag to Int32 for compatibility with the Gmsh C API, which uses 32-bit integers for entity tags. - -# Examples - -```julia -# Create domain entity with tag and data -core_data = CoreEntityData([0.0, 0.0, 0.0]) -domain_data = SurfaceEntity(core_data, material) -entity = $(FUNCTIONNAME)(1, domain_data) -``` -""" -function GmshObject(tag::Integer, data::T) where {T <: AbstractEntityData} - return GmshObject{T}(Int32(tag), data) -end - -mutable struct Darwin <: AbstractImpedanceFormulation - problem::GetDP.Problem - resolution_name::String - - function Darwin() - return new(GetDP.Problem(), "Darwin") - end - -end - -mutable struct Electrodynamics <: AbstractAdmittanceFormulation - problem::GetDP.Problem - resolution_name::String - - function Electrodynamics() - return new(GetDP.Problem(), "Electrodynamics") - end -end \ No newline at end of file + +""" +$(TYPEDEF) + +Abstract base type for workspace containers in the FEM simulation framework. +Workspace containers maintain the complete state of a simulation, including +intermediate data structures, identification mappings, and results. + +Concrete implementations should provide state tracking for all phases of the +simulation process from geometry creation through results analysis. +""" +abstract type AbstractWorkspace end + +""" +$(TYPEDEF) + +Abstract type for entity data to be stored within the FEMWorkspace. +""" +abstract type AbstractEntityData end + +""" +$(TYPEDEF) + +Core entity data structure containing common properties for all entity types. + +$(TYPEDFIELDS) +""" +struct CoreEntityData + "Encoded physical tag \\[dimensionless\\]." + physical_group_tag::Int + "Name of the elementary surface." + elementary_name::String + "Target mesh size \\[m\\]." + mesh_size::Float64 +end + +""" +$(TYPEDEF) + +Entity data structure for cable parts. + +$(TYPEDFIELDS) +""" +struct CablePartEntity{T <: AbstractCablePart} <: AbstractEntityData + "Core entity data." + core::CoreEntityData + "Reference to original cable part." + cable_part::T +end + +""" +$(TYPEDEF) + +Entity data structure for domain surfaces external to cable parts. + +$(TYPEDFIELDS) +""" +struct SurfaceEntity <: AbstractEntityData + "Core entity data." + core::CoreEntityData + "Material properties of the domain." + material::Material +end + +""" +$(TYPEDEF) + +Entity data structure for domain curves (boundaries and layer interfaces). + +$(TYPEDFIELDS) +""" +struct CurveEntity <: AbstractEntityData + "Core entity data." + core::CoreEntityData + "Material properties of the domain." + material::Material +end + +""" +$(TYPEDEF) + +Entity container that associates Gmsh entity with metadata. + +$(TYPEDFIELDS) +""" +struct GmshObject{T <: AbstractEntityData} + "Gmsh entity tag (will be defined after boolean fragmentation)." + tag::Int32 + "Entity-specific data." + data::T +end + +""" +$(TYPEDSIGNATURES) + +Constructs a [`GmshObject`](@ref) instance with automatic type conversion. + +# Arguments + +- `tag`: Gmsh entity tag (will be converted to Int32) +- `data`: Entity-specific data conforming to [`AbstractEntityData`](@ref) + +# Returns + +- A [`GmshObject`](@ref) instance with the specified tag and data. + +# Notes + +This constructor automatically converts any integer tag to Int32 for compatibility with the Gmsh C API, which uses 32-bit integers for entity tags. + +# Examples + +```julia +# Create domain entity with tag and data +core_data = CoreEntityData([0.0, 0.0, 0.0]) +domain_data = SurfaceEntity(core_data, material) +entity = $(FUNCTIONNAME)(1, domain_data) +``` +""" +function GmshObject(tag::Integer, data::T) where {T <: AbstractEntityData} + return GmshObject{T}(Int32(tag), data) +end + +struct Darwin <: AbstractImpedanceFormulation end +struct Electrodynamics <: AbstractAdmittanceFormulation end +struct MagnetoThermal <: AmpacityFormulation end + +get_resolution_name(::Darwin) = "Darwin" +get_resolution_name(::Electrodynamics) = "Electrodynamics" +get_resolution_name(::MagnetoThermal) = "MagnetoThermal" \ No newline at end of file diff --git a/src/engine/fem/visualization.jl b/src/engine/fem/visualization.jl index b884e403..c7e0783e 100644 --- a/src/engine/fem/visualization.jl +++ b/src/engine/fem/visualization.jl @@ -1,160 +1,160 @@ -""" -Visualization functions for the FEMTools.jl module. -These functions handle the visualization of the mesh and results. -""" - -""" -$(TYPEDSIGNATURES) - -Preview the mesh in the Gmsh GUI. - -# Arguments - -- `workspace`: The [`FEMWorkspace`](@ref) containing the model. - -# Returns - -- Nothing. Launches the Gmsh GUI. - -# Examples - -```julia -$(FUNCTIONNAME)(workspace) -``` -""" -function preview_mesh(workspace::FEMWorkspace) - - if gmsh.is_initialized() == 0 - gmsh.initialize() - @debug "Initialized Gmsh for mesh preview" - else - @debug "Gmsh already initialized" - end - - try - # Set visualization options - gmsh.option.set_number("Geometry.SurfaceLabels", 0) # Show surface labels - gmsh.option.set_number("Geometry.PointNumbers", 0) - gmsh.option.set_number("Geometry.CurveNumbers", 0) - gmsh.option.set_number("Geometry.SurfaceNumbers", 0) - gmsh.option.set_number("Geometry.NumSubEdges", 160) - gmsh.option.set_number("Geometry.Points", 1) - gmsh.option.set_number("Geometry.Curves", 1) - gmsh.option.set_number("Geometry.Surfaces", 0) - gmsh.option.set_number("Mesh.ColorCarousel", 2) # Colors by physical group - gmsh.option.set_number("Mesh.LineWidth", 1) - gmsh.option.set_number("Mesh.SurfaceFaces", 1) - - # Initialize FLTK GUI - gmsh.fltk.initialize() - - @info "Launching Gmsh GUI for mesh preview" - @info "Close the Gmsh window to continue..." - - # Define event check function - function check_for_event() - action = gmsh.onelab.get_string("ONELAB/Action") - if length(action) > 0 && action[1] == "check" - gmsh.onelab.set_string("ONELAB/Action", [""]) - @debug "UI interaction detected" - gmsh.graphics.draw() - end - return true - end - - # Wait for user to close the window - while gmsh.fltk.is_available() == 1 && check_for_event() - gmsh.fltk.wait() - end - - @info "Mesh preview closed" - - catch e - @warn "Error during mesh preview: $e" - end -end - -""" -$(TYPEDSIGNATURES) - -Preview a single electromagnetic field result file in Gmsh GUI. - -# Arguments -- `workspace`: The [`FEMWorkspace`](@ref) containing the model. -- `pos_file`: Path to the .pos file to visualize. - -# Examples -```julia -$(FUNCTIONNAME)(workspace, "/path/to/result.pos") -``` -""" -function preview_results(workspace::FEMWorkspace, pos_file::String) - # Validate inputs - if !isfile(pos_file) - @error "Result file not found: $pos_file" - return - end - - if !endswith(pos_file, ".pos") - @error "File must be a .pos file: $pos_file" - return - end - - # Initialize Gmsh - gmsh.initialize() - - try - # Add single model - gmsh.model.add("field_view") - - # Merge mesh file - mesh_file = workspace.paths[:mesh_file] - if isfile(mesh_file) - gmsh.merge(abspath(mesh_file)) - else - @error "Mesh file not found: $mesh_file" - return - end - - # Merge the single result file - @info "Loading field data: $(display_path(pos_file))" - gmsh.merge(abspath(pos_file)) - - # Set mesh color to light gray - gmsh.option.set_color("Mesh.Color.Lines", 240, 240, 240) - gmsh.option.set_number("Mesh.ColorCarousel", 0) - gmsh.option.set_number("Mesh.LineWidth", 1) - gmsh.option.set_number("Mesh.SurfaceFaces", 0) - gmsh.option.set_number("Mesh.Lines", 1) - gmsh.option.set_number("Geometry.Points", 0) - gmsh.option.set_number("General.InitialModule", 4) - - # Get view tags and configure - view_tags = gmsh.view.getTags() - - if isempty(view_tags) - @warn "No field views found in file" - return - end - - # Configure field visualization - for view_tag in view_tags - gmsh.view.option.set_number(view_tag, "IntervalsType", 2) - gmsh.view.option.set_number(view_tag, "RangeType", 3) - gmsh.view.option.set_number(view_tag, "ShowTime", 0) - end - - @info "Launching Gmsh GUI with $(length(view_tags)) field view(s)" - @info "Close the Gmsh window to continue..." - - # Launch GUI - gmsh.fltk.run() - - @info "Field visualization closed" - - catch e - @error "Error during field visualization" exception = e - finally - gmsh.finalize() - end +""" +Visualization functions for the FEMTools.jl module. +These functions handle the visualization of the mesh and results. +""" + +""" +$(TYPEDSIGNATURES) + +Preview the mesh in the Gmsh GUI. + +# Arguments + +- `workspace`: The [`FEMWorkspace`](@ref) containing the model. + +# Returns + +- Nothing. Launches the Gmsh GUI. + +# Examples + +```julia +$(FUNCTIONNAME)(workspace) +``` +""" +function preview_mesh(workspace::FEMWorkspace) + + if gmsh.is_initialized() == 0 + gmsh.initialize() + @debug "Initialized Gmsh for mesh preview" + else + @debug "Gmsh already initialized" + end + + try + # Set visualization options + gmsh.option.set_number("Geometry.SurfaceLabels", 0) # Show surface labels + gmsh.option.set_number("Geometry.PointNumbers", 0) + gmsh.option.set_number("Geometry.CurveNumbers", 0) + gmsh.option.set_number("Geometry.SurfaceNumbers", 0) + gmsh.option.set_number("Geometry.NumSubEdges", 160) + gmsh.option.set_number("Geometry.Points", 1) + gmsh.option.set_number("Geometry.Curves", 1) + gmsh.option.set_number("Geometry.Surfaces", 0) + gmsh.option.set_number("Mesh.ColorCarousel", 2) # Colors by physical group + gmsh.option.set_number("Mesh.LineWidth", 1) + gmsh.option.set_number("Mesh.SurfaceFaces", 1) + + # Initialize FLTK GUI + gmsh.fltk.initialize() + + @info "Launching Gmsh GUI for mesh preview" + @info "Close the Gmsh window to continue..." + + # Define event check function + function check_for_event() + action = gmsh.onelab.get_string("ONELAB/Action") + if length(action) > 0 && action[1] == "check" + gmsh.onelab.set_string("ONELAB/Action", [""]) + @debug "UI interaction detected" + gmsh.graphics.draw() + end + return true + end + + # Wait for user to close the window + while gmsh.fltk.is_available() == 1 && check_for_event() + gmsh.fltk.wait() + end + + @info "Mesh preview closed" + + catch e + @warn "Error during mesh preview: $e" + end +end + +""" +$(TYPEDSIGNATURES) + +Preview a single electromagnetic field result file in Gmsh GUI. + +# Arguments +- `workspace`: The [`FEMWorkspace`](@ref) containing the model. +- `pos_file`: Path to the .pos file to visualize. + +# Examples +```julia +$(FUNCTIONNAME)(workspace, "/path/to/result.pos") +``` +""" +function preview_results(workspace::FEMWorkspace, pos_file::String) + # Validate inputs + if !isfile(pos_file) + @error "Result file not found: $pos_file" + return + end + + if !endswith(pos_file, ".pos") + @error "File must be a .pos file: $pos_file" + return + end + + # Initialize Gmsh + gmsh.initialize() + + try + # Add single model + gmsh.model.add("field_view") + + # Merge mesh file + mesh_file = workspace.core.paths[:mesh_file] + if isfile(mesh_file) + gmsh.merge(abspath(mesh_file)) + else + @error "Mesh file not found: $mesh_file" + return + end + + # Merge the single result file + @info "Loading field data: $(display_path(pos_file))" + gmsh.merge(abspath(pos_file)) + + # Set mesh color to light gray + gmsh.option.set_color("Mesh.Color.Lines", 240, 240, 240) + gmsh.option.set_number("Mesh.ColorCarousel", 0) + gmsh.option.set_number("Mesh.LineWidth", 1) + gmsh.option.set_number("Mesh.SurfaceFaces", 0) + gmsh.option.set_number("Mesh.Lines", 1) + gmsh.option.set_number("Geometry.Points", 0) + gmsh.option.set_number("General.InitialModule", 4) + + # Get view tags and configure + view_tags = gmsh.view.getTags() + + if isempty(view_tags) + @warn "No field views found in file" + return + end + + # Configure field visualization + for view_tag in view_tags + gmsh.view.option.set_number(view_tag, "IntervalsType", 2) + gmsh.view.option.set_number(view_tag, "RangeType", 3) + gmsh.view.option.set_number(view_tag, "ShowTime", 0) + end + + @info "Launching Gmsh GUI with $(length(view_tags)) field view(s)" + @info "Close the Gmsh window to continue..." + + # Launch GUI + gmsh.fltk.run() + + @info "Field visualization closed" + + catch e + @error "Error during field visualization" exception = e + finally + gmsh.finalize() + end end \ No newline at end of file diff --git a/src/engine/fem/workspace.jl b/src/engine/fem/workspace.jl index e052b6e5..799b3c40 100644 --- a/src/engine/fem/workspace.jl +++ b/src/engine/fem/workspace.jl @@ -1,282 +1,352 @@ -import ..Engine: _get_earth_data - -""" -$(TYPEDEF) - -FEMWorkspace - The central workspace for FEM simulations. -This is the main container that maintains all state during the simulation process. - -$(TYPEDFIELDS) -""" -struct FEMWorkspace{T <: AbstractFloat} - "Line parameters problem definition." - problem_def::LineParametersProblem - "Formulation parameters." - formulation::FEMFormulation - "Computation options." - opts::FEMOptions - - "Path information." - paths::Dict{Symbol, String} - - "Conductor surfaces within cables." - conductors::Vector{GmshObject{<:AbstractEntityData}} - "Insulator surfaces within cables." - insulators::Vector{GmshObject{<:AbstractEntityData}} - "Domain-space physical surfaces (air and earth layers)." - space_regions::Vector{GmshObject{<:AbstractEntityData}} - "Domain boundary curves." - boundaries::Vector{GmshObject{<:AbstractEntityData}} - "Container for all pre-fragmentation entities." - unassigned_entities::Dict{Vector{Float64}, AbstractEntityData} - "Container for all material names used in the model." - material_registry::Dict{String, Int} - "Container for unique physical groups." - physical_groups::Dict{Int, Material} - - "Vector of frequency values [Hz]." - freq::Vector{T} - "Vector of horizontal positions [m]." - horz::Vector{T} - "Vector of vertical positions [m]." - vert::Vector{T} - "Vector of internal conductor radii [m]." - r_in::Vector{T} - "Vector of external conductor radii [m]." - r_ext::Vector{T} - "Vector of internal insulator radii [m]." - r_ins_in::Vector{T} - "Vector of external insulator radii [m]." - r_ins_ext::Vector{T} - "Vector of conductor resistivities [Ω·m]." - rho_cond::Vector{T} - "Vector of conductor temperature coefficients [1/°C]." - alpha_cond::Vector{T} - "Vector of conductor relative permeabilities." - mu_cond::Vector{T} - "Vector of conductor relative permittivities." - eps_cond::Vector{T} - "Vector of insulator resistivities [Ω·m]." - rho_ins::Vector{T} - "Vector of insulator relative permeabilities." - mu_ins::Vector{T} - "Vector of insulator relative permittivities." - eps_ins::Vector{T} - "Vector of insulator loss tangents." - tan_ins::Vector{T} - "Vector of phase mapping indices." - phase_map::Vector{Int} - "Vector of cable mapping indices." - cable_map::Vector{Int} - "Effective earth resistivity (layers × freq)." - rho_g::Matrix{T} - "Effective earth permittivity (layers × freq)." - eps_g::Matrix{T} - "Effective earth permeability (layers × freq)." - mu_g::Matrix{T} - "Operating temperature [°C]." - temp::T - "Number of frequency samples." - n_frequencies::Int - "Number of phases in the system." - n_phases::Int - "Number of cables in the system." - n_cables::Int - "Full component-based Z matrix (before bundling/reduction)." - Z::Array{Complex{T}, 3} - "Full component-based Y matrix (before bundling/reduction)." - Y::Array{Complex{T}, 3} - - - """ - $(TYPEDSIGNATURES) - - Constructs a [`FEMWorkspace`](@ref) instance. - - # Arguments - - - `cable_system`: Cable system being simulated. - - `formulation`: Problem definition parameters. - - `solver`: Solver parameters. - - `frequency`: Simulation frequency \\[Hz\\]. Default: 50.0. - - # Returns - - - A [`FEMWorkspace`](@ref) instance with the specified parameters. - - # Examples - - ```julia - # Create a workspace - workspace = $(FUNCTIONNAME)(cable_system, formulation, solver) - ``` - """ - function FEMWorkspace( - problem::LineParametersProblem{U}, - formulation::FEMFormulation, - ) where {U <: REALSCALAR} - - # Initialize empty workspace - opts = formulation.options - - system = problem.system - n_frequencies = length(problem.frequencies) - n_phases = sum(length(cable.design_data.components) for cable in system.cables) - - # Pre-allocate 1D arrays - T = BASE_FLOAT - freq = Vector{T}(undef, n_frequencies) - horz = Vector{T}(undef, n_phases) - vert = Vector{T}(undef, n_phases) - r_in = Vector{T}(undef, n_phases) - r_ext = Vector{T}(undef, n_phases) - r_ins_in = Vector{T}(undef, n_phases) - r_ins_ext = Vector{T}(undef, n_phases) - rho_cond = Vector{T}(undef, n_phases) - alpha_cond = Vector{T}(undef, n_phases) - mu_cond = Vector{T}(undef, n_phases) - eps_cond = Vector{T}(undef, n_phases) - rho_ins = Vector{T}(undef, n_phases) - mu_ins = Vector{T}(undef, n_phases) - eps_ins = Vector{T}(undef, n_phases) - tan_ins = Vector{T}(undef, n_phases) # Loss tangent for insulator - phase_map = Vector{Int}(undef, n_phases) - cable_map = Vector{Int}(undef, n_phases) - Z = zeros(Complex{T}, n_phases, n_phases, n_frequencies) - Y = zeros(Complex{T}, n_phases, n_phases, n_frequencies) - - # Fill arrays, ensuring type promotion - freq .= to_nominal.(problem.frequencies) - - idx = 0 - for (cable_idx, cable) in enumerate(system.cables) - for (comp_idx, component) in enumerate(cable.design_data.components) - idx += 1 - # Geometric properties - horz[idx] = to_nominal(cable.horz) - vert[idx] = to_nominal(cable.vert) - r_in[idx] = to_nominal(component.conductor_group.radius_in) - r_ext[idx] = to_nominal(component.conductor_group.radius_ext) - r_ins_in[idx] = to_nominal(component.insulator_group.radius_in) - r_ins_ext[idx] = to_nominal(component.insulator_group.radius_ext) - - # Material properties - rho_cond[idx] = to_nominal(component.conductor_props.rho) - alpha_cond[idx] = to_nominal(component.conductor_props.alpha) - mu_cond[idx] = to_nominal(component.conductor_props.mu_r) - eps_cond[idx] = to_nominal(component.conductor_props.eps_r) - rho_ins[idx] = to_nominal(component.insulator_props.rho) - mu_ins[idx] = to_nominal(component.insulator_props.mu_r) - eps_ins[idx] = to_nominal(component.insulator_props.eps_r) - - # Calculate loss factor from resistivity - ω = 2 * π * f₀ # Using default frequency - C_eq = to_nominal(component.insulator_group.shunt_capacitance) - G_eq = to_nominal(component.insulator_group.shunt_conductance) - tan_ins[idx] = G_eq / (ω * C_eq) - - # Mapping - phase_map[idx] = cable.conn[comp_idx] - cable_map[idx] = cable_idx - end - end - - (rho_g, eps_g, mu_g) = _get_earth_data( - nothing, - problem.earth_props, - freq, - T, - ) - - - temp = to_nominal(problem.temperature) - - - workspace = new{T}( - problem, formulation, opts, - setup_paths(problem.system, formulation), - # Dict{Symbol,String}(), # Path information. - Vector{GmshObject{<:AbstractEntityData}}(), #conductors - Vector{GmshObject{<:AbstractEntityData}}(), #insulators - Vector{GmshObject{<:AbstractEntityData}}(), #space_regions - Vector{GmshObject{<:AbstractEntityData}}(), #boundaries - Dict{Vector{Float64}, AbstractEntityData}(), #unassigned_entities - Dict{String, Int}(), # Initialize empty material registry - Dict{Int, Material}(), # Maps physical group tags to materials, - freq, - horz, vert, - r_in, r_ext, - r_ins_in, r_ins_ext, - rho_cond, alpha_cond, mu_cond, eps_cond, - rho_ins, mu_ins, eps_ins, tan_ins, - phase_map, cable_map, rho_g, - eps_g, mu_g, - temp, n_frequencies, n_phases, - system.num_cables, Z, Y, - ) - - # Set up paths - # workspace.paths = setup_paths(problem.system, formulation) - - return workspace - end -end - -function init_workspace(problem, formulation, workspace) - if isnothing(workspace) - @debug "Creating new workspace" - workspace = FEMWorkspace(problem, formulation) - else - @debug "Reusing existing workspace" - end - - opts = formulation.options - - # set_verbosity!(opts.verbosity, opts.logfile) - - # Handle existing results - check both current and archived - results_dir = workspace.paths[:results_dir] - base_dir = dirname(results_dir) - - # Check current results directory - current_results_exist = isdir(results_dir) && !isempty(readdir(results_dir)) - - # Check for archived frequency results (results_f* pattern) - archived_results_exist = false - if isdir(base_dir) - archived_dirs = - filter(d -> startswith(d, "results_f") && isdir(joinpath(base_dir, d)), - readdir(base_dir)) - archived_results_exist = !isempty(archived_dirs) - end - - # Handle existing results if any are found - if current_results_exist || archived_results_exist - if opts.force_overwrite - # Remove both current and archived results - if current_results_exist - rm(results_dir, recursive = true, force = true) - end - if archived_results_exist - for archived_dir in archived_dirs - rm(joinpath(base_dir, archived_dir), recursive = true, force = true) - end - @debug "Removed $(length(archived_dirs)) archived result directories" - end - else - # Build informative error message - error_msg = "Existing results found:\n" - if current_results_exist - error_msg *= " - Current results: $results_dir\n" - end - if archived_results_exist - error_msg *= " - Archived results: $(length(archived_dirs)) frequency directories\n" - end - error_msg *= "Set force_overwrite=true to automatically delete existing results." - - Base.error(error_msg) - end - end - - return workspace -end +import ..Engine: _get_earth_data + +""" +$(TYPEDEF) + +FEMWorkspaceCore - The core workspace for FEM simulations. + +$(TYPEDFIELDS) +""" +@kwdef struct FEMWorkspaceCore{T <: REALSCALAR} + "Formulation parameters." + formulation::FEMFormulation + "Computation options." + opts::FEMOptions + "Path information." + paths::Dict{Symbol, String} + "Conductor surfaces within cables." + conductors::Vector{GmshObject{<:AbstractEntityData}} + "Insulator surfaces within cables." + insulators::Vector{GmshObject{<:AbstractEntityData}} + "Domain-space physical surfaces (air and earth layers)." + space_regions::Vector{GmshObject{<:AbstractEntityData}} + "Domain boundary curves." + boundaries::Vector{GmshObject{<:AbstractEntityData}} + "Container for all pre-fragmentation entities." + unassigned_entities::Dict{Vector{Float64}, AbstractEntityData} + "Container for all material names used in the model." + material_registry::Dict{String, Int} + "Container for unique physical groups." + physical_groups::Dict{Int, Material} + "Vector of frequency values [Hz]." + freq::Vector{T} + "Vector of horizontal positions [m]." + horz::Vector{T} + "Vector of vertical positions [m]." + vert::Vector{T} + "Vector of internal conductor radii [m]." + r_in::Vector{T} + "Vector of external conductor radii [m]." + r_ext::Vector{T} + "Vector of internal insulator radii [m]." + r_ins_in::Vector{T} + "Vector of external insulator radii [m]." + r_ins_ext::Vector{T} + "Vector of conductor resistivities [Ω·m]." + rho_cond::Vector{T} + "Vector of conductor temperature coefficients [1/°C]." + alpha_cond::Vector{T} + "Vector of conductor relative permeabilities." + mu_cond::Vector{T} + "Vector of conductor relative permittivities." + eps_cond::Vector{T} + "Vector of insulator resistivities [Ω·m]." + rho_ins::Vector{T} + "Vector of insulator relative permeabilities." + mu_ins::Vector{T} + "Vector of insulator relative permittivities." + eps_ins::Vector{T} + "Vector of insulator loss tangents." + tan_ins::Vector{T} + "Vector of phase mapping indices." + phase_map::Vector{Int} + "Vector of cable mapping indices." + cable_map::Vector{Int} + "Effective earth resistivity (layers × freq)." + rho_g::Matrix{T} + "Effective earth permittivity (layers × freq)." + eps_g::Matrix{T} + "Effective earth permeability (layers × freq)." + mu_g::Matrix{T} + "Thermal conductivity of earth (layers × freq)." + kappa_g::Matrix{T} + "Operating temperature [°C]." + temp::T + "Number of frequency samples." + n_frequencies::Int + "Number of phases in the system." + n_phases::Int + "Number of cables in the system." + n_cables::Int + "The physical cable system to analyze." + system::LineCableSystem{T} + "Earth properties model." + earth_props::EarthModel{T} +end + +""" +$(TYPEDEF) + +FEMWorkspaceAmpacity - The workspace for ampacity simulations. + +$(TYPEDFIELDS) +""" +@kwdef struct FEMWorkspaceAmpacity{T <: AbstractFloat} + "Common parameters definitions." + core::FEMWorkspaceCore + "Vector of energizing currents [A]. Index corresponds to phase number." + energizations::Vector{Complex{T}} + "Velocity of the ambient wind [m/s]." + wind_velocity::T + + +end + +""" +$(TYPEDEF) + +FEMWorkspaceLineParameters - The workspace for line parameters simulations. + +$(TYPEDFIELDS) +""" +@kwdef struct FEMWorkspaceLineParameters{T <: AbstractFloat} + "Common parameters definitions." + core::FEMWorkspaceCore + "Full component-based Z matrix (before bundling/reduction)." + Z::Array{Complex{T}, 3} + "Full component-based Y matrix (before bundling/reduction)." + Y::Array{Complex{T}, 3} +end + +function Base.show(io::IO, ::MIME"text/plain", ws::FEMWorkspaceLineParameters) + print(io, "FEMWorkspaceLineParameters:\n") + # iterar em todos os campos de ws.core e imprimir + print(io, " Z: ", ws.Z, "\n") + print(io, " Y: ", ws.Y, "\n") +end + +""" + FEMWorkspace + +A type alias (`Union`) that can refer to an instance of either a +[`FEMWorkspaceAmpacity`](@ref) or a [`FEMWorkspaceLineParameters`](@ref). +""" +const FEMWorkspace{T} = Union{FEMWorkspaceAmpacity{T}, FEMWorkspaceLineParameters{T}} +""" +$(TYPEDSIGNATURES) + +Internal function to construct the common [`FEMWorkspaceCore`](@ref). +This populates all common fields from a given problem. +""" +function build_workspace( + problem::Union{LineParametersProblem{U}, AmpacityProblem{U}}, + formulation::FEMFormulation, +) where {U <: REALSCALAR} + + opts = formulation.options + system = problem.system + n_frequencies = length(problem.frequencies) + n_phases = sum(length(cable.design_data.components) for cable in system.cables) + + T = BASE_FLOAT + + # Pre-allocate 1D arrays + freq = Vector{T}(undef, n_frequencies) + horz = Vector{T}(undef, n_phases) + vert = Vector{T}(undef, n_phases) + r_in = Vector{T}(undef, n_phases) + r_ext = Vector{T}(undef, n_phases) + r_ins_in = Vector{T}(undef, n_phases) + r_ins_ext = Vector{T}(undef, n_phases) + rho_cond = Vector{T}(undef, n_phases) + alpha_cond = Vector{T}(undef, n_phases) + mu_cond = Vector{T}(undef, n_phases) + eps_cond = Vector{T}(undef, n_phases) + rho_ins = Vector{T}(undef, n_phases) + mu_ins = Vector{T}(undef, n_phases) + eps_ins = Vector{T}(undef, n_phases) + tan_ins = Vector{T}(undef, n_phases) + phase_map = Vector{Int}(undef, n_phases) + cable_map = Vector{Int}(undef, n_phases) + + # Fill arrays + freq .= problem.frequencies + + idx = 0 + for (cable_idx, cable) in enumerate(system.cables) + for (comp_idx, component) in enumerate(cable.design_data.components) + idx += 1 + # Geometric properties + horz[idx] = T(cable.horz) + vert[idx] = T(cable.vert) + r_in[idx] = T(component.conductor_group.radius_in) + r_ext[idx] = T(component.conductor_group.radius_ext) + r_ins_in[idx] = T(component.insulator_group.radius_in) + r_ins_ext[idx] = T(component.insulator_group.radius_ext) + + # Material properties + rho_cond[idx] = T(component.conductor_props.rho) + alpha_cond[idx] = T(component.conductor_props.alpha) + mu_cond[idx] = T(component.conductor_props.mu_r) + eps_cond[idx] = T(component.conductor_props.eps_r) + rho_ins[idx] = T(component.insulator_props.rho) + mu_ins[idx] = T(component.insulator_props.mu_r) + eps_ins[idx] = T(component.insulator_props.eps_r) + + # Calculate loss factor + ω = 2 * π * f₀ # Using default frequency + C_eq = T(component.insulator_group.shunt_capacitance) + G_eq = T(component.insulator_group.shunt_conductance) + tan_ins[idx] = G_eq / (ω * C_eq) + + # Mapping + phase_map[idx] = cable.conn[comp_idx] + cable_map[idx] = cable_idx + end + end + + (rho_g, eps_g, mu_g, kappa_g) = _get_earth_data( + nothing, + problem.earth_props, + freq, + T, + ) + + temp = T(problem.temperature) + + # Create and return the core workspace + core = FEMWorkspaceCore{T}( + formulation = formulation, + opts = opts, + paths = setup_paths(problem.system, formulation), + conductors = Vector{GmshObject{<:AbstractEntityData}}(), + insulators = Vector{GmshObject{<:AbstractEntityData}}(), + space_regions = Vector{GmshObject{<:AbstractEntityData}}(), + boundaries = Vector{GmshObject{<:AbstractEntityData}}(), + unassigned_entities = Dict{Vector{Float64}, AbstractEntityData}(), + material_registry = Dict{String, Int}(), + physical_groups = Dict{Int, Material}(), + freq = freq, + horz = horz, + vert = vert, + r_in = r_in, + r_ext = r_ext, + r_ins_in = r_ins_in, + r_ins_ext = r_ins_ext, + rho_cond = rho_cond, + alpha_cond = alpha_cond, + mu_cond = mu_cond, + eps_cond = eps_cond, + rho_ins = rho_ins, + mu_ins = mu_ins, + eps_ins = eps_ins, + tan_ins = tan_ins, + phase_map = phase_map, + cable_map = cable_map, + rho_g = rho_g, + eps_g = eps_g, + mu_g = mu_g, + kappa_g = kappa_g, + temp = temp, + n_frequencies = n_frequencies, + n_phases = n_phases, + n_cables = system.num_cables, + system = system, + earth_props = problem.earth_props, + ) + return FEMWorkspaceSpecific(problem, core) + +end + +function FEMWorkspaceSpecific( + problem::LineParametersProblem{T}, + core::FEMWorkspaceCore{T}, +) where {T} + + # 1. Add LineParameters-specific fields + n_phases = core.n_phases + n_frequencies = core.n_frequencies + Z = zeros(Complex{T}, n_phases, n_phases, n_frequencies) + Y = zeros(Complex{T}, n_phases, n_phases, n_frequencies) + + # 2. Return the complete workspace + return FEMWorkspaceLineParameters{T}( + core = core, + Z = Z, + Y = Y + ) +end + +function FEMWorkspaceSpecific( + problem::AmpacityProblem{T}, + core::FEMWorkspaceCore{T}, +) where {T} + + # 1. Return the complete workspace + return FEMWorkspaceAmpacity{T}( + core = core, + wind_velocity = problem.wind_velocity, + energizations = problem.energizations, + ) +end + +function init_workspace( + problem::Union{LineParametersProblem, AmpacityProblem}, + formulation::FEMFormulation, + workspace::Union{FEMWorkspace, Nothing} +) + if isnothing(workspace) + @debug "Creating new workspace" + workspace = build_workspace(problem, formulation) + else + @debug "Reusing existing workspace" + end + + opts = formulation.options + + # Access paths via the core + results_dir = workspace.core.paths[:results_dir] + base_dir = dirname(results_dir) + + # Check current results directory + current_results_exist = isdir(results_dir) && !isempty(readdir(results_dir)) + + # Check for archived frequency results (results_f* pattern) + archived_results_exist = false + if isdir(base_dir) + archived_dirs = + filter(d -> startswith(d, "results_f") && isdir(joinpath(base_dir, d)), + readdir(base_dir)) + archived_results_exist = !isempty(archived_dirs) + end + + # Handle existing results if any are found + if current_results_exist || archived_results_exist + if opts.force_overwrite + # Remove both current and archived results + if current_results_exist + rm(results_dir, recursive = true, force = true) + end + if archived_results_exist + for archived_dir in archived_dirs + rm(joinpath(base_dir, archived_dir), recursive = true, force = true) + end + @debug "Removed $(length(archived_dirs)) archived result directories" + end + else + # Build informative error message + error_msg = "Existing results found:\n" + if current_results_exist + error_msg *= " - Current results: $results_dir\n" + end + if archived_results_exist + error_msg *= " - Archived results: $(length(archived_dirs)) frequency directories\n" + end + error_msg *= "Set force_overwrite=true to automatically delete existing results." + + Base.error(error_msg) + end + end + + return workspace +end diff --git a/src/engine/helpers.jl b/src/engine/helpers.jl index fbbb5f7a..198625ba 100644 --- a/src/engine/helpers.jl +++ b/src/engine/helpers.jl @@ -1,182 +1,184 @@ -""" -$(TYPEDSIGNATURES) - -Inspects all numerical data within a `LineParametersProblem` and determines the -common floating-point type. If any value (frequencies, geometric properties, -material properties, or earth properties) is a `Measurement`, the function -returns `Measurement{Float64}`. Otherwise, it returns `Float64`. -""" -function _find_common_type(problem::LineParametersProblem) - # Check frequencies - any(x -> x isa Measurement, problem.frequencies) && return Measurement{Float64} - - # Check cable system properties - for cable in problem.system.cables - (cable.horz isa Measurement || cable.vert isa Measurement) && - return Measurement{Float64} - for component in cable.design_data.components - if any( - x -> x isa Measurement, - ( - component.conductor_group.radius_in, - component.conductor_group.radius_ext, - component.insulator_group.radius_in, - component.insulator_group.radius_ext, - component.conductor_props.rho, component.conductor_props.mu_r, - component.conductor_props.eps_r, - component.insulator_props.rho, component.insulator_props.mu_r, - component.insulator_props.eps_r, - component.insulator_group.shunt_capacitance, - component.insulator_group.shunt_conductance, - ), - ) - return Measurement{Float64} - end - end - end - - # Check earth model properties - if !isnothing(problem.earth_props) - for layer in problem.earth_props.layers - if any(x -> x isa Measurement, (layer.rho_g, layer.mu_g, layer.eps_g)) - return Measurement{Float64} - end - end - end - - if !isnothing(problem.temperature) - if problem.temperature isa Measurement - return Measurement{Float64} - end - - end - - return Float64 -end - -function _get_earth_data( - functor::AbstractEHEMFormulation, - earth_model::EarthModel, - freq::Vector{<:REALSCALAR}, - T::DataType, -) - return functor(earth_model, freq, T) -end - -""" -Default method for when no EHEM formulation is provided. -""" -function _get_earth_data(::Nothing, - earth_model::EarthModel, - freq::AbstractVector{<:REALSCALAR}, - ::Type{T}) where {T <: REALSCALAR} - - nL = length(earth_model.layers) - nF = length(freq) - - ρ = Matrix{T}(undef, nL, nF) - ε = Matrix{T}(undef, nL, nF) - μ = Matrix{T}(undef, nL, nF) - - @inbounds for i in 1:nL - L = earth_model.layers[i] - @assert length(L.rho_g) == nF && length(L.eps_g) == nF && length(L.mu_g) == nF - # Fill elementwise to avoid temp vectors - for j in 1:nF - ρ[i, j] = T(to_nominal(L.rho_g[j])) - ε[i, j] = T(to_nominal(L.eps_g[j])) - μ[i, j] = T(to_nominal(L.mu_g[j])) - end - end - - return (rho_g = ρ, eps_g = ε, mu_g = μ) -end - -@inline function _get_outer_radii(cable_map::AbstractVector{Int}, - r_ext::AbstractVector{T}, - r_ins_ext::AbstractVector{T}) where {T <: Real} - @assert length(cable_map) == length(r_ext) == length(r_ins_ext) - n = length(cable_map) - G = maximum(cable_map) - gmax = fill(zero(T), G) - @inbounds for i in 1:n - g = cable_map[i] - r = max(r_ext[i], r_ins_ext[i]) - if r > gmax[g] - ; - gmax[g] = r; - end - end - return gmax -end - -@inline function _calc_horz_sep!(dest::AbstractMatrix{T}, - horz::AbstractVector{T}, - r_ext::AbstractVector{T}, - r_ins_ext::AbstractVector{T}, - cable_map::AbstractVector{Int}) where {T <: Real} - @assert size(dest, 1) == size(dest, 2) == length(horz) == - length(r_ext) == length(r_ins_ext) == length(cable_map) - n = length(horz) - gmax = _get_outer_radii(cable_map, r_ext, r_ins_ext) - @inbounds for j in 1:n, i in 1:n - if cable_map[i] == cable_map[j] - dest[i, j] = gmax[cable_map[i]] - else - dest[i, j] = abs(horz[i] - horz[j]) - end - end - return dest -end - -@inline function _get_cable_indices(ws) - Nc = ws.n_cables - idxs_by_cable = [Int[] for _ in 1:Nc] - @inbounds for i in 1:ws.n_phases - push!(idxs_by_cable[ws.cable_map[i]], i) - end - heads = similar(collect(1:Nc)) - @inbounds for c in 1:Nc - heads[c] = idxs_by_cable[c][1] # representative (any member) per cable - end - return idxs_by_cable, heads -end - -@inline function _to_phase!(A::AbstractMatrix{Complex{T}}) where {T <: REALSCALAR} - m, n = size(A) - - # Right-multiply by T_I (lower-triangular ones): cumulative sum of columns, right→left - @inbounds for j in (n-1):-1:1 - @views A[:, j] .+= A[:, j+1] - end - - # Left-multiply by T_V^{-1} (bidiagonal solve): cumulative sum of rows, bottom→top - @inbounds for i in (m-1):-1:1 - @views A[i, :] .+= A[i+1, :] - end - - return A -end - -# function _to_phase!( -# M::Matrix{Complex{T}}, -# ) where {T <: REALSCALAR} -# # Check the size of the M matrix (assuming M is NxN) -# N = size(M, 1) - -# # Build the voltage transformation matrix T_V -# T_V = Matrix{T}(I, N, N + 1) # Start with an identity matrix -# for i ∈ 1:N -# T_V[i, i+1] = -1 # Set the -1 in the next column -# end -# T_V = T_V[:, 1:N] # Remove the last column - -# # Build the current transformation matrix T_I -# T_I = tril(ones(T, N, N)) # Lower triangular matrix of ones - -# # Compute the new impedance matrix M_prime -# M = T_V \ M * T_I - -# return M -# end - +""" +$(TYPEDSIGNATURES) + +Inspects all numerical data within a `LineParametersProblem` and determines the +common floating-point type. If any value (frequencies, geometric properties, +material properties, or earth properties) is a `Measurement`, the function +returns `Measurement{Float64}`. Otherwise, it returns `Float64`. +""" +function _find_common_type(problem::LineParametersProblem) + # Check frequencies + any(x -> x isa Measurement, problem.frequencies) && return Measurement{Float64} + + # Check cable system properties + for cable in problem.system.cables + (cable.horz isa Measurement || cable.vert isa Measurement) && + return Measurement{Float64} + for component in cable.design_data.components + if any( + x -> x isa Measurement, + ( + component.conductor_group.radius_in, + component.conductor_group.radius_ext, + component.insulator_group.radius_in, + component.insulator_group.radius_ext, + component.conductor_props.rho, component.conductor_props.mu_r, + component.conductor_props.eps_r, + component.insulator_props.rho, component.insulator_props.mu_r, + component.insulator_props.eps_r, + component.insulator_group.shunt_capacitance, + component.insulator_group.shunt_conductance, + ), + ) + return Measurement{Float64} + end + end + end + + # Check earth model properties + if !isnothing(problem.earth_props) + for layer in problem.earth_props.layers + if any(x -> x isa Measurement, (layer.rho_g, layer.mu_g, layer.eps_g)) + return Measurement{Float64} + end + end + end + + if !isnothing(problem.temperature) + if problem.temperature isa Measurement + return Measurement{Float64} + end + + end + + return Float64 +end + +function _get_earth_data( + functor::AbstractEHEMFormulation, + earth_model::EarthModel, + freq::Vector{<:REALSCALAR}, + T::DataType, +) + return functor(earth_model, freq, T) +end + +""" +Default method for when no EHEM formulation is provided. +""" +function _get_earth_data(::Nothing, + earth_model::EarthModel, + freq::AbstractVector{<:REALSCALAR}, + ::Type{T}) where {T <: REALSCALAR} + + nL = length(earth_model.layers) + nF = length(freq) + + ρ = Matrix{T}(undef, nL, nF) + ε = Matrix{T}(undef, nL, nF) + μ = Matrix{T}(undef, nL, nF) + κ = Matrix{T}(undef, nL, nF) + + @inbounds for i in 1:nL + L = earth_model.layers[i] + @assert length(L.rho_g) == nF && length(L.eps_g) == nF && length(L.mu_g) == nF + # Fill elementwise to avoid temp vectors + for j in 1:nF + ρ[i, j] = T(to_nominal(L.rho_g[j])) + ε[i, j] = T(to_nominal(L.eps_g[j])) + μ[i, j] = T(to_nominal(L.mu_g[j])) + κ[i, j] = T(to_nominal(L.kappa_g[j])) + end + end + + return (rho_g = ρ, eps_g = ε, mu_g = μ, kappa_g = κ) +end + +@inline function _get_outer_radii(cable_map::AbstractVector{Int}, + r_ext::AbstractVector{T}, + r_ins_ext::AbstractVector{T}) where {T <: Real} + @assert length(cable_map) == length(r_ext) == length(r_ins_ext) + n = length(cable_map) + G = maximum(cable_map) + gmax = fill(zero(T), G) + @inbounds for i in 1:n + g = cable_map[i] + r = max(r_ext[i], r_ins_ext[i]) + if r > gmax[g] + ; + gmax[g] = r; + end + end + return gmax +end + +@inline function _calc_horz_sep!(dest::AbstractMatrix{T}, + horz::AbstractVector{T}, + r_ext::AbstractVector{T}, + r_ins_ext::AbstractVector{T}, + cable_map::AbstractVector{Int}) where {T <: Real} + @assert size(dest, 1) == size(dest, 2) == length(horz) == + length(r_ext) == length(r_ins_ext) == length(cable_map) + n = length(horz) + gmax = _get_outer_radii(cable_map, r_ext, r_ins_ext) + @inbounds for j in 1:n, i in 1:n + if cable_map[i] == cable_map[j] + dest[i, j] = gmax[cable_map[i]] + else + dest[i, j] = abs(horz[i] - horz[j]) + end + end + return dest +end + +@inline function _get_cable_indices(ws) + Nc = ws.n_cables + idxs_by_cable = [Int[] for _ in 1:Nc] + @inbounds for i in 1:ws.n_phases + push!(idxs_by_cable[ws.cable_map[i]], i) + end + heads = similar(collect(1:Nc)) + @inbounds for c in 1:Nc + heads[c] = idxs_by_cable[c][1] # representative (any member) per cable + end + return idxs_by_cable, heads +end + +@inline function _to_phase!(A::AbstractMatrix{Complex{T}}) where {T <: REALSCALAR} + m, n = size(A) + + # Right-multiply by T_I (lower-triangular ones): cumulative sum of columns, right→left + @inbounds for j in (n-1):-1:1 + @views A[:, j] .+= A[:, j+1] + end + + # Left-multiply by T_V^{-1} (bidiagonal solve): cumulative sum of rows, bottom→top + @inbounds for i in (m-1):-1:1 + @views A[i, :] .+= A[i+1, :] + end + + return A +end + +# function _to_phase!( +# M::Matrix{Complex{T}}, +# ) where {T <: REALSCALAR} +# # Check the size of the M matrix (assuming M is NxN) +# N = size(M, 1) + +# # Build the voltage transformation matrix T_V +# T_V = Matrix{T}(I, N, N + 1) # Start with an identity matrix +# for i ∈ 1:N +# T_V[i, i+1] = -1 # Set the -1 in the next column +# end +# T_V = T_V[:, 1:N] # Remove the last column + +# # Build the current transformation matrix T_I +# T_I = tril(ones(T, N, N)) # Lower triangular matrix of ones + +# # Compute the new impedance matrix M_prime +# M = T_V \ M * T_I + +# return M +# end + diff --git a/src/engine/insulationadmittance/InsulationAdmittance.jl b/src/engine/insulationadmittance/InsulationAdmittance.jl index af908046..c06436ec 100644 --- a/src/engine/insulationadmittance/InsulationAdmittance.jl +++ b/src/engine/insulationadmittance/InsulationAdmittance.jl @@ -1,25 +1,25 @@ -""" - LineCableModels.Engine.InsulationAdmittance - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module InsulationAdmittance - -# Export public API -export Lossless - -# Module-specific dependencies -using ...Commons -import ...Commons: get_description -import ..Engine: InsulationAdmittanceFormulation -using Measurements - -include("lossless.jl") - +""" + LineCableModels.Engine.InsulationAdmittance + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module InsulationAdmittance + +# Export public API +export Lossless + +# Module-specific dependencies +using ...Commons +import ...Commons: get_description +import ..Engine: InsulationAdmittanceFormulation +using Measurements + +include("lossless.jl") + end # module InsulationAdmittance \ No newline at end of file diff --git a/src/engine/insulationadmittance/lossless.jl b/src/engine/insulationadmittance/lossless.jl index 3bfe9418..dd945124 100644 --- a/src/engine/insulationadmittance/lossless.jl +++ b/src/engine/insulationadmittance/lossless.jl @@ -1,22 +1,22 @@ -struct Lossless <: InsulationAdmittanceFormulation end -get_description(::Lossless) = "Lossless insulation (ideal dielectric)" - -@inline function (f::Lossless)( - r_in::T, - r_ex::T, - epsr_i::T, - jω::Complex{T}, -) where {T <: REALSCALAR} - - if isapprox(r_in, 0.0, atol = eps(T)) || isapprox(r_in, r_ex, atol = eps(T)) - # TODO: Implement consistent handling of admittance for bare conductors - # Issue URL: https://github.com/Electa-Git/LineCableModels.jl/issues/17 - return zero(Complex{T}) - end - - # Constants - eps_i = T(ε₀) * epsr_i - - - return Complex{T}(log(r_ex / r_in) / (2π * eps_i)) -end +struct Lossless <: InsulationAdmittanceFormulation end +get_description(::Lossless) = "Lossless insulation (ideal dielectric)" + +@inline function (f::Lossless)( + r_in::T, + r_ex::T, + epsr_i::T, + jω::Complex{T}, +) where {T <: REALSCALAR} + + if isapprox(r_in, 0.0, atol = eps(T)) || isapprox(r_in, r_ex, atol = eps(T)) + # TODO: Implement consistent handling of admittance for bare conductors + # Issue URL: https://github.com/Electa-Git/LineCableModels.jl/issues/17 + return zero(Complex{T}) + end + + # Constants + eps_i = T(ε₀) * epsr_i + + + return Complex{T}(log(r_ex / r_in) / (2π * eps_i)) +end diff --git a/src/engine/insulationimpedance/InsulationImpedance.jl b/src/engine/insulationimpedance/InsulationImpedance.jl index 60854f40..438ea758 100644 --- a/src/engine/insulationimpedance/InsulationImpedance.jl +++ b/src/engine/insulationimpedance/InsulationImpedance.jl @@ -1,26 +1,26 @@ -""" - LineCableModels.Engine.InsulationImpedance - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module InsulationImpedance - -# Export public API -export Lossless - - -# Module-specific dependencies -using ...Commons -import ...Commons: get_description -import ..Engine: InsulationImpedanceFormulation -using Measurements - -include("lossless.jl") - -end # module InsulationImpedance +""" + LineCableModels.Engine.InsulationImpedance + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module InsulationImpedance + +# Export public API +export Lossless + + +# Module-specific dependencies +using ...Commons +import ...Commons: get_description +import ..Engine: InsulationImpedanceFormulation +using Measurements + +include("lossless.jl") + +end # module InsulationImpedance diff --git a/src/engine/insulationimpedance/lossless.jl b/src/engine/insulationimpedance/lossless.jl index b70e3a8f..4153de8c 100644 --- a/src/engine/insulationimpedance/lossless.jl +++ b/src/engine/insulationimpedance/lossless.jl @@ -1,22 +1,22 @@ - -struct Lossless <: InsulationImpedanceFormulation end -get_description(::Lossless) = "Lossless insulation (ideal dielectric)" - -@inline function (f::Lossless)( - r_in::T, - r_ex::T, - mur_i::T, - jω::Complex{T}, -) where {T <: REALSCALAR} - - if isapprox(r_in, 0.0, atol = eps(T)) || isapprox(r_in, r_ex, atol = eps(T)) - # TODO: Implement consistent handling of admittance for bare conductors - # Issue URL: https://github.com/Electa-Git/LineCableModels.jl/issues/18 - return zero(Complex{T}) - end - - # Constants - mu_i = T(μ₀) * mur_i - - return Complex{T}(jω * mu_i * log(r_ex / r_in) / 2π) + +struct Lossless <: InsulationImpedanceFormulation end +get_description(::Lossless) = "Lossless insulation (ideal dielectric)" + +@inline function (f::Lossless)( + r_in::T, + r_ex::T, + mur_i::T, + jω::Complex{T}, +) where {T <: REALSCALAR} + + if isapprox(r_in, 0.0, atol = eps(T)) || isapprox(r_in, r_ex, atol = eps(T)) + # TODO: Implement consistent handling of admittance for bare conductors + # Issue URL: https://github.com/Electa-Git/LineCableModels.jl/issues/18 + return zero(Complex{T}) + end + + # Constants + mu_i = T(μ₀) * mur_i + + return Complex{T}(jω * mu_i * log(r_ex / r_in) / 2π) end \ No newline at end of file diff --git a/src/engine/internalimpedance/InternalImpedance.jl b/src/engine/internalimpedance/InternalImpedance.jl index 6d839dd8..7913d87f 100644 --- a/src/engine/internalimpedance/InternalImpedance.jl +++ b/src/engine/internalimpedance/InternalImpedance.jl @@ -1,29 +1,29 @@ -""" - LineCableModels.Engine.InternalImpedance - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module InternalImpedance - -# Export public API -export ScaledBessel - -# Module-specific dependencies -using ...Commons -import ...Commons: get_description -import ..Engine: InternalImpedanceFormulation -using Measurements -using LinearAlgebra -using ...UncertainBessels: besselix, besselkx -using ...Utils: _to_σ - -include("scaledbessel.jl") - -end # module InternalImpedance - +""" + LineCableModels.Engine.InternalImpedance + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module InternalImpedance + +# Export public API +export ScaledBessel + +# Module-specific dependencies +using ...Commons +import ...Commons: get_description +import ..Engine: InternalImpedanceFormulation +using Measurements +using LinearAlgebra +using ...UncertainBessels: besselix, besselkx +using ...Utils: _to_σ + +include("scaledbessel.jl") + +end # module InternalImpedance + diff --git a/src/engine/internalimpedance/scaledbessel.jl b/src/engine/internalimpedance/scaledbessel.jl index 8dafd717..ed11e613 100644 --- a/src/engine/internalimpedance/scaledbessel.jl +++ b/src/engine/internalimpedance/scaledbessel.jl @@ -1,157 +1,157 @@ - -struct ScaledBessel <: InternalImpedanceFormulation end -get_description(::ScaledBessel) = "Scaled Bessel (Schelkunoff)" - - -@inline function (f::ScaledBessel)( - form::Symbol, - r_in::T, - r_ex::T, - rho_c::T, - mur_c::T, - jω::Complex{T}, -) where {T <: REALSCALAR} - Base.@nospecialize form - return form === :inner ? f(Val(:inner), r_in, r_ex, rho_c, mur_c, jω) : - form === :outer ? f(Val(:outer), r_in, r_ex, rho_c, mur_c, jω) : - form === :mutual ? f(Val(:mutual), r_in, r_ex, rho_c, mur_c, jω) : - throw(ArgumentError("Unknown ScaledBessel form: $form")) -end - -@inline function (f::ScaledBessel)( - ::Val{:inner}, - r_in::T, - r_ex::T, - rho_c::T, - mur_c::T, - jω::Complex{T}, -) where {T <: REALSCALAR} - # Constants - mu_c = T(μ₀) * mur_c - sigma_c = _to_σ(rho_c) - - # Calculate the reciprocal of the skin depth - m = sqrt(jω * mu_c * sigma_c) - w_ex = m * r_ex - - if isapprox(r_in, 0.0, atol = eps(T)) - return zero(Complex{T}) # not physical, but consistent with :outer - algorithmic shortcut for solids/tubular blending - else - - w_in = m * r_in - - sc_in = exp(abs(real(w_in)) - w_ex) - sc_ex = exp(abs(real(w_ex)) - w_in) - sc = sc_in / sc_ex - - # Bessel function terms with uncertainty handling - N = - (besselkx(0, w_in)) * - (besselix(1, w_ex)) + - sc * - (besselix(0, w_in)) * - (besselkx(1, w_ex)) - - D = - (besselkx(1, w_in)) * - (besselix(1, w_ex)) - - sc * - (besselix(1, w_in)) * - (besselkx(1, w_ex)) - - return Complex{T}((jω * mu_c / 2π) * (1 / w_in) * (N / D)) - end - -end - -@inline function (f::ScaledBessel)( - ::Val{:outer}, - r_in::T, - r_ex::T, - rho_c::T, - mur_c::T, - jω::Complex{T}, -) where {T <: REALSCALAR} - # Constants - mu_c = T(μ₀) * mur_c - sigma_c = _to_σ(rho_c) - - # Calculate the reciprocal of the skin depth - m = sqrt(jω * mu_c * sigma_c) - w_ex = m * r_ex - - if isapprox(r_in, 0.0, atol = eps(T)) # solid conductor - @debug "Using closed form for solid conductor" - N = besselix(0, w_ex) - D = besselix(1, w_ex) - - else - w_in = m * r_in - - sc_in = exp(abs(real(w_in)) - w_ex) - sc_ex = exp(abs(real(w_ex)) - w_in) - sc = sc_in / sc_ex - - # Bessel function terms with uncertainty handling - N = - (besselix(0, w_ex)) * - (besselkx(1, w_in)) + - sc * - (besselkx(0, w_ex)) * - (besselix(1, w_in)) - - D = - (besselix(1, w_ex)) * - (besselkx(1, w_in)) - - sc * - (besselkx(1, w_ex)) * - (besselix(1, w_in)) - end - - return Complex{T}((jω * mu_c / 2π) * (1 / w_ex) * (N / D)) -end - -@inline function (f::ScaledBessel)( - ::Val{:mutual}, - r_in::T, - r_ex::T, - rho_c::T, - mur_c::T, - jω::Complex{T}, -) where {T <: REALSCALAR} - # Constants - mu_c = T(μ₀) * mur_c - sigma_c = _to_σ(rho_c) - - # Calculate the reciprocal of the skin depth - m = sqrt(jω * mu_c * sigma_c) - w_ex = m * r_ex - - if isapprox(r_in, 0.0, atol = eps(T)) - - return zero(Complex{T}) # not physical, but consistent with :outer - algorithmic shortcut for solids/tubular blending - # return f(Val(:outer), r_in, r_ex, rho_c, mur_c, freq) - - else - - w_in = m * r_in - - sc_in = exp(abs(real(w_in)) - w_ex) - sc_ex = exp(abs(real(w_ex)) - w_in) - sc = sc_in / sc_ex - - # Bessel function terms with uncertainty handling - N = 1.0 / sc_ex - - D = - (besselix(1, w_ex)) * - (besselkx(1, w_in)) - - sc * - (besselix(1, w_in)) * - (besselkx(1, w_ex)) - - return Complex{T}((1 / (2π * r_in * r_ex * sigma_c)) * (N / D)) - - end - -end + +struct ScaledBessel <: InternalImpedanceFormulation end +get_description(::ScaledBessel) = "Scaled Bessel (Schelkunoff)" + + +@inline function (f::ScaledBessel)( + form::Symbol, + r_in::T, + r_ex::T, + rho_c::T, + mur_c::T, + jω::Complex{T}, +) where {T <: REALSCALAR} + Base.@nospecialize form + return form === :inner ? f(Val(:inner), r_in, r_ex, rho_c, mur_c, jω) : + form === :outer ? f(Val(:outer), r_in, r_ex, rho_c, mur_c, jω) : + form === :mutual ? f(Val(:mutual), r_in, r_ex, rho_c, mur_c, jω) : + throw(ArgumentError("Unknown ScaledBessel form: $form")) +end + +@inline function (f::ScaledBessel)( + ::Val{:inner}, + r_in::T, + r_ex::T, + rho_c::T, + mur_c::T, + jω::Complex{T}, +) where {T <: REALSCALAR} + # Constants + mu_c = T(μ₀) * mur_c + sigma_c = _to_σ(rho_c) + + # Calculate the reciprocal of the skin depth + m = sqrt(jω * mu_c * sigma_c) + w_ex = m * r_ex + + if isapprox(r_in, 0.0, atol = eps(T)) + return zero(Complex{T}) # not physical, but consistent with :outer - algorithmic shortcut for solids/tubular blending + else + + w_in = m * r_in + + sc_in = exp(abs(real(w_in)) - w_ex) + sc_ex = exp(abs(real(w_ex)) - w_in) + sc = sc_in / sc_ex + + # Bessel function terms with uncertainty handling + N = + (besselkx(0, w_in)) * + (besselix(1, w_ex)) + + sc * + (besselix(0, w_in)) * + (besselkx(1, w_ex)) + + D = + (besselkx(1, w_in)) * + (besselix(1, w_ex)) - + sc * + (besselix(1, w_in)) * + (besselkx(1, w_ex)) + + return Complex{T}((jω * mu_c / 2π) * (1 / w_in) * (N / D)) + end + +end + +@inline function (f::ScaledBessel)( + ::Val{:outer}, + r_in::T, + r_ex::T, + rho_c::T, + mur_c::T, + jω::Complex{T}, +) where {T <: REALSCALAR} + # Constants + mu_c = T(μ₀) * mur_c + sigma_c = _to_σ(rho_c) + + # Calculate the reciprocal of the skin depth + m = sqrt(jω * mu_c * sigma_c) + w_ex = m * r_ex + + if isapprox(r_in, 0.0, atol = eps(T)) # solid conductor + @debug "Using closed form for solid conductor" + N = besselix(0, w_ex) + D = besselix(1, w_ex) + + else + w_in = m * r_in + + sc_in = exp(abs(real(w_in)) - w_ex) + sc_ex = exp(abs(real(w_ex)) - w_in) + sc = sc_in / sc_ex + + # Bessel function terms with uncertainty handling + N = + (besselix(0, w_ex)) * + (besselkx(1, w_in)) + + sc * + (besselkx(0, w_ex)) * + (besselix(1, w_in)) + + D = + (besselix(1, w_ex)) * + (besselkx(1, w_in)) - + sc * + (besselkx(1, w_ex)) * + (besselix(1, w_in)) + end + + return Complex{T}((jω * mu_c / 2π) * (1 / w_ex) * (N / D)) +end + +@inline function (f::ScaledBessel)( + ::Val{:mutual}, + r_in::T, + r_ex::T, + rho_c::T, + mur_c::T, + jω::Complex{T}, +) where {T <: REALSCALAR} + # Constants + mu_c = T(μ₀) * mur_c + sigma_c = _to_σ(rho_c) + + # Calculate the reciprocal of the skin depth + m = sqrt(jω * mu_c * sigma_c) + w_ex = m * r_ex + + if isapprox(r_in, 0.0, atol = eps(T)) + + return zero(Complex{T}) # not physical, but consistent with :outer - algorithmic shortcut for solids/tubular blending + # return f(Val(:outer), r_in, r_ex, rho_c, mur_c, freq) + + else + + w_in = m * r_in + + sc_in = exp(abs(real(w_in)) - w_ex) + sc_ex = exp(abs(real(w_ex)) - w_in) + sc = sc_in / sc_ex + + # Bessel function terms with uncertainty handling + N = 1.0 / sc_ex + + D = + (besselix(1, w_ex)) * + (besselkx(1, w_in)) - + sc * + (besselix(1, w_in)) * + (besselkx(1, w_ex)) + + return Complex{T}((1 / (2π * r_in * r_ex * sigma_c)) * (N / D)) + + end + +end diff --git a/src/engine/lineparamopts.jl b/src/engine/lineparamopts.jl index d3e03c04..bfe064fd 100644 --- a/src/engine/lineparamopts.jl +++ b/src/engine/lineparamopts.jl @@ -1,88 +1,88 @@ -Base.@kwdef struct LineParamOptions - "Skip user confirmation for overwriting results" - force_overwrite::Bool = false - "Reduce bundle conductors to equivalent single conductor" - reduce_bundle::Bool = true - "Eliminate grounded conductors from the system (Kron reduction)" - kron_reduction::Bool = true - "Enforce ideal transposition/snaking" - ideal_transposition::Bool = true - "Temperature correction" - temperature_correction::Bool = true - "Store primitive matrices" - store_primitive_matrices::Bool = true - "Verbosity level" - verbosity::Int = 0 - "Log file path" - logfile::Union{String, Nothing} = nothing -end - - -# --- Helpers to turn anything into a NamedTuple ---------------------------- - -_to_nt(nt::NamedTuple) = nt -_to_nt(p::Base.Pairs) = (; p...) -_to_nt(d::AbstractDict) = (; d...) -_to_nt(::Nothing) = (;) - -# --- Generic key splitter + builder --------------------------------------- - -const _COMMON_KEYS = Set(fieldnames(LineParamOptions)) - -_select_keys(nt::NamedTuple, allowed::Set{Symbol}) = - (; (k => v for (k, v) in pairs(nt) if k in allowed)...) - -function build_options(::Type{O}, opts; - strict::Bool = true, -) where {O <: AbstractFormulationOptions} - - nt = _to_nt(opts) - - own_allowed = Set(filter(!=(:common), fieldnames(O))) - common_nt = _select_keys(nt, _COMMON_KEYS) - own_nt = _select_keys(nt, own_allowed) - - unknown = setdiff(Set(keys(nt)), union(_COMMON_KEYS, own_allowed)) - if strict && !isempty(unknown) - throw(ArgumentError("Unknown option keys for $(O): $(collect(unknown))")) - end - - return O(; common = LineParamOptions(; common_nt...), own_nt...) -end - -# Convenience overloads (accept already-built things) -build_options(::Type{O}, o::O; kwargs...) where {O <: AbstractFormulationOptions} = o -build_options( - ::Type{O}, - c::LineParamOptions; - kwargs..., -) where {O <: AbstractFormulationOptions} = O(; common = c) - -# save_path stays solver-specific (different sensible defaults). -Base.@kwdef struct EMTOptions <: AbstractFormulationOptions - common::LineParamOptions = LineParamOptions() - "Save path for output files" - save_path::String = joinpath(".", "lineparams_output") -end - -const _COMMON_SYMS = Tuple(fieldnames(LineParamOptions)) -const _EMT_OWN = Tuple(s for s in fieldnames(EMTOptions) if s != :common) -@inline Base.hasproperty(::EMTOptions, s::Symbol) = - (s in _EMT_OWN) || (s in _COMMON_SYMS) || s === :common - -@inline function Base.getproperty(o::EMTOptions, s::Symbol) - s === :common && return getfield(o, :common) - (s in _EMT_OWN) && return getfield(o, s) # EMT-specific - (s in _COMMON_SYMS) && return getfield(o.common, s) # forwarded common - throw(ArgumentError("Unknown option $(s) for $(typeof(o))")) -end - -Base.propertynames(::EMTOptions, ::Bool = false) = (_COMMON_SYMS..., _EMT_OWN..., :common) -Base.get(o::EMTOptions, s::Symbol, default) = - hasproperty(o, s) ? getproperty(o, s) : default -asnamedtuple(o::EMTOptions) = (; (k=>getproperty(o, k) for k in propertynames(o))...) - - - - - +Base.@kwdef struct LineParamOptions + "Skip user confirmation for overwriting results" + force_overwrite::Bool = false + "Reduce bundle conductors to equivalent single conductor" + reduce_bundle::Bool = true + "Eliminate grounded conductors from the system (Kron reduction)" + kron_reduction::Bool = true + "Enforce ideal transposition/snaking" + ideal_transposition::Bool = true + "Temperature correction" + temperature_correction::Bool = true + "Store primitive matrices" + store_primitive_matrices::Bool = true + "Verbosity level" + verbosity::Int = 0 + "Log file path" + logfile::Union{String, Nothing} = nothing +end + + +# --- Helpers to turn anything into a NamedTuple ---------------------------- + +_to_nt(nt::NamedTuple) = nt +_to_nt(p::Base.Pairs) = (; p...) +_to_nt(d::AbstractDict) = (; d...) +_to_nt(::Nothing) = (;) + +# --- Generic key splitter + builder --------------------------------------- + +const _COMMON_KEYS = Set(fieldnames(LineParamOptions)) + +_select_keys(nt::NamedTuple, allowed::Set{Symbol}) = + (; (k => v for (k, v) in pairs(nt) if k in allowed)...) + +function build_options(::Type{O}, opts; + strict::Bool = true, +) where {O <: AbstractFormulationOptions} + + nt = _to_nt(opts) + + own_allowed = Set(filter(!=(:common), fieldnames(O))) + common_nt = _select_keys(nt, _COMMON_KEYS) + own_nt = _select_keys(nt, own_allowed) + + unknown = setdiff(Set(keys(nt)), union(_COMMON_KEYS, own_allowed)) + if strict && !isempty(unknown) + throw(ArgumentError("Unknown option keys for $(O): $(collect(unknown))")) + end + + return O(; common = LineParamOptions(; common_nt...), own_nt...) +end + +# Convenience overloads (accept already-built things) +build_options(::Type{O}, o::O; kwargs...) where {O <: AbstractFormulationOptions} = o +build_options( + ::Type{O}, + c::LineParamOptions; + kwargs..., +) where {O <: AbstractFormulationOptions} = O(; common = c) + +# save_path stays solver-specific (different sensible defaults). +Base.@kwdef struct EMTOptions <: AbstractFormulationOptions + common::LineParamOptions = LineParamOptions() + "Save path for output files" + save_path::String = joinpath(".", "lineparams_output") +end + +const _COMMON_SYMS = Tuple(fieldnames(LineParamOptions)) +const _EMT_OWN = Tuple(s for s in fieldnames(EMTOptions) if s != :common) +@inline Base.hasproperty(::EMTOptions, s::Symbol) = + (s in _EMT_OWN) || (s in _COMMON_SYMS) || s === :common + +@inline function Base.getproperty(o::EMTOptions, s::Symbol) + s === :common && return getfield(o, :common) + (s in _EMT_OWN) && return getfield(o, s) # EMT-specific + (s in _COMMON_SYMS) && return getfield(o.common, s) # forwarded common + throw(ArgumentError("Unknown option $(s) for $(typeof(o))")) +end + +Base.propertynames(::EMTOptions, ::Bool = false) = (_COMMON_SYMS..., _EMT_OWN..., :common) +Base.get(o::EMTOptions, s::Symbol, default) = + hasproperty(o, s) ? getproperty(o, s) : default +asnamedtuple(o::EMTOptions) = (; (k=>getproperty(o, k) for k in propertynames(o))...) + + + + + diff --git a/src/engine/lineparams.jl b/src/engine/lineparams.jl index c14b8fbd..87ad2714 100644 --- a/src/engine/lineparams.jl +++ b/src/engine/lineparams.jl @@ -1,92 +1,92 @@ -struct SeriesImpedance{T} <: AbstractArray{T, 3} - values::Array{T, 3} # n×n×nfreq, units: Ω/m -end - -struct ShuntAdmittance{T} <: AbstractArray{T, 3} - values::Array{T, 3} # n×n×nfreq, units: S/m -end - -""" -$(TYPEDEF) - -Represents the frequency-dependent line parameters (series impedance and shunt admittance matrices) for a cable or line system. - -$(TYPEDFIELDS) -""" -struct LineParameters{T <: COMPLEXSCALAR, U <: REALSCALAR} - "Series impedance matrices \\[Ω/m\\]." - Z::SeriesImpedance{T} - "Shunt admittance matrices \\[S/m\\]." - Y::ShuntAdmittance{T} - "Frequencies \\[Hz\\]." - f::Vector{U} - - @doc """ - $(TYPEDSIGNATURES) - - Constructs a [`LineParameters`](@ref) instance. - - # Arguments - - - `Z`: Series impedance matrices \\[Ω/m\\]. - - `Y`: Shunt admittance matrices \\[S/m\\]. - - `f`: Frequencies \\[Hz\\]. - - # Returns - - - A [`LineParameters`](@ref) object with prelocated impedance and admittance matrices for a given frequency range. - - # Examples - - ```julia - params = $(FUNCTIONNAME)(Z, Y, f) - ``` - """ - function LineParameters( - Z::SeriesImpedance{T}, - Y::ShuntAdmittance{T}, - f::AbstractVector{U}, - ) where {T <: COMPLEXSCALAR, U <: REALSCALAR} - size(Z, 1) == size(Z, 2) || throw(DimensionMismatch("Z must be square")) - size(Y, 1) == size(Y, 2) || throw(DimensionMismatch("Y must be square")) - size(Z, 3) == size(Y, 3) == length(f) || - throw(DimensionMismatch("Z and Y must have same dimensions (n×n×nfreq)")) - new{T, U}(Z, Y, Vector{U}(f)) - end -end - -SeriesImpedance(A::AbstractArray{T, 3}) where {T} = SeriesImpedance{T}(Array(A)) -ShuntAdmittance(A::AbstractArray{T, 3}) where {T} = ShuntAdmittance{T}(Array(A)) - -# --- Outer convenience constructors ------------------------------------------- - -""" -$(TYPEDSIGNATURES) - -Construct from 3D arrays and frequency vector. Arrays are wrapped -into `SeriesImpedance` and `ShuntAdmittance` automatically. -""" -function LineParameters( - Z::AbstractArray{Tc, 3}, - Y::AbstractArray{Tc, 3}, - f::AbstractVector{U}, -) where {Tc <: COMPLEXSCALAR, U <: REALSCALAR} - return LineParameters(SeriesImpedance(Z), ShuntAdmittance(Y), f) -end - -""" -$(TYPEDSIGNATURES) - -Backward-compatible constructor without frequencies. A dummy equally-spaced -`Vector{BASE_FLOAT}` is used with length `size(Z,3)`. -""" -function LineParameters( - Z::AbstractArray{Tc, 3}, - Y::AbstractArray{Tc, 3}, -) where {Tc <: COMPLEXSCALAR} - nfreq = size(Z, 3) - (size(Y, 3) == nfreq) || throw(DimensionMismatch("Z and Y must have same nfreq")) - # Provide a placeholder frequency vector to preserve legacy call sites - f = collect(BASE_FLOAT.(1:nfreq)) - return LineParameters(SeriesImpedance(Z), ShuntAdmittance(Y), f) -end +struct SeriesImpedance{T} <: AbstractArray{T, 3} + values::Array{T, 3} # n×n×nfreq, units: Ω/m +end + +struct ShuntAdmittance{T} <: AbstractArray{T, 3} + values::Array{T, 3} # n×n×nfreq, units: S/m +end + +""" +$(TYPEDEF) + +Represents the frequency-dependent line parameters (series impedance and shunt admittance matrices) for a cable or line system. + +$(TYPEDFIELDS) +""" +struct LineParameters{T <: COMPLEXSCALAR, U <: REALSCALAR} + "Series impedance matrices \\[Ω/m\\]." + Z::SeriesImpedance{T} + "Shunt admittance matrices \\[S/m\\]." + Y::ShuntAdmittance{T} + "Frequencies \\[Hz\\]." + f::Vector{U} + + @doc """ + $(TYPEDSIGNATURES) + + Constructs a [`LineParameters`](@ref) instance. + + # Arguments + + - `Z`: Series impedance matrices \\[Ω/m\\]. + - `Y`: Shunt admittance matrices \\[S/m\\]. + - `f`: Frequencies \\[Hz\\]. + + # Returns + + - A [`LineParameters`](@ref) object with prelocated impedance and admittance matrices for a given frequency range. + + # Examples + + ```julia + params = $(FUNCTIONNAME)(Z, Y, f) + ``` + """ + function LineParameters( + Z::SeriesImpedance{T}, + Y::ShuntAdmittance{T}, + f::AbstractVector{U}, + ) where {T <: COMPLEXSCALAR, U <: REALSCALAR} + size(Z, 1) == size(Z, 2) || throw(DimensionMismatch("Z must be square")) + size(Y, 1) == size(Y, 2) || throw(DimensionMismatch("Y must be square")) + size(Z, 3) == size(Y, 3) == length(f) || + throw(DimensionMismatch("Z and Y must have same dimensions (n×n×nfreq)")) + new{T, U}(Z, Y, Vector{U}(f)) + end +end + +SeriesImpedance(A::AbstractArray{T, 3}) where {T} = SeriesImpedance{T}(Array(A)) +ShuntAdmittance(A::AbstractArray{T, 3}) where {T} = ShuntAdmittance{T}(Array(A)) + +# --- Outer convenience constructors ------------------------------------------- + +""" +$(TYPEDSIGNATURES) + +Construct from 3D arrays and frequency vector. Arrays are wrapped +into `SeriesImpedance` and `ShuntAdmittance` automatically. +""" +function LineParameters( + Z::AbstractArray{Tc, 3}, + Y::AbstractArray{Tc, 3}, + f::AbstractVector{U}, +) where {Tc <: COMPLEXSCALAR, U <: REALSCALAR} + return LineParameters(SeriesImpedance(Z), ShuntAdmittance(Y), f) +end + +""" +$(TYPEDSIGNATURES) + +Backward-compatible constructor without frequencies. A dummy equally-spaced +`Vector{BASE_FLOAT}` is used with length `size(Z,3)`. +""" +function LineParameters( + Z::AbstractArray{Tc, 3}, + Y::AbstractArray{Tc, 3}, +) where {Tc <: COMPLEXSCALAR} + nfreq = size(Z, 3) + (size(Y, 3) == nfreq) || throw(DimensionMismatch("Z and Y must have same nfreq")) + # Provide a placeholder frequency vector to preserve legacy call sites + f = collect(BASE_FLOAT.(1:nfreq)) + return LineParameters(SeriesImpedance(Z), ShuntAdmittance(Y), f) +end diff --git a/src/engine/plot.jl b/src/engine/plot.jl index a4c03da4..c48211e8 100644 --- a/src/engine/plot.jl +++ b/src/engine/plot.jl @@ -1,1209 +1,1209 @@ - -using Makie -import Makie: plot - -import ..BackendHandler: BackendHandler, next_fignum -using Base: basename, mod1 -using Dates: format, now - -using ..PlotUIComponents: - PlotAssembly, - PlotBuildArtifacts, - ControlButtonSpec, - ControlToggleSpec, - ControlReaction, - _make_window, - _run_plot_pipeline, - with_plot_theme, - ensure_export_background!, - with_icon, - MI_REFRESH, - MI_SAVE, - ICON_TTF, - AXIS_LABEL_FONT_SIZE, - clear_status! - -using Measurements: Measurements - -const _ICON_FN = - (icon; text = nothing, kwargs...) -> - with_icon(icon; text = text === nothing ? "" : text, kwargs...) - -const LP_FIG_SIZE = (800, 400) - -const METRIC_PREFIX_EXPONENT = Dict( - :yocto => -24, - :zepto => -21, - :atto => -18, - :femto => -15, - :pico => -12, - :nano => -9, - :micro => -6, - :milli => -3, - :centi => -2, - :deci => -1, - :base => 0, - :deca => 1, - :hecto => 2, - :kilo => 3, - :mega => 6, - :giga => 9, - :tera => 12, - :peta => 15, - :exa => 18, - :zetta => 21, - :yotta => 24, -) - -const METRIC_PREFIX_SYMBOL = Dict( - :yocto => "y", - :zepto => "z", - :atto => "a", - :femto => "f", - :pico => "p", - :nano => "n", - :micro => "μ", - :milli => "m", - :centi => "c", - :deci => "d", - :base => "", - :deca => "da", - :hecto => "h", - :kilo => "k", - :mega => "M", - :giga => "G", - :tera => "T", - :peta => "P", - :exa => "E", - :zetta => "Z", - :yotta => "Y", -) - -const DEFAULT_QUANTITY_UNITS = Dict( - :impedance => :base, - :admittance => :base, - :resistance => :base, - :inductance => :milli, - :conductance => :base, - :capacitance => :micro, - :angle => :base, -) - -struct UnitSpec - symbol::String - per_length::Bool -end - -struct ComponentMetadata - component::Symbol - quantity::Symbol - symbol::String - title::String - axis_label::String - unit::UnitSpec -end - -struct LineParametersPlotSpec - parent_kind::Symbol - component::Symbol - symbol::String - title::String - xlabel::String - ylabel::String - freqs::Vector{<:Real} - raw_freqs::Vector{<:Real} - curves::Vector{Vector{<:Real}} - raw_curves::Vector{Vector{<:Real}} - labels::Vector{String} - x_exp::Int - y_exp::Int - fig_size::Union{Nothing, Tuple{Int, Int}} - xscale::Base.RefValue{Function} - yscale::Base.RefValue{Function} -end - -const EXPORT_TIMESTAMP_FORMAT = "yyyymmdd_HHMMSS" -const EXPORT_EXTENSION = "svg" - -function _sanitize_filename_component(str::AbstractString) - sanitized = lowercase(strip(str)) - sanitized = replace(sanitized, r"[^0-9a-z]+" => "_") - sanitized = strip(sanitized, '_') - return isempty(sanitized) ? "lineparameters_plot" : sanitized -end - -function _default_export_path( - spec::LineParametersPlotSpec; - extension::AbstractString = EXPORT_EXTENSION, -) - base_title = strip(spec.title) - base = isempty(base_title) ? string(spec.parent_kind, "_", spec.component) : base_title - name = _sanitize_filename_component(base) - timestamp = format(now(), EXPORT_TIMESTAMP_FORMAT) - filename = string(name, "_", timestamp, ".", extension) - return joinpath(pwd(), filename) -end - -function _save_plot_export(spec::LineParametersPlotSpec, axis) - # Capture current axis scales before building the export figure - spec.xscale[] = axis.xscale[] - spec.yscale[] = axis.yscale[] - fig = build_export_figure(spec) - trim!(fig.layout) - path = _default_export_path(spec) - Makie.save(path, fig) - return path -end - -get_description(::SeriesImpedance) = ( - impedance = "Series impedance", - resistance = "Series resistance", - inductance = "Series inductance", -) - -get_symbol(::SeriesImpedance) = ( - impedance = "Z", - resistance = "R", - inductance = "L", -) - -get_unit_symbol(::SeriesImpedance) = ( - impedance = "Ω", - resistance = "Ω", - inductance = "H", -) - -get_description(::ShuntAdmittance) = ( - admittance = "Shunt admittance", - conductance = "Shunt conductance", - capacitance = "Shunt capacitance", -) - -get_symbol(::ShuntAdmittance) = ( - admittance = "Y", - conductance = "G", - capacitance = "C", -) - -get_unit_symbol(::ShuntAdmittance) = ( - admittance = "S", - conductance = "S", - capacitance = "F", -) - -parent_kind(::SeriesImpedance) = :series_impedance -parent_kind(::ShuntAdmittance) = :shunt_admittance - -metric_exponent(prefix::Symbol) = - get(METRIC_PREFIX_EXPONENT, prefix) do - Base.error("Unsupported metric prefix :$(prefix)") - end - -prefix_symbol(prefix::Symbol) = - get(METRIC_PREFIX_SYMBOL, prefix) do - Base.error("Unsupported metric prefix :$(prefix)") - end - - -quantity_scale(prefix::Symbol) = 10.0 ^ (-metric_exponent(prefix)) -length_scale(prefix::Symbol) = 10.0 ^ (metric_exponent(prefix)) -frequency_scale(prefix::Symbol) = quantity_scale(prefix) - -function unit_text(quantity_prefix::Symbol, base_unit::String) - ps = prefix_symbol(quantity_prefix) - return isempty(ps) ? base_unit : string(ps, base_unit) -end - -function length_unit_text(prefix::Symbol) - ps = prefix_symbol(prefix) - return isempty(ps) ? "m" : string(ps, "m") -end - -function composite_unit( - quantity_prefix::Symbol, - base_unit::String, - per_length::Bool, - length_prefix::Symbol, -) - numerator = unit_text(quantity_prefix, base_unit) - if per_length - denominator = length_unit_text(length_prefix) - return string(numerator, "/", denominator) - else - return numerator - end -end - -function frequency_axis_label(prefix::Symbol) - unit = unit_text(prefix, "Hz") - return string("frequency [", unit, "]") -end - -function normalize_quantity_units(units) - table = Dict(DEFAULT_QUANTITY_UNITS) - if units isa Symbol - for key in keys(table) - table[key] = units - end - elseif units isa NamedTuple - for (key, val) in pairs(units) - table[key] = val - end - elseif units isa AbstractDict - for (key, val) in units - table[key] = val - end - elseif units === nothing - return table - else - Base.error("Unsupported quantity unit specification $(typeof(units))") - end - return table -end - -function resolve_quantity_prefix(quantity::Symbol, units::AbstractDict{Symbol, Symbol}) - return get(units, quantity, get(DEFAULT_QUANTITY_UNITS, quantity, :base)) -end - -function resolve_conductors(data_dims::NTuple{3, Int}, con) - nrows, ncols, _ = data_dims - if con === nothing - return collect(1:nrows), collect(1:ncols) - elseif con isa Tuple && length(con) == 2 - isel = collect_indices(con[1], nrows) - jsel = collect_indices(con[2], ncols) - return isel, jsel - else - Base.error("Conductor selector must be a tuple (i_sel, j_sel)") - end -end - -function collect_indices(sel, n) - if sel === nothing - return collect(1:n) - elseif sel isa Integer - (1 <= sel <= n) || - Base.error("Index $(sel) out of bounds for dimension of size $(n)") - return [sel] - elseif sel isa AbstractVector - indices = collect(Int, sel) - for idx in indices - (1 <= idx <= n) || - Base.error("Index $(idx) out of bounds for dimension of size $(n)") - end - return indices - elseif sel isa AbstractRange - indices = collect(sel) - for idx in indices - (1 <= idx <= n) || - Base.error("Index $(idx) out of bounds for dimension of size $(n)") - end - return indices - elseif sel isa Colon - return collect(1:n) - else - Base.error("Unsupported selector $(sel)") - end -end - -function components_for(obj::SeriesImpedance, mode::Symbol, coord::Symbol) - desc = get_description(obj) - sym = get_symbol(obj) - units = get_unit_symbol(obj) - if mode == :ZY - coord in (:cart, :polar) || Base.error("Unsupported coordinate system $(coord)") - if coord == :cart - return ComponentMetadata[ - ComponentMetadata(:real, :impedance, sym.impedance, - string(desc.impedance, " – real part"), - string("real(", sym.impedance, ")"), - UnitSpec(units.impedance, true)), - ComponentMetadata(:imag, :impedance, sym.impedance, - string(desc.impedance, " – imaginary part"), - string("imag(", sym.impedance, ")"), - UnitSpec(units.impedance, true)), - ] - else - return ComponentMetadata[ - ComponentMetadata(:magnitude, :impedance, sym.impedance, - string(desc.impedance, " – magnitude"), - string("|", sym.impedance, "|"), - UnitSpec(units.impedance, true)), - ComponentMetadata(:angle, :angle, sym.impedance, - string(desc.impedance, " – angle"), - string("angle(", sym.impedance, ")"), - UnitSpec("deg", false)), - ] - end - elseif mode == :RLCG - return ComponentMetadata[ - ComponentMetadata(:resistance, :resistance, sym.resistance, - desc.resistance, - sym.resistance, - UnitSpec(units.resistance, true)), - ComponentMetadata(:inductance, :inductance, sym.inductance, - desc.inductance, - sym.inductance, - UnitSpec(units.inductance, true)), - ] - else - Base.error("Unsupported mode $(mode)") - end -end - -function components_for(obj::ShuntAdmittance, mode::Symbol, coord::Symbol) - desc = get_description(obj) - sym = get_symbol(obj) - units = get_unit_symbol(obj) - if mode == :ZY - coord in (:cart, :polar) || Base.error("Unsupported coordinate system $(coord)") - if coord == :cart - return ComponentMetadata[ - ComponentMetadata(:real, :admittance, sym.admittance, - string(desc.admittance, " – real part"), - string("real(", sym.admittance, ")"), - UnitSpec(units.admittance, true)), - ComponentMetadata(:imag, :admittance, sym.admittance, - string(desc.admittance, " – imaginary part"), - string("imag(", sym.admittance, ")"), - UnitSpec(units.admittance, true)), - ] - else - return ComponentMetadata[ - ComponentMetadata(:magnitude, :admittance, sym.admittance, - string(desc.admittance, " – magnitude"), - string("|", sym.admittance, "|"), - UnitSpec(units.admittance, true)), - ComponentMetadata(:angle, :angle, sym.admittance, - string(desc.admittance, " – angle"), - string("angle(", sym.admittance, ")"), - UnitSpec("deg", false)), - ] - end - elseif mode == :RLCG - if (coord == :cart || coord == :polar) - @warn "Ignoring argument :$(coord) for RLCG parameters" - end - return ComponentMetadata[ - ComponentMetadata(:conductance, :conductance, sym.conductance, - desc.conductance, - sym.conductance, - UnitSpec(units.conductance, true)), - ComponentMetadata(:capacitance, :capacitance, sym.capacitance, - desc.capacitance, - sym.capacitance, - UnitSpec(units.capacitance, true)), - ] - else - Base.error("Unsupported mode $(mode)") - end -end - -function component_values(component::Symbol, slice, freqs::Vector{<:Real}) - data = collect(slice) - if component === :real - return (real.(data)) - elseif component === :imag - return (imag.(data)) - elseif component === :magnitude - return (abs.(data)) - elseif component === :angle - return rad2deg.((angle.(data))) - elseif component === :resistance || component === :conductance - return (real.(data)) - elseif component === :inductance - imag_part = (imag.(data)) - return reactance_to_l(imag_part, freqs) - elseif component === :capacitance - imag_part = (imag.(data)) - return reactance_to_c(imag_part, freqs) - else - Base.error("Unsupported component $(component)") - end -end - -function reactance_to_l(imag_part::Vector{<:Real}, freqs::Vector{<:Real}) - result = similar(freqs, promote_type(eltype(imag_part), eltype(freqs))) - two_pi = 2π - for idx in eachindex(freqs) - f = freqs[idx] - if iszero(f) - result[idx] = NaN - else - result[idx] = imag_part[idx] / (two_pi * f) - end - end - return result -end - -function reactance_to_c(imag_part::Vector{<:Real}, freqs::Vector{<:Real}) - result = similar(freqs, promote_type(eltype(imag_part), eltype(freqs))) - two_pi = 2π - for idx in eachindex(freqs) - f = freqs[idx] - if iszero(f) - result[idx] = NaN - else - result[idx] = imag_part[idx] / (two_pi * f) - end - end - return result -end - -function legend_label(symbol::String, i::Int, j::Int) - return string(symbol, "(", i, ",", j, ")") -end - -function _axis_label(base::AbstractString, exp::Int) - exp == 0 && return base - return Makie.rich( - base, - Makie.rich(" × 10"; font = :regular, fontsize = AXIS_LABEL_FONT_SIZE), - Makie.rich( - superscript(string(exp)); - font = :regular, - fontsize = AXIS_LABEL_FONT_SIZE - 2, - # baseline_shift = 0.6, - ), - ) -end - -# Return scaled data and the exponent factored out for the axis badge. -function autoscale_axis(values::AbstractVector{<:Real}; _threshold = 1e4) - isempty(values) && return values, 0 - maxval = 0.0 - has_value = false - for val in values - if isnan(val) - continue - end - absval = abs(val) - if !has_value || absval > maxval - maxval = absval - has_value = true - end - end - !has_value && return values, 0 - exp = floor(Int, log10(maxval)) - abs(exp) < 3 && return values, 0 - scale = 10.0 ^ exp - # return values ./ scale, exp - return values ./ scale, exp -end - -function autoscale_axis_stacked( - curves::AbstractVector{<:AbstractVector{<:Real}}; - _threshold = 1e4, -) - isempty(curves) && return curves, 0 - maxval = 0.0 - has_value = false - for curve in curves - for val in curve - if isnan(val) - continue - end - absval = abs(val) - if !has_value || absval > maxval - maxval = absval - has_value = true - end - end - end - !has_value && return curves, 0 - exp = floor(Int, log10(maxval)) - abs(exp) < 3 && return curves, 0 - scale = 10.0 ^ exp - scaled_curves = [curve ./ scale for curve in curves] - return scaled_curves, exp -end - -function lineparameter_plot_specs( - obj::SeriesImpedance, - freqs::AbstractVector; - mode::Symbol = :ZY, - coord::Symbol = :cart, - freq_unit::Symbol = :base, - length_unit::Symbol = :base, - quantity_units = nothing, - con = nothing, - fig_size::Union{Nothing, Tuple{Int, Int}} = LP_FIG_SIZE, - xscale::Function = Makie.identity, - yscale::Function = Makie.identity, -) - freq_vec = collect(freqs) - nfreq = length(freq_vec) - if nfreq <= 1 - @warn "Frequency vector has $(nfreq) sample(s); nothing to plot." - return LineParametersPlotSpec[] - end - size(obj.values, 3) == nfreq || - Base.error("Frequency vector length does not match impedance samples") - comps = components_for(obj, mode, coord) - units = normalize_quantity_units(quantity_units) - freq_scale = frequency_scale(freq_unit) - raw_freq_axis = freq_vec .* freq_scale - freq_axis, freq_exp = autoscale_axis(raw_freq_axis) - xlabel_base = frequency_axis_label(freq_unit) - (isel, jsel) = resolve_conductors(size(obj.values), con) - specs = LineParametersPlotSpec[] - for meta in comps - q_prefix = resolve_quantity_prefix(meta.quantity, units) - y_scale = quantity_scale(q_prefix) - l_scale = meta.unit.per_length ? length_scale(length_unit) : 1.0 - ylabel_unit = - composite_unit(q_prefix, meta.unit.symbol, meta.unit.per_length, length_unit) - ylabel_base = string(meta.axis_label, " [", ylabel_unit, "]") - - # collect raw curves and labels - raw_curves = Vector{Vector{<:Real}}() - labels = String[] - for i in isel, j in jsel - slice = @view obj.values[i, j, :] - raw_vals = component_values(meta.component, slice, freq_vec) - push!(raw_curves, (raw_vals .* y_scale .* l_scale)) - push!(labels, legend_label(meta.symbol, i, j)) - end - curves, y_exp = autoscale_axis_stacked(raw_curves) - push!( - specs, - LineParametersPlotSpec( - parent_kind(obj), - meta.component, - meta.symbol, - meta.title, - xlabel_base, - ylabel_base, - freq_axis, - raw_freq_axis, - curves, - raw_curves, - labels, - freq_exp, - y_exp, - fig_size, - Ref{Function}(xscale), - Ref{Function}(yscale), - ), - ) - end - return specs -end - -function lineparameter_plot_specs( - obj::ShuntAdmittance, - freqs::AbstractVector; - mode::Symbol = :ZY, - coord::Symbol = :cart, - freq_unit::Symbol = :base, - length_unit::Symbol = :base, - quantity_units = nothing, - con = nothing, - fig_size::Union{Nothing, Tuple{Int, Int}} = LP_FIG_SIZE, - xscale::Function = Makie.identity, - yscale::Function = Makie.identity, -) - freq_vec = collect(freqs) - nfreq = length(freq_vec) - if nfreq <= 1 - @warn "Frequency vector has $(nfreq) sample(s); nothing to plot." - return LineParametersPlotSpec[] - end - size(obj.values, 3) == nfreq || - Base.error("Frequency vector length does not match admittance samples") - comps = components_for(obj, mode, coord) - units = normalize_quantity_units(quantity_units) - freq_scale = frequency_scale(freq_unit) - raw_freq_axis = freq_vec .* freq_scale - freq_axis, freq_exp = autoscale_axis(raw_freq_axis) - xlabel_base = frequency_axis_label(freq_unit) - (isel, jsel) = resolve_conductors(size(obj.values), con) - specs = LineParametersPlotSpec[] - for meta in comps - q_prefix = resolve_quantity_prefix(meta.quantity, units) - y_scale = quantity_scale(q_prefix) - l_scale = meta.unit.per_length ? length_scale(length_unit) : 1.0 - ylabel_unit = - composite_unit(q_prefix, meta.unit.symbol, meta.unit.per_length, length_unit) - ylabel_base = string(meta.axis_label, " [", ylabel_unit, "]") - - raw_curves = Vector{Vector{<:Real}}() - labels = String[] - for i in isel, j in jsel - slice = @view obj.values[i, j, :] - raw_vals = component_values(meta.component, slice, freq_vec) - push!(raw_curves, (raw_vals .* y_scale .* l_scale)) - push!(labels, legend_label(meta.symbol, i, j)) - end - - curves, y_exp = autoscale_axis_stacked(raw_curves) - push!( - specs, - LineParametersPlotSpec( - parent_kind(obj), - meta.component, - meta.symbol, - meta.title, - xlabel_base, - ylabel_base, - freq_axis, - raw_freq_axis, - curves, - raw_curves, - labels, - freq_exp, - y_exp, - fig_size, - Ref{Function}(xscale), - Ref{Function}(yscale), - ), - ) - end - return specs -end - -function lineparameter_plot_specs( - lp::LineParameters; - mode::Symbol = :ZY, - coord::Symbol = :cart, - freq_unit::Symbol = :base, - length_unit::Symbol = :base, - quantity_units = nothing, - con = nothing, - fig_size::Union{Nothing, Tuple{Int, Int}} = LP_FIG_SIZE, - xscale::Function = Makie.identity, - yscale::Function = Makie.identity, -) - specs = LineParametersPlotSpec[] - append!( - specs, - lineparameter_plot_specs(lp.Z, lp.f; - mode = mode, - coord = coord, - freq_unit = freq_unit, - length_unit = length_unit, - quantity_units = quantity_units, - con = con, - fig_size = fig_size, - xscale = xscale, - yscale = yscale, - ), - ) - append!( - specs, - lineparameter_plot_specs(lp.Y, lp.f; - mode = mode, - coord = coord, - freq_unit = freq_unit, - length_unit = length_unit, - quantity_units = quantity_units, - con = con, - fig_size = fig_size, - xscale = xscale, - yscale = yscale, - ), - ) - return specs -end - -function render_plot_specs( - specs::Vector{LineParametersPlotSpec}; - backend = nothing, - display_plot::Bool = true, -) - assemblies = Dict{Tuple{Symbol, Symbol}, PlotAssembly}() - for spec in specs - assembly = _render_spec(spec; backend = backend, display_plot = display_plot) - assemblies[(spec.parent_kind, spec.component)] = assembly - end - return assemblies -end - -function plot( - obj::SeriesImpedance, - freqs::AbstractVector; - backend = nothing, - display_plot::Bool = true, - kwargs..., -) - specs = lineparameter_plot_specs(obj, freqs; kwargs...) - return render_plot_specs(specs; backend = backend, display_plot = display_plot) -end - -function plot( - obj::ShuntAdmittance, - freqs::AbstractVector; - backend = nothing, - display_plot::Bool = true, - kwargs..., -) - specs = lineparameter_plot_specs(obj, freqs; kwargs...) - return render_plot_specs(specs; backend = backend, display_plot = display_plot) -end - -function plot( - lp::LineParameters; - backend = nothing, - display_plot::Bool = true, - kwargs..., -) - specs = lineparameter_plot_specs(lp; kwargs...) - return render_plot_specs(specs; backend = backend, display_plot = display_plot) -end - -function build_export_figure(spec::LineParametersPlotSpec) - backend_ctx = _make_window( - BackendHandler, - :cairo; - icons = _ICON_FN, - icons_font = ICON_TTF, - interactive_override = false, - use_latex_fonts = true, - ) - pipeline_kwargs = - spec.fig_size === nothing ? - (; initial_status = "") : - (; fig_size = spec.fig_size, initial_status = "") - assembly = with_plot_theme(backend_ctx; mode = :export) do - _run_plot_pipeline( - backend_ctx, - (fig_ctx, ctx, axis) -> _build_plot!(fig_ctx, ctx, axis, spec); - pipeline_kwargs..., - ) - end - ensure_export_background!(assembly.figure) - return assembly.figure -end - -function build_export_figure( - obj, - key::Tuple{Symbol, Symbol}; - kwargs..., -) - specs = - obj isa LineParametersPlotSpec ? [obj] : lineparameter_plot_specs(obj; kwargs...) - idx = findfirst(s -> (s.parent_kind, s.component) == key, specs) - idx === nothing && Base.error("No plot specification found for key $(key)") - return build_export_figure(specs[idx]) -end - -function _render_spec( - spec::LineParametersPlotSpec; - backend = nothing, - display_plot::Bool = true, -) - n = next_fignum() - backend_ctx = _make_window( - BackendHandler, - backend; - title = "Fig. $(n) – $(spec.title)", - icons = _ICON_FN, - icons_font = ICON_TTF, - ) - pipeline_kwargs = - spec.fig_size === nothing ? - (; initial_status = " ") : - (; fig_size = spec.fig_size, initial_status = " ") - assembly = with_plot_theme(backend_ctx) do - _run_plot_pipeline( - backend_ctx, - (fig_ctx, ctx, axis) -> _build_plot!(fig_ctx, ctx, axis, spec); - pipeline_kwargs..., - ) - end - if display_plot - _display!(backend_ctx, assembly.figure; title = spec.title) - end - return assembly -end - -function _get_axis_data( - raw_data::Vector{<:Real}, - scaled_data::Vector{<:Real}, - scale_func::Function, -) - data = scale_func == Makie.log10 ? raw_data : scaled_data - values = float(Measurements.value.(data)) - errors = if eltype(data) <: Measurements.Measurement - float(Measurements.uncertainty.(data)) - else - nothing - end - return (; values, errors) -end - -function _get_axis_label(base_label::String, exponent::Int, scale_func::Function) - if scale_func == Makie.log10 - return base_label - else - return _axis_label(base_label, exponent) - end -end - -function _build_plot!(fig_ctx, ctx, axis, spec::LineParametersPlotSpec) - # ---- Axis title & initial labels ---------------------------------------- - axis.title = spec.title - axis.xlabel = _get_axis_label(spec.xlabel, spec.x_exp, spec.xscale[]) - axis.ylabel = _get_axis_label(spec.ylabel, spec.y_exp, spec.yscale[]) - - # ---- Helpers ------------------------------------------------------------ - sanitize_log!(v::AbstractVector, is_log::Bool) = - (is_log && !isempty(v)) ? (v[v .<= 0] .= NaN; v) : v - - _x_data_for(scale) = begin - xd = _get_axis_data(spec.raw_freqs, spec.freqs, scale) - sanitize_log!(xd.values, scale == Makie.log10) - xd - end - - _y_data_for(i::Int, scale) = begin - yd = _get_axis_data(spec.raw_curves[i], spec.curves[i], scale) - sanitize_log!(yd.values, scale == Makie.log10) - yd - end - - function _link_visibility!(plot_obj, controller) - # plot_obj is the Errorbars plot object. - # controller is the master Lines plot. - # React to the controller's visibility changes. - on(controller.visible) do is_visible - # A. Manually control the visibility of the stem plot directly. - plot_obj.visible = is_visible - - # B. Manually control the special attribute for the whiskers. - plot_obj.whisker_visible[] = is_visible - end - nothing - end - - # safe max(abs(.)) ignoring non-finite - _finite_max_abs(v) = begin - buf = (x -> abs(x)).(value.(v)) - any(isfinite, buf) ? maximum(x for x in buf if isfinite(x)) : 0.0 - end - - # ---- Select active (non-noise) curves by EPS ------------------------------- - ncurves = length(spec.curves) - active_idx = Int[] - - @inbounds for i in 1:ncurves - # max magnitude of raw curve; works for Real, Complex, and Measurement types - maxmag = maximum(value.(abs.(spec.raw_curves[i]))) - if maxmag > eps(Float64) # keep only if anything rises above machine eps - push!(active_idx, i) - end - end - - any_real_curve = !isempty(active_idx) - - # ---- Initial data (x) --------------------------------------------------- - x_init = _x_data_for(spec.xscale[]) - x_vals_obs = Observable(copy(x_init.values)) - x_errs_obs = x_init.errors === nothing ? nothing : Observable(copy(x_init.errors)) - - # ---- Per-curve allocs only for active curves --------------------------- - palette = Makie.wong_colors() - ncolors = length(palette) - nact = length(active_idx) - - y_vals_obs = Vector{Observable}(undef, nact) - y_errs_obs = Vector{Union{Nothing, Observable}}(undef, nact) - line_plots = Vector{Any}(undef, nact) - yerr_plots = Vector{Any}(undef, nact) - xerr_plots = Vector{Any}(undef, nact) - - # ---- Draw active curves ------------------------------------------------- - for k in 1:nact - i = active_idx[k] - color = palette[mod1(k, ncolors)] # color by active order - label = spec.labels[i] - - yd = _y_data_for(i, spec.yscale[]) - - y_vals_obs[k] = Observable(copy(yd.values)) - y_errs_obs[k] = yd.errors === nothing ? nothing : Observable(copy(yd.errors)) - - # line - ln = lines!( - axis, - x_vals_obs, - y_vals_obs[k]; - color = color, - label = label, - linewidth = 2, - ) - line_plots[k] = ln - - # Y errorbars: stems + caps; fully follow the line’s visibility - if y_errs_obs[k] !== nothing - eb = errorbars!( - axis, x_vals_obs, y_vals_obs[k], y_errs_obs[k]; - color = :black, direction = :y, whiskerwidth = 3, linewidth = 1, - ) - _link_visibility!(eb, ln) - yerr_plots[k] = eb - else - yerr_plots[k] = nothing - end - - # X errorbars: stems + caps; fully follow the line’s visibility - if x_errs_obs !== nothing - ebx = errorbars!( - axis, x_vals_obs, y_vals_obs[k], x_errs_obs; - color = :black, direction = :x, whiskerwidth = 3, linewidth = 1, - ) - _link_visibility!(ebx, ln) - xerr_plots[k] = ebx - else - xerr_plots[k] = nothing - end - - end - - # If nothing to draw, add transparent dummy without legend entry - if !any_real_curve - lines!(axis, [NaN], [NaN]; color = :transparent, label = "No data") - end - - # ---- Apply initial scales safely --------------------------------------- - try - axis.xscale[] = spec.xscale[] - axis.yscale[] = spec.yscale[] - catch - axis.xscale[] = Makie.identity - axis.yscale[] = Makie.identity - @warn "Failed to set axis scale; reverted to linear scale." - end - - # Enforce reasonable limits (avoid microscopic ranges when curves are flat) - # Helper to compute finite extents - _finite_extents(v::AbstractVector) = begin - fv = filter(isfinite, v) - isempty(fv) && return (NaN, NaN, false) - return (minimum(fv), maximum(fv), true) - end - - function _apply_limits!() - # Helper: smallest positive finite value in a vector - _min_positive(v::AbstractVector) = begin - m = Inf - @inbounds for a in v - if isfinite(a) && a > 0 && a < m - m = a - end - end - return m - end - - # X limits - x = x_vals_obs[] - xmin, xmax, okx = _finite_extents(x) - if okx - Δx = xmax - xmin - if Δx <= 0 - xc = (xmax + xmin) / 2 - # minimal span based on magnitude - Δx = max(1e-12, 1e-3 * max(abs(xc), abs(xmax), abs(xmin), 1.0)) - xmin = xc - Δx / 2 - xmax = xc + Δx / 2 - else - pad = 0.05 * Δx - xmin -= pad; - xmax += pad - end - # Guard for log x-axis: lower bound must stay > 0 - if axis.xscale[] == Makie.log10 - posmin = _min_positive(x) - floor_pos = isfinite(posmin) ? 0.9 * posmin : nextfloat(0.0) - xmin = max(xmin, floor_pos) - xmin <= 0 && (xmin = nextfloat(0.0)) # absolute safety - end - Makie.xlims!(axis, xmin, xmax) - end - - # Y limits (consider error bars too) - ymins = Float64[] - ymaxs = Float64[] - @inbounds for k in 1:nact - y = y_vals_obs[k][] - ymin, ymax, ok = _finite_extents(y) - if ok - if y_errs_obs[k] !== nothing - e = y_errs_obs[k][] - eymin, _, okm = _finite_extents(y .- e) - _, eymax, okp = _finite_extents(y .+ e) - okm && (ymin = min(ymin, eymin)) - okp && (ymax = max(ymax, eymax)) - end - push!(ymins, ymin); - push!(ymaxs, ymax) - end - end - - if !isempty(ymins) - ymin = minimum(ymins) - ymax = maximum(ymaxs) - Δy = ymax - ymin - yc = (ymax + ymin) / 2 - - # Minimal span to avoid "micro-zoom" when the curve is essentially flat. - # - relative floor: 0.1% of magnitude (>= 1.0 to avoid collapsing near zero) - # - absolute floor: 1e-12 - min_span = max(1e-12, 1e-3 * max(abs(yc), abs(ymax), abs(ymin), 1.0)) - - if !(Δy > min_span) - Δy = min_span - ymin = yc - Δy / 2 - ymax = yc + Δy / 2 - else - pad = 0.05 * Δy - ymin -= pad; - ymax += pad - end - - # Guard for log y-axis: lower bound must stay > 0 - if axis.yscale[] == Makie.log10 - # find smallest positive among all active curves (and their lower error bars) - posmin = Inf - @inbounds for k in 1:nact - y = y_vals_obs[k][] - m = _min_positive(y) - if isfinite(m) && m < posmin - posmin = m - end - if y_errs_obs[k] !== nothing - e = y_errs_obs[k][] - # consider lower whiskers - @inbounds for (yy, ee) in zip(y, e) - l = yy - ee - if isfinite(l) && l > 0 && l < posmin - posmin = l - end - end - end - end - floor_pos = isfinite(posmin) ? 0.9 * posmin : nextfloat(0.0) - ymin = max(ymin, floor_pos) - ymin <= 0 && (ymin = nextfloat(0.0)) # absolute safety - end - - Makie.ylims!(axis, ymin, ymax) - end - return nothing - end - Makie.autolimits!(axis) - _apply_limits!() - - # ---- Refreshers (update Observables only) ------------------------------ - function _refresh_x!(scale) - Makie.autolimits!(axis) - spec.xscale[] = scale - axis.xscale[] = scale - axis.xlabel = _get_axis_label(spec.xlabel, spec.x_exp, scale) - - xd = _x_data_for(scale) - x_vals_obs[] = xd.values - if x_errs_obs !== nothing - x_errs_obs[] = xd.errors - end - - _apply_limits!() - nothing - end - - function _refresh_y!(scale) - Makie.autolimits!(axis) - spec.yscale[] = scale - axis.yscale[] = scale - axis.ylabel = _get_axis_label(spec.ylabel, spec.y_exp, scale) - - @inbounds for k in 1:nact - i = active_idx[k] - yd = _y_data_for(i, scale) - y_vals_obs[k][] = yd.values - if y_errs_obs[k] !== nothing - y_errs_obs[k][] = yd.errors - end - end - _apply_limits!() - nothing - end - - # ---- Buttons ------------------------------------------------------------ - buttons = - any_real_curve ? - [ - ControlButtonSpec( - (_ctx, _btn) -> (Makie.reset_limits!(axis); nothing); - icon = MI_REFRESH, - on_success = ControlReaction(status_string = "Axis limits reset"), - ), - ControlButtonSpec( - (_ctx, _btn) -> _save_plot_export(spec, axis); - icon = MI_SAVE, - on_success = ControlReaction( - status_string = path -> string("Saved SVG to ", basename(path)), - ), - ), - ] : Any[] - - # ---- Toggles ------------------------------------------------------------ - toggles = - any_real_curve ? - [ - ControlToggleSpec( - (_ctx, _t) -> _refresh_x!(Makie.log10), - (_ctx, _t) -> _refresh_x!(Makie.identity); - label = "log x-axis", - start_active = spec.xscale[] == Makie.log10, - on_success_on = ControlReaction(status_string = "x-axis scale set to log"), - on_success_off = ControlReaction( - status_string = "x-axis scale set to linear", - ), - on_failure = ControlReaction(status_string = err -> err), - ), - ControlToggleSpec( - (_ctx, _t) -> _refresh_y!(Makie.log10), - (_ctx, _t) -> _refresh_y!(Makie.identity); - label = "log y-axis", - start_active = spec.yscale[] == Makie.log10, - on_success_on = ControlReaction(status_string = "y-axis scale set to log"), - on_success_off = ControlReaction( - status_string = "y-axis scale set to linear", - ), - on_failure = ControlReaction(status_string = err -> err), - ), - ] : Any[] - - # ---- Legend ------------------------------------------------------------- - legend_builder = - parent -> - Makie.Legend( - parent, - axis; - orientation = :vertical, - ) - - return PlotBuildArtifacts( - axis = axis, - legends = legend_builder, - colorbars = Any[], - control_buttons = buttons, - control_toggles = toggles, - status_message = nothing, - ) -end - - - -function _display!(backend_ctx, fig::Makie.Figure; title::AbstractString = "") - if backend_ctx.interactive && backend_ctx.window !== nothing - display(backend_ctx.window, fig) - if !isempty(title) && hasproperty(backend_ctx.window, :title) - backend_ctx.window.title[] = title - end - else - BackendHandler.renderfig(fig) - end - return nothing -end + +using Makie +import Makie: plot + +import ..BackendHandler: BackendHandler, next_fignum +using Base: basename, mod1 +using Dates: format, now + +using ..PlotUIComponents: + PlotAssembly, + PlotBuildArtifacts, + ControlButtonSpec, + ControlToggleSpec, + ControlReaction, + _make_window, + _run_plot_pipeline, + with_plot_theme, + ensure_export_background!, + with_icon, + MI_REFRESH, + MI_SAVE, + ICON_TTF, + AXIS_LABEL_FONT_SIZE, + clear_status! + +using Measurements: Measurements + +const _ICON_FN = + (icon; text = nothing, kwargs...) -> + with_icon(icon; text = text === nothing ? "" : text, kwargs...) + +const LP_FIG_SIZE = (800, 400) + +const METRIC_PREFIX_EXPONENT = Dict( + :yocto => -24, + :zepto => -21, + :atto => -18, + :femto => -15, + :pico => -12, + :nano => -9, + :micro => -6, + :milli => -3, + :centi => -2, + :deci => -1, + :base => 0, + :deca => 1, + :hecto => 2, + :kilo => 3, + :mega => 6, + :giga => 9, + :tera => 12, + :peta => 15, + :exa => 18, + :zetta => 21, + :yotta => 24, +) + +const METRIC_PREFIX_SYMBOL = Dict( + :yocto => "y", + :zepto => "z", + :atto => "a", + :femto => "f", + :pico => "p", + :nano => "n", + :micro => "μ", + :milli => "m", + :centi => "c", + :deci => "d", + :base => "", + :deca => "da", + :hecto => "h", + :kilo => "k", + :mega => "M", + :giga => "G", + :tera => "T", + :peta => "P", + :exa => "E", + :zetta => "Z", + :yotta => "Y", +) + +const DEFAULT_QUANTITY_UNITS = Dict( + :impedance => :base, + :admittance => :base, + :resistance => :base, + :inductance => :milli, + :conductance => :base, + :capacitance => :micro, + :angle => :base, +) + +struct UnitSpec + symbol::String + per_length::Bool +end + +struct ComponentMetadata + component::Symbol + quantity::Symbol + symbol::String + title::String + axis_label::String + unit::UnitSpec +end + +struct LineParametersPlotSpec + parent_kind::Symbol + component::Symbol + symbol::String + title::String + xlabel::String + ylabel::String + freqs::Vector{<:Real} + raw_freqs::Vector{<:Real} + curves::Vector{Vector{<:Real}} + raw_curves::Vector{Vector{<:Real}} + labels::Vector{String} + x_exp::Int + y_exp::Int + fig_size::Union{Nothing, Tuple{Int, Int}} + xscale::Base.RefValue{Function} + yscale::Base.RefValue{Function} +end + +const EXPORT_TIMESTAMP_FORMAT = "yyyymmdd_HHMMSS" +const EXPORT_EXTENSION = "svg" + +function _sanitize_filename_component(str::AbstractString) + sanitized = lowercase(strip(str)) + sanitized = replace(sanitized, r"[^0-9a-z]+" => "_") + sanitized = strip(sanitized, '_') + return isempty(sanitized) ? "lineparameters_plot" : sanitized +end + +function _default_export_path( + spec::LineParametersPlotSpec; + extension::AbstractString = EXPORT_EXTENSION, +) + base_title = strip(spec.title) + base = isempty(base_title) ? string(spec.parent_kind, "_", spec.component) : base_title + name = _sanitize_filename_component(base) + timestamp = format(now(), EXPORT_TIMESTAMP_FORMAT) + filename = string(name, "_", timestamp, ".", extension) + return joinpath(pwd(), filename) +end + +function _save_plot_export(spec::LineParametersPlotSpec, axis) + # Capture current axis scales before building the export figure + spec.xscale[] = axis.xscale[] + spec.yscale[] = axis.yscale[] + fig = build_export_figure(spec) + trim!(fig.layout) + path = _default_export_path(spec) + Makie.save(path, fig) + return path +end + +get_description(::SeriesImpedance) = ( + impedance = "Series impedance", + resistance = "Series resistance", + inductance = "Series inductance", +) + +get_symbol(::SeriesImpedance) = ( + impedance = "Z", + resistance = "R", + inductance = "L", +) + +get_unit_symbol(::SeriesImpedance) = ( + impedance = "Ω", + resistance = "Ω", + inductance = "H", +) + +get_description(::ShuntAdmittance) = ( + admittance = "Shunt admittance", + conductance = "Shunt conductance", + capacitance = "Shunt capacitance", +) + +get_symbol(::ShuntAdmittance) = ( + admittance = "Y", + conductance = "G", + capacitance = "C", +) + +get_unit_symbol(::ShuntAdmittance) = ( + admittance = "S", + conductance = "S", + capacitance = "F", +) + +parent_kind(::SeriesImpedance) = :series_impedance +parent_kind(::ShuntAdmittance) = :shunt_admittance + +metric_exponent(prefix::Symbol) = + get(METRIC_PREFIX_EXPONENT, prefix) do + Base.error("Unsupported metric prefix :$(prefix)") + end + +prefix_symbol(prefix::Symbol) = + get(METRIC_PREFIX_SYMBOL, prefix) do + Base.error("Unsupported metric prefix :$(prefix)") + end + + +quantity_scale(prefix::Symbol) = 10.0 ^ (-metric_exponent(prefix)) +length_scale(prefix::Symbol) = 10.0 ^ (metric_exponent(prefix)) +frequency_scale(prefix::Symbol) = quantity_scale(prefix) + +function unit_text(quantity_prefix::Symbol, base_unit::String) + ps = prefix_symbol(quantity_prefix) + return isempty(ps) ? base_unit : string(ps, base_unit) +end + +function length_unit_text(prefix::Symbol) + ps = prefix_symbol(prefix) + return isempty(ps) ? "m" : string(ps, "m") +end + +function composite_unit( + quantity_prefix::Symbol, + base_unit::String, + per_length::Bool, + length_prefix::Symbol, +) + numerator = unit_text(quantity_prefix, base_unit) + if per_length + denominator = length_unit_text(length_prefix) + return string(numerator, "/", denominator) + else + return numerator + end +end + +function frequency_axis_label(prefix::Symbol) + unit = unit_text(prefix, "Hz") + return string("frequency [", unit, "]") +end + +function normalize_quantity_units(units) + table = Dict(DEFAULT_QUANTITY_UNITS) + if units isa Symbol + for key in keys(table) + table[key] = units + end + elseif units isa NamedTuple + for (key, val) in pairs(units) + table[key] = val + end + elseif units isa AbstractDict + for (key, val) in units + table[key] = val + end + elseif units === nothing + return table + else + Base.error("Unsupported quantity unit specification $(typeof(units))") + end + return table +end + +function resolve_quantity_prefix(quantity::Symbol, units::AbstractDict{Symbol, Symbol}) + return get(units, quantity, get(DEFAULT_QUANTITY_UNITS, quantity, :base)) +end + +function resolve_conductors(data_dims::NTuple{3, Int}, con) + nrows, ncols, _ = data_dims + if con === nothing + return collect(1:nrows), collect(1:ncols) + elseif con isa Tuple && length(con) == 2 + isel = collect_indices(con[1], nrows) + jsel = collect_indices(con[2], ncols) + return isel, jsel + else + Base.error("Conductor selector must be a tuple (i_sel, j_sel)") + end +end + +function collect_indices(sel, n) + if sel === nothing + return collect(1:n) + elseif sel isa Integer + (1 <= sel <= n) || + Base.error("Index $(sel) out of bounds for dimension of size $(n)") + return [sel] + elseif sel isa AbstractVector + indices = collect(Int, sel) + for idx in indices + (1 <= idx <= n) || + Base.error("Index $(idx) out of bounds for dimension of size $(n)") + end + return indices + elseif sel isa AbstractRange + indices = collect(sel) + for idx in indices + (1 <= idx <= n) || + Base.error("Index $(idx) out of bounds for dimension of size $(n)") + end + return indices + elseif sel isa Colon + return collect(1:n) + else + Base.error("Unsupported selector $(sel)") + end +end + +function components_for(obj::SeriesImpedance, mode::Symbol, coord::Symbol) + desc = get_description(obj) + sym = get_symbol(obj) + units = get_unit_symbol(obj) + if mode == :ZY + coord in (:cart, :polar) || Base.error("Unsupported coordinate system $(coord)") + if coord == :cart + return ComponentMetadata[ + ComponentMetadata(:real, :impedance, sym.impedance, + string(desc.impedance, " – real part"), + string("real(", sym.impedance, ")"), + UnitSpec(units.impedance, true)), + ComponentMetadata(:imag, :impedance, sym.impedance, + string(desc.impedance, " – imaginary part"), + string("imag(", sym.impedance, ")"), + UnitSpec(units.impedance, true)), + ] + else + return ComponentMetadata[ + ComponentMetadata(:magnitude, :impedance, sym.impedance, + string(desc.impedance, " – magnitude"), + string("|", sym.impedance, "|"), + UnitSpec(units.impedance, true)), + ComponentMetadata(:angle, :angle, sym.impedance, + string(desc.impedance, " – angle"), + string("angle(", sym.impedance, ")"), + UnitSpec("deg", false)), + ] + end + elseif mode == :RLCG + return ComponentMetadata[ + ComponentMetadata(:resistance, :resistance, sym.resistance, + desc.resistance, + sym.resistance, + UnitSpec(units.resistance, true)), + ComponentMetadata(:inductance, :inductance, sym.inductance, + desc.inductance, + sym.inductance, + UnitSpec(units.inductance, true)), + ] + else + Base.error("Unsupported mode $(mode)") + end +end + +function components_for(obj::ShuntAdmittance, mode::Symbol, coord::Symbol) + desc = get_description(obj) + sym = get_symbol(obj) + units = get_unit_symbol(obj) + if mode == :ZY + coord in (:cart, :polar) || Base.error("Unsupported coordinate system $(coord)") + if coord == :cart + return ComponentMetadata[ + ComponentMetadata(:real, :admittance, sym.admittance, + string(desc.admittance, " – real part"), + string("real(", sym.admittance, ")"), + UnitSpec(units.admittance, true)), + ComponentMetadata(:imag, :admittance, sym.admittance, + string(desc.admittance, " – imaginary part"), + string("imag(", sym.admittance, ")"), + UnitSpec(units.admittance, true)), + ] + else + return ComponentMetadata[ + ComponentMetadata(:magnitude, :admittance, sym.admittance, + string(desc.admittance, " – magnitude"), + string("|", sym.admittance, "|"), + UnitSpec(units.admittance, true)), + ComponentMetadata(:angle, :angle, sym.admittance, + string(desc.admittance, " – angle"), + string("angle(", sym.admittance, ")"), + UnitSpec("deg", false)), + ] + end + elseif mode == :RLCG + if (coord == :cart || coord == :polar) + @warn "Ignoring argument :$(coord) for RLCG parameters" + end + return ComponentMetadata[ + ComponentMetadata(:conductance, :conductance, sym.conductance, + desc.conductance, + sym.conductance, + UnitSpec(units.conductance, true)), + ComponentMetadata(:capacitance, :capacitance, sym.capacitance, + desc.capacitance, + sym.capacitance, + UnitSpec(units.capacitance, true)), + ] + else + Base.error("Unsupported mode $(mode)") + end +end + +function component_values(component::Symbol, slice, freqs::Vector{<:Real}) + data = collect(slice) + if component === :real + return (real.(data)) + elseif component === :imag + return (imag.(data)) + elseif component === :magnitude + return (abs.(data)) + elseif component === :angle + return rad2deg.((angle.(data))) + elseif component === :resistance || component === :conductance + return (real.(data)) + elseif component === :inductance + imag_part = (imag.(data)) + return reactance_to_l(imag_part, freqs) + elseif component === :capacitance + imag_part = (imag.(data)) + return reactance_to_c(imag_part, freqs) + else + Base.error("Unsupported component $(component)") + end +end + +function reactance_to_l(imag_part::Vector{<:Real}, freqs::Vector{<:Real}) + result = similar(freqs, promote_type(eltype(imag_part), eltype(freqs))) + two_pi = 2π + for idx in eachindex(freqs) + f = freqs[idx] + if iszero(f) + result[idx] = NaN + else + result[idx] = imag_part[idx] / (two_pi * f) + end + end + return result +end + +function reactance_to_c(imag_part::Vector{<:Real}, freqs::Vector{<:Real}) + result = similar(freqs, promote_type(eltype(imag_part), eltype(freqs))) + two_pi = 2π + for idx in eachindex(freqs) + f = freqs[idx] + if iszero(f) + result[idx] = NaN + else + result[idx] = imag_part[idx] / (two_pi * f) + end + end + return result +end + +function legend_label(symbol::String, i::Int, j::Int) + return string(symbol, "(", i, ",", j, ")") +end + +function _axis_label(base::AbstractString, exp::Int) + exp == 0 && return base + return Makie.rich( + base, + Makie.rich(" × 10"; font = :regular, fontsize = AXIS_LABEL_FONT_SIZE), + Makie.rich( + superscript(string(exp)); + font = :regular, + fontsize = AXIS_LABEL_FONT_SIZE - 2, + # baseline_shift = 0.6, + ), + ) +end + +# Return scaled data and the exponent factored out for the axis badge. +function autoscale_axis(values::AbstractVector{<:Real}; _threshold = 1e4) + isempty(values) && return values, 0 + maxval = 0.0 + has_value = false + for val in values + if isnan(val) + continue + end + absval = abs(val) + if !has_value || absval > maxval + maxval = absval + has_value = true + end + end + !has_value && return values, 0 + exp = floor(Int, log10(maxval)) + abs(exp) < 3 && return values, 0 + scale = 10.0 ^ exp + # return values ./ scale, exp + return values ./ scale, exp +end + +function autoscale_axis_stacked( + curves::AbstractVector{<:AbstractVector{<:Real}}; + _threshold = 1e4, +) + isempty(curves) && return curves, 0 + maxval = 0.0 + has_value = false + for curve in curves + for val in curve + if isnan(val) + continue + end + absval = abs(val) + if !has_value || absval > maxval + maxval = absval + has_value = true + end + end + end + !has_value && return curves, 0 + exp = floor(Int, log10(maxval)) + abs(exp) < 3 && return curves, 0 + scale = 10.0 ^ exp + scaled_curves = [curve ./ scale for curve in curves] + return scaled_curves, exp +end + +function lineparameter_plot_specs( + obj::SeriesImpedance, + freqs::AbstractVector; + mode::Symbol = :ZY, + coord::Symbol = :cart, + freq_unit::Symbol = :base, + length_unit::Symbol = :base, + quantity_units = nothing, + con = nothing, + fig_size::Union{Nothing, Tuple{Int, Int}} = LP_FIG_SIZE, + xscale::Function = Makie.identity, + yscale::Function = Makie.identity, +) + freq_vec = collect(freqs) + nfreq = length(freq_vec) + if nfreq <= 1 + @warn "Frequency vector has $(nfreq) sample(s); nothing to plot." + return LineParametersPlotSpec[] + end + size(obj.values, 3) == nfreq || + Base.error("Frequency vector length does not match impedance samples") + comps = components_for(obj, mode, coord) + units = normalize_quantity_units(quantity_units) + freq_scale = frequency_scale(freq_unit) + raw_freq_axis = freq_vec .* freq_scale + freq_axis, freq_exp = autoscale_axis(raw_freq_axis) + xlabel_base = frequency_axis_label(freq_unit) + (isel, jsel) = resolve_conductors(size(obj.values), con) + specs = LineParametersPlotSpec[] + for meta in comps + q_prefix = resolve_quantity_prefix(meta.quantity, units) + y_scale = quantity_scale(q_prefix) + l_scale = meta.unit.per_length ? length_scale(length_unit) : 1.0 + ylabel_unit = + composite_unit(q_prefix, meta.unit.symbol, meta.unit.per_length, length_unit) + ylabel_base = string(meta.axis_label, " [", ylabel_unit, "]") + + # collect raw curves and labels + raw_curves = Vector{Vector{<:Real}}() + labels = String[] + for i in isel, j in jsel + slice = @view obj.values[i, j, :] + raw_vals = component_values(meta.component, slice, freq_vec) + push!(raw_curves, (raw_vals .* y_scale .* l_scale)) + push!(labels, legend_label(meta.symbol, i, j)) + end + curves, y_exp = autoscale_axis_stacked(raw_curves) + push!( + specs, + LineParametersPlotSpec( + parent_kind(obj), + meta.component, + meta.symbol, + meta.title, + xlabel_base, + ylabel_base, + freq_axis, + raw_freq_axis, + curves, + raw_curves, + labels, + freq_exp, + y_exp, + fig_size, + Ref{Function}(xscale), + Ref{Function}(yscale), + ), + ) + end + return specs +end + +function lineparameter_plot_specs( + obj::ShuntAdmittance, + freqs::AbstractVector; + mode::Symbol = :ZY, + coord::Symbol = :cart, + freq_unit::Symbol = :base, + length_unit::Symbol = :base, + quantity_units = nothing, + con = nothing, + fig_size::Union{Nothing, Tuple{Int, Int}} = LP_FIG_SIZE, + xscale::Function = Makie.identity, + yscale::Function = Makie.identity, +) + freq_vec = collect(freqs) + nfreq = length(freq_vec) + if nfreq <= 1 + @warn "Frequency vector has $(nfreq) sample(s); nothing to plot." + return LineParametersPlotSpec[] + end + size(obj.values, 3) == nfreq || + Base.error("Frequency vector length does not match admittance samples") + comps = components_for(obj, mode, coord) + units = normalize_quantity_units(quantity_units) + freq_scale = frequency_scale(freq_unit) + raw_freq_axis = freq_vec .* freq_scale + freq_axis, freq_exp = autoscale_axis(raw_freq_axis) + xlabel_base = frequency_axis_label(freq_unit) + (isel, jsel) = resolve_conductors(size(obj.values), con) + specs = LineParametersPlotSpec[] + for meta in comps + q_prefix = resolve_quantity_prefix(meta.quantity, units) + y_scale = quantity_scale(q_prefix) + l_scale = meta.unit.per_length ? length_scale(length_unit) : 1.0 + ylabel_unit = + composite_unit(q_prefix, meta.unit.symbol, meta.unit.per_length, length_unit) + ylabel_base = string(meta.axis_label, " [", ylabel_unit, "]") + + raw_curves = Vector{Vector{<:Real}}() + labels = String[] + for i in isel, j in jsel + slice = @view obj.values[i, j, :] + raw_vals = component_values(meta.component, slice, freq_vec) + push!(raw_curves, (raw_vals .* y_scale .* l_scale)) + push!(labels, legend_label(meta.symbol, i, j)) + end + + curves, y_exp = autoscale_axis_stacked(raw_curves) + push!( + specs, + LineParametersPlotSpec( + parent_kind(obj), + meta.component, + meta.symbol, + meta.title, + xlabel_base, + ylabel_base, + freq_axis, + raw_freq_axis, + curves, + raw_curves, + labels, + freq_exp, + y_exp, + fig_size, + Ref{Function}(xscale), + Ref{Function}(yscale), + ), + ) + end + return specs +end + +function lineparameter_plot_specs( + lp::LineParameters; + mode::Symbol = :ZY, + coord::Symbol = :cart, + freq_unit::Symbol = :base, + length_unit::Symbol = :base, + quantity_units = nothing, + con = nothing, + fig_size::Union{Nothing, Tuple{Int, Int}} = LP_FIG_SIZE, + xscale::Function = Makie.identity, + yscale::Function = Makie.identity, +) + specs = LineParametersPlotSpec[] + append!( + specs, + lineparameter_plot_specs(lp.Z, lp.f; + mode = mode, + coord = coord, + freq_unit = freq_unit, + length_unit = length_unit, + quantity_units = quantity_units, + con = con, + fig_size = fig_size, + xscale = xscale, + yscale = yscale, + ), + ) + append!( + specs, + lineparameter_plot_specs(lp.Y, lp.f; + mode = mode, + coord = coord, + freq_unit = freq_unit, + length_unit = length_unit, + quantity_units = quantity_units, + con = con, + fig_size = fig_size, + xscale = xscale, + yscale = yscale, + ), + ) + return specs +end + +function render_plot_specs( + specs::Vector{LineParametersPlotSpec}; + backend = nothing, + display_plot::Bool = true, +) + assemblies = Dict{Tuple{Symbol, Symbol}, PlotAssembly}() + for spec in specs + assembly = _render_spec(spec; backend = backend, display_plot = display_plot) + assemblies[(spec.parent_kind, spec.component)] = assembly + end + return assemblies +end + +function plot( + obj::SeriesImpedance, + freqs::AbstractVector; + backend = nothing, + display_plot::Bool = true, + kwargs..., +) + specs = lineparameter_plot_specs(obj, freqs; kwargs...) + return render_plot_specs(specs; backend = backend, display_plot = display_plot) +end + +function plot( + obj::ShuntAdmittance, + freqs::AbstractVector; + backend = nothing, + display_plot::Bool = true, + kwargs..., +) + specs = lineparameter_plot_specs(obj, freqs; kwargs...) + return render_plot_specs(specs; backend = backend, display_plot = display_plot) +end + +function plot( + lp::LineParameters; + backend = nothing, + display_plot::Bool = true, + kwargs..., +) + specs = lineparameter_plot_specs(lp; kwargs...) + return render_plot_specs(specs; backend = backend, display_plot = display_plot) +end + +function build_export_figure(spec::LineParametersPlotSpec) + backend_ctx = _make_window( + BackendHandler, + :cairo; + icons = _ICON_FN, + icons_font = ICON_TTF, + interactive_override = false, + use_latex_fonts = true, + ) + pipeline_kwargs = + spec.fig_size === nothing ? + (; initial_status = "") : + (; fig_size = spec.fig_size, initial_status = "") + assembly = with_plot_theme(backend_ctx; mode = :export) do + _run_plot_pipeline( + backend_ctx, + (fig_ctx, ctx, axis) -> _build_plot!(fig_ctx, ctx, axis, spec); + pipeline_kwargs..., + ) + end + ensure_export_background!(assembly.figure) + return assembly.figure +end + +function build_export_figure( + obj, + key::Tuple{Symbol, Symbol}; + kwargs..., +) + specs = + obj isa LineParametersPlotSpec ? [obj] : lineparameter_plot_specs(obj; kwargs...) + idx = findfirst(s -> (s.parent_kind, s.component) == key, specs) + idx === nothing && Base.error("No plot specification found for key $(key)") + return build_export_figure(specs[idx]) +end + +function _render_spec( + spec::LineParametersPlotSpec; + backend = nothing, + display_plot::Bool = true, +) + n = next_fignum() + backend_ctx = _make_window( + BackendHandler, + backend; + title = "Fig. $(n) – $(spec.title)", + icons = _ICON_FN, + icons_font = ICON_TTF, + ) + pipeline_kwargs = + spec.fig_size === nothing ? + (; initial_status = " ") : + (; fig_size = spec.fig_size, initial_status = " ") + assembly = with_plot_theme(backend_ctx) do + _run_plot_pipeline( + backend_ctx, + (fig_ctx, ctx, axis) -> _build_plot!(fig_ctx, ctx, axis, spec); + pipeline_kwargs..., + ) + end + if display_plot + _display!(backend_ctx, assembly.figure; title = spec.title) + end + return assembly +end + +function _get_axis_data( + raw_data::Vector{<:Real}, + scaled_data::Vector{<:Real}, + scale_func::Function, +) + data = scale_func == Makie.log10 ? raw_data : scaled_data + values = float(Measurements.value.(data)) + errors = if eltype(data) <: Measurements.Measurement + float(Measurements.uncertainty.(data)) + else + nothing + end + return (; values, errors) +end + +function _get_axis_label(base_label::String, exponent::Int, scale_func::Function) + if scale_func == Makie.log10 + return base_label + else + return _axis_label(base_label, exponent) + end +end + +function _build_plot!(fig_ctx, ctx, axis, spec::LineParametersPlotSpec) + # ---- Axis title & initial labels ---------------------------------------- + axis.title = spec.title + axis.xlabel = _get_axis_label(spec.xlabel, spec.x_exp, spec.xscale[]) + axis.ylabel = _get_axis_label(spec.ylabel, spec.y_exp, spec.yscale[]) + + # ---- Helpers ------------------------------------------------------------ + sanitize_log!(v::AbstractVector, is_log::Bool) = + (is_log && !isempty(v)) ? (v[v .<= 0] .= NaN; v) : v + + _x_data_for(scale) = begin + xd = _get_axis_data(spec.raw_freqs, spec.freqs, scale) + sanitize_log!(xd.values, scale == Makie.log10) + xd + end + + _y_data_for(i::Int, scale) = begin + yd = _get_axis_data(spec.raw_curves[i], spec.curves[i], scale) + sanitize_log!(yd.values, scale == Makie.log10) + yd + end + + function _link_visibility!(plot_obj, controller) + # plot_obj is the Errorbars plot object. + # controller is the master Lines plot. + # React to the controller's visibility changes. + on(controller.visible) do is_visible + # A. Manually control the visibility of the stem plot directly. + plot_obj.visible = is_visible + + # B. Manually control the special attribute for the whiskers. + plot_obj.whisker_visible[] = is_visible + end + nothing + end + + # safe max(abs(.)) ignoring non-finite + _finite_max_abs(v) = begin + buf = (x -> abs(x)).(value.(v)) + any(isfinite, buf) ? maximum(x for x in buf if isfinite(x)) : 0.0 + end + + # ---- Select active (non-noise) curves by EPS ------------------------------- + ncurves = length(spec.curves) + active_idx = Int[] + + @inbounds for i in 1:ncurves + # max magnitude of raw curve; works for Real, Complex, and Measurement types + maxmag = maximum(value.(abs.(spec.raw_curves[i]))) + if maxmag > eps(Float64) # keep only if anything rises above machine eps + push!(active_idx, i) + end + end + + any_real_curve = !isempty(active_idx) + + # ---- Initial data (x) --------------------------------------------------- + x_init = _x_data_for(spec.xscale[]) + x_vals_obs = Observable(copy(x_init.values)) + x_errs_obs = x_init.errors === nothing ? nothing : Observable(copy(x_init.errors)) + + # ---- Per-curve allocs only for active curves --------------------------- + palette = Makie.wong_colors() + ncolors = length(palette) + nact = length(active_idx) + + y_vals_obs = Vector{Observable}(undef, nact) + y_errs_obs = Vector{Union{Nothing, Observable}}(undef, nact) + line_plots = Vector{Any}(undef, nact) + yerr_plots = Vector{Any}(undef, nact) + xerr_plots = Vector{Any}(undef, nact) + + # ---- Draw active curves ------------------------------------------------- + for k in 1:nact + i = active_idx[k] + color = palette[mod1(k, ncolors)] # color by active order + label = spec.labels[i] + + yd = _y_data_for(i, spec.yscale[]) + + y_vals_obs[k] = Observable(copy(yd.values)) + y_errs_obs[k] = yd.errors === nothing ? nothing : Observable(copy(yd.errors)) + + # line + ln = lines!( + axis, + x_vals_obs, + y_vals_obs[k]; + color = color, + label = label, + linewidth = 2, + ) + line_plots[k] = ln + + # Y errorbars: stems + caps; fully follow the line’s visibility + if y_errs_obs[k] !== nothing + eb = errorbars!( + axis, x_vals_obs, y_vals_obs[k], y_errs_obs[k]; + color = :black, direction = :y, whiskerwidth = 3, linewidth = 1, + ) + _link_visibility!(eb, ln) + yerr_plots[k] = eb + else + yerr_plots[k] = nothing + end + + # X errorbars: stems + caps; fully follow the line’s visibility + if x_errs_obs !== nothing + ebx = errorbars!( + axis, x_vals_obs, y_vals_obs[k], x_errs_obs; + color = :black, direction = :x, whiskerwidth = 3, linewidth = 1, + ) + _link_visibility!(ebx, ln) + xerr_plots[k] = ebx + else + xerr_plots[k] = nothing + end + + end + + # If nothing to draw, add transparent dummy without legend entry + if !any_real_curve + lines!(axis, [NaN], [NaN]; color = :transparent, label = "No data") + end + + # ---- Apply initial scales safely --------------------------------------- + try + axis.xscale[] = spec.xscale[] + axis.yscale[] = spec.yscale[] + catch + axis.xscale[] = Makie.identity + axis.yscale[] = Makie.identity + @warn "Failed to set axis scale; reverted to linear scale." + end + + # Enforce reasonable limits (avoid microscopic ranges when curves are flat) + # Helper to compute finite extents + _finite_extents(v::AbstractVector) = begin + fv = filter(isfinite, v) + isempty(fv) && return (NaN, NaN, false) + return (minimum(fv), maximum(fv), true) + end + + function _apply_limits!() + # Helper: smallest positive finite value in a vector + _min_positive(v::AbstractVector) = begin + m = Inf + @inbounds for a in v + if isfinite(a) && a > 0 && a < m + m = a + end + end + return m + end + + # X limits + x = x_vals_obs[] + xmin, xmax, okx = _finite_extents(x) + if okx + Δx = xmax - xmin + if Δx <= 0 + xc = (xmax + xmin) / 2 + # minimal span based on magnitude + Δx = max(1e-12, 1e-3 * max(abs(xc), abs(xmax), abs(xmin), 1.0)) + xmin = xc - Δx / 2 + xmax = xc + Δx / 2 + else + pad = 0.05 * Δx + xmin -= pad; + xmax += pad + end + # Guard for log x-axis: lower bound must stay > 0 + if axis.xscale[] == Makie.log10 + posmin = _min_positive(x) + floor_pos = isfinite(posmin) ? 0.9 * posmin : nextfloat(0.0) + xmin = max(xmin, floor_pos) + xmin <= 0 && (xmin = nextfloat(0.0)) # absolute safety + end + Makie.xlims!(axis, xmin, xmax) + end + + # Y limits (consider error bars too) + ymins = Float64[] + ymaxs = Float64[] + @inbounds for k in 1:nact + y = y_vals_obs[k][] + ymin, ymax, ok = _finite_extents(y) + if ok + if y_errs_obs[k] !== nothing + e = y_errs_obs[k][] + eymin, _, okm = _finite_extents(y .- e) + _, eymax, okp = _finite_extents(y .+ e) + okm && (ymin = min(ymin, eymin)) + okp && (ymax = max(ymax, eymax)) + end + push!(ymins, ymin); + push!(ymaxs, ymax) + end + end + + if !isempty(ymins) + ymin = minimum(ymins) + ymax = maximum(ymaxs) + Δy = ymax - ymin + yc = (ymax + ymin) / 2 + + # Minimal span to avoid "micro-zoom" when the curve is essentially flat. + # - relative floor: 0.1% of magnitude (>= 1.0 to avoid collapsing near zero) + # - absolute floor: 1e-12 + min_span = max(1e-12, 1e-3 * max(abs(yc), abs(ymax), abs(ymin), 1.0)) + + if !(Δy > min_span) + Δy = min_span + ymin = yc - Δy / 2 + ymax = yc + Δy / 2 + else + pad = 0.05 * Δy + ymin -= pad; + ymax += pad + end + + # Guard for log y-axis: lower bound must stay > 0 + if axis.yscale[] == Makie.log10 + # find smallest positive among all active curves (and their lower error bars) + posmin = Inf + @inbounds for k in 1:nact + y = y_vals_obs[k][] + m = _min_positive(y) + if isfinite(m) && m < posmin + posmin = m + end + if y_errs_obs[k] !== nothing + e = y_errs_obs[k][] + # consider lower whiskers + @inbounds for (yy, ee) in zip(y, e) + l = yy - ee + if isfinite(l) && l > 0 && l < posmin + posmin = l + end + end + end + end + floor_pos = isfinite(posmin) ? 0.9 * posmin : nextfloat(0.0) + ymin = max(ymin, floor_pos) + ymin <= 0 && (ymin = nextfloat(0.0)) # absolute safety + end + + Makie.ylims!(axis, ymin, ymax) + end + return nothing + end + Makie.autolimits!(axis) + _apply_limits!() + + # ---- Refreshers (update Observables only) ------------------------------ + function _refresh_x!(scale) + Makie.autolimits!(axis) + spec.xscale[] = scale + axis.xscale[] = scale + axis.xlabel = _get_axis_label(spec.xlabel, spec.x_exp, scale) + + xd = _x_data_for(scale) + x_vals_obs[] = xd.values + if x_errs_obs !== nothing + x_errs_obs[] = xd.errors + end + + _apply_limits!() + nothing + end + + function _refresh_y!(scale) + Makie.autolimits!(axis) + spec.yscale[] = scale + axis.yscale[] = scale + axis.ylabel = _get_axis_label(spec.ylabel, spec.y_exp, scale) + + @inbounds for k in 1:nact + i = active_idx[k] + yd = _y_data_for(i, scale) + y_vals_obs[k][] = yd.values + if y_errs_obs[k] !== nothing + y_errs_obs[k][] = yd.errors + end + end + _apply_limits!() + nothing + end + + # ---- Buttons ------------------------------------------------------------ + buttons = + any_real_curve ? + [ + ControlButtonSpec( + (_ctx, _btn) -> (Makie.reset_limits!(axis); nothing); + icon = MI_REFRESH, + on_success = ControlReaction(status_string = "Axis limits reset"), + ), + ControlButtonSpec( + (_ctx, _btn) -> _save_plot_export(spec, axis); + icon = MI_SAVE, + on_success = ControlReaction( + status_string = path -> string("Saved SVG to ", basename(path)), + ), + ), + ] : Any[] + + # ---- Toggles ------------------------------------------------------------ + toggles = + any_real_curve ? + [ + ControlToggleSpec( + (_ctx, _t) -> _refresh_x!(Makie.log10), + (_ctx, _t) -> _refresh_x!(Makie.identity); + label = "log x-axis", + start_active = spec.xscale[] == Makie.log10, + on_success_on = ControlReaction(status_string = "x-axis scale set to log"), + on_success_off = ControlReaction( + status_string = "x-axis scale set to linear", + ), + on_failure = ControlReaction(status_string = err -> err), + ), + ControlToggleSpec( + (_ctx, _t) -> _refresh_y!(Makie.log10), + (_ctx, _t) -> _refresh_y!(Makie.identity); + label = "log y-axis", + start_active = spec.yscale[] == Makie.log10, + on_success_on = ControlReaction(status_string = "y-axis scale set to log"), + on_success_off = ControlReaction( + status_string = "y-axis scale set to linear", + ), + on_failure = ControlReaction(status_string = err -> err), + ), + ] : Any[] + + # ---- Legend ------------------------------------------------------------- + legend_builder = + parent -> + Makie.Legend( + parent, + axis; + orientation = :vertical, + ) + + return PlotBuildArtifacts( + axis = axis, + legends = legend_builder, + colorbars = Any[], + control_buttons = buttons, + control_toggles = toggles, + status_message = nothing, + ) +end + + + +function _display!(backend_ctx, fig::Makie.Figure; title::AbstractString = "") + if backend_ctx.interactive && backend_ctx.window !== nothing + display(backend_ctx.window, fig) + if !isempty(title) && hasproperty(backend_ctx.window, :title) + backend_ctx.window.title[] = title + end + else + BackendHandler.renderfig(fig) + end + return nothing +end diff --git a/src/engine/problemdefs.jl b/src/engine/problemdefs.jl index f8522454..4ba3102f 100644 --- a/src/engine/problemdefs.jl +++ b/src/engine/problemdefs.jl @@ -1,264 +1,448 @@ - -""" -$(TYPEDEF) - -Represents a line parameters computation problem for a given physical cable system. - -$(TYPEDFIELDS) -""" -struct LineParametersProblem{T <: REALSCALAR} <: ProblemDefinition - "The physical cable system to analyze." - system::LineCableSystem{T} - "Operating temperature \\[°C\\]." - temperature::T - "Earth properties model." - earth_props::EarthModel{T} - "Frequencies at which to perform the analysis \\[Hz\\]." - frequencies::Vector{T} - - @doc """ - $(TYPEDSIGNATURES) - - Constructs a [`LineParametersProblem`](@ref) instance. - - # Arguments - - - `system`: The cable system to analyze ([`LineCableSystem`](@ref)). - - `temperature`: Operating temperature \\[°C\\]. Default: `T₀`. - - `earth_props`: Earth properties model ([`EarthModel`](@ref)). - - `frequencies`: Frequencies for analysis \\[Hz\\]. Default: [`f₀`](@ref). - - # Returns - - - A [`LineParametersProblem`](@ref) object with validated cable system, temperature, earth model, and frequency vector. - - # Examples - - ```julia - prob = $(FUNCTIONNAME)(system; temperature=25.0, earth_props=earth, frequencies=[50.0, 60.0, 100.0]) - ``` - """ - function LineParametersProblem( - system::LineCableSystem; - temperature::REALSCALAR = (T₀), - earth_props::EarthModel, - frequencies::Vector{<:Number} = [f₀], - ) - - # 1. System structure validation - @assert !isempty(system.cables) "LineCableSystem must contain at least one cable" - - # 2. Phase assignment validation - phase_numbers = unique(vcat([cable.conn for cable in system.cables]...)) - @assert !isempty(filter(x -> x > 0, phase_numbers)) "At least one conductor must be assigned to a phase (>0)" - @assert maximum(phase_numbers) <= system.num_phases "Invalid phase number detected" - - # 3. Cable components validation - for (i, cable) in enumerate(system.cables) - @assert !isempty(cable.design_data.components) "Cable $i has no components defined" - - # Validate conductor-insulator pairs - for (j, comp) in enumerate(cable.design_data.components) - @assert !isempty(comp.conductor_group.layers) "Component $j in cable $i has no conductor layers" - @assert !isempty(comp.insulator_group.layers) "Component $j in cable $i has no insulator layers" - - # Validate monotonic increase of radii - @assert comp.conductor_group.radius_ext > comp.conductor_group.radius_in "Component $j in cable $i: conductor outer radius must be larger than inner radius" - @assert comp.insulator_group.radius_ext > comp.insulator_group.radius_in "Component $j in cable $i: insulator outer radius must be larger than inner radius" - - # Validate geometric continuity between conductor and insulator - r_ext_cond = comp.conductor_group.radius_ext - r_in_ins = comp.insulator_group.radius_in - @assert abs(r_ext_cond - r_in_ins) < 1e-10 "Geometric mismatch in cable $i component $j: conductor outer radius ≠ insulator inner radius" - - # Validate electromagnetic properties - # Conductor properties - @assert comp.conductor_props.rho > 0 "Component $j in cable $i: conductor resistivity must be positive" - @assert comp.conductor_props.mu_r > 0 "Component $j in cable $i: conductor relative permeability must be positive" - @assert comp.conductor_props.eps_r >= 0 "Component $j in cable $i: conductor relative permittivity grater than or equal to zero" - - # Insulator properties - @assert comp.insulator_props.rho > 0 "Component $j in cable $i: insulator resistivity must be positive" - @assert comp.insulator_props.mu_r > 0 "Component $j in cable $i: insulator relative permeability must be positive" - @assert comp.insulator_props.eps_r > 0 "Component $j in cable $i: insulator relative permittivity must be positive" - end - end - - # 4. Temperature range validation - @assert abs(temperature - T₀) < ΔTmax """ -Temperature is outside the valid range for linear resistivity model: -T = $temperature -T₀ = $T₀ -ΔTmax = $ΔTmax -|T - T₀| = $(abs(temperature - T₀))""" - - # 5. Frequency range validation - @assert !isempty(frequencies) "Frequency vector cannot be empty" - @assert all(f -> f > 0, frequencies) "All frequencies must be positive" - @assert issorted(frequencies) "Frequency vector must be monotonically increasing" - if maximum(frequencies) > 1e8 - @warn "Frequencies above 100 MHz exceed quasi-TEM validity limit. High-frequency results should be interpreted with caution." maxfreq = - maximum(frequencies) - end - - # 6. Earth model validation - @assert length(earth_props.layers[end].rho_g) == length(frequencies) """Earth model frequencies must match analysis frequencies - Earth model frequencies = $(length(earth_props.layers[end].rho_g)) - Analysis frequencies = $(length(frequencies)) - """ - - # 7. Geometric validation - positions = [ - ( - cable.horz, - cable.vert, - maximum( - comp.insulator_group.radius_ext - for comp in cable.design_data.components - ), - ) - for cable in system.cables - ] - - for i in eachindex(positions) - for j in (i+1):lastindex(positions) - # Calculate center-to-center distance - dist = sqrt( - (positions[i][1] - positions[j][1])^2 + - (positions[i][2] - positions[j][2])^2, - ) - - # Get outermost radii for both cables - r_outer_i = positions[i][3] - r_outer_j = positions[j][3] - - # Check if cables overlap - min_allowed_dist = r_outer_i + r_outer_j - - @assert dist > min_allowed_dist """ - Cables $i and $j overlap! - Center-to-center distance: $(dist) m - Minimum required distance: $(min_allowed_dist) m - Cable $i outer radius: $(r_outer_i) m - Cable $j outer radius: $(r_outer_j) m""" - end - end - - T = resolve_T(system, temperature, earth_props, frequencies) - return new{T}( - coerce_to_T(system, T), - coerce_to_T(temperature, T), - coerce_to_T(earth_props, T), - coerce_to_T(frequencies, T), - ) - end -end - -# @kwdef struct EMTOptions <: AbstractFormulationOptions -# "Skip user confirmation for overwriting results" -# force_overwrite::Bool = false -# "Reduce bundle conductors to equivalent single conductor" -# reduce_bundle::Bool = true -# "Eliminate grounded conductors from the system (Kron reduction)" -# kron_reduction::Bool = true -# "Enforce ideal transposition/snaking" -# ideal_transposition::Bool = true -# "Temperature correction" -# temperature_correction::Bool = true -# "Save path for output files" -# save_path::String = joinpath(".", "lineparams_output") -# "Verbosity level" -# verbosity::Int = 0 -# "Log file path" -# logfile::Union{String, Nothing} = nothing -# end - -# The one-line constructor to "promote" a NamedTuple -# EMTOptions(opts::NamedTuple) = EMTOptions(; opts...) - -""" -$(TYPEDEF) - -Represents the electromagnetic transient (EMT) formulation set for cable or line systems, containing all required impedance and admittance models for internal and earth effects. - -$(TYPEDFIELDS) -""" -struct EMTFormulation <: AbstractFormulationSet - "Internal impedance formulation." - internal_impedance::InternalImpedanceFormulation - "Insulation impedance formulation." - insulation_impedance::InsulationImpedanceFormulation - "Earth impedance formulation." - earth_impedance::EarthImpedanceFormulation - "Insulation admittance formulation." - insulation_admittance::InsulationAdmittanceFormulation - "Earth admittance formulation." - earth_admittance::EarthAdmittanceFormulation - "Modal transformation method." - modal_transform::AbstractTransformFormulation - "Equivalent homogeneous earth model (EHEM) formulation." - equivalent_earth::Union{AbstractEHEMFormulation, Nothing} - "Solver options for EMT-type computations." - options::EMTOptions - - @doc """ - $(TYPEDSIGNATURES) - - Constructs an [`EMTFormulation`](@ref) instance. - - # Arguments - - - `internal_impedance`: Internal impedance formulation. - - `insulation_impedance`: Insulation impedance formulation. - - `earth_impedance`: Earth impedance formulation. - - `insulation_admittance`: Insulation admittance formulation. - - `earth_admittance`: Earth admittance formulation. - - `modal_transform`: Modal transformation method. - - `equivalent_earth`: Equivalent homogeneous earth model (EHEM) formulation. - - `options`: Solver options for EMT-type computations. - - # Returns - - - An [`EMTFormulation`](@ref) object containing the specified methods. - - # Examples - - ```julia - emt = $(FUNCTIONNAME)(...) - ``` - """ - function EMTFormulation(; - internal_impedance::InternalImpedanceFormulation, - insulation_impedance::InsulationImpedanceFormulation, - earth_impedance::EarthImpedanceFormulation, - insulation_admittance::InsulationAdmittanceFormulation, - earth_admittance::EarthAdmittanceFormulation, - modal_transform::AbstractTransformFormulation, - equivalent_earth::Union{AbstractEHEMFormulation, Nothing}, - options::EMTOptions, - ) - return new( - internal_impedance, insulation_impedance, earth_impedance, - insulation_admittance, earth_admittance, modal_transform, equivalent_earth, - options, - ) - end -end - -function FormulationSet(::Val{:EMT}; - internal_impedance::InternalImpedanceFormulation = InternalImpedance.ScaledBessel(), - insulation_impedance::InsulationImpedanceFormulation = InsulationImpedance.Lossless(), - earth_impedance::EarthImpedanceFormulation = EarthImpedance.Papadopoulos(), - insulation_admittance::InsulationAdmittanceFormulation = InsulationAdmittance.Lossless(), - earth_admittance::EarthAdmittanceFormulation = EarthAdmittance.Papadopoulos(), - modal_transform::AbstractTransformFormulation = Transforms.Fortescue(), - equivalent_earth::Union{AbstractEHEMFormulation, Nothing} = nothing, - options = (;), -) - emt_opts = build_options(EMTOptions, options; strict = true) - return EMTFormulation(; internal_impedance, insulation_impedance, earth_impedance, - insulation_admittance, earth_admittance, modal_transform, equivalent_earth, - options = emt_opts, - ) -end - + +""" +$(TYPEDEF) + +Represents a line parameters computation problem for a given physical cable system. + +$(TYPEDFIELDS) +""" +struct LineParametersProblem{T <: REALSCALAR} <: ProblemDefinition + "The physical cable system to analyze." + system::LineCableSystem{T} + "Operating temperature \\[°C\\]." + temperature::T + "Earth properties model." + earth_props::EarthModel{T} + "Frequencies at which to perform the analysis \\[Hz\\]." + frequencies::Vector{T} + + @doc """ + $(TYPEDSIGNATURES) + + Constructs a [`LineParametersProblem`](@ref) instance. + + # Arguments + + - `system`: The cable system to analyze ([`LineCableSystem`](@ref)). + - `temperature`: Operating temperature \\[°C\\]. Default: `T₀`. + - `earth_props`: Earth properties model ([`EarthModel`](@ref)). + - `frequencies`: Frequencies for analysis \\[Hz\\]. Default: [`f₀`](@ref). + + # Returns + + - A [`LineParametersProblem`](@ref) object with validated cable system, temperature, earth model, and frequency vector. + + # Examples + + ```julia + prob = $(FUNCTIONNAME)(system; temperature=25.0, earth_props=earth, frequencies=[50.0, 60.0, 100.0]) + ``` + """ + function LineParametersProblem( + system::LineCableSystem; + temperature::REALSCALAR = (T₀), + earth_props::EarthModel, + frequencies::Vector{<:Number} = [f₀], + ) + + # 1. System structure validation + @assert !isempty(system.cables) "LineCableSystem must contain at least one cable" + + # 2. Phase assignment validation + phase_numbers = unique(vcat([cable.conn for cable in system.cables]...)) + @assert !isempty(filter(x -> x > 0, phase_numbers)) "At least one conductor must be assigned to a phase (>0)" + @assert maximum(phase_numbers) <= system.num_phases "Invalid phase number detected" + + # 3. Cable components validation + for (i, cable) in enumerate(system.cables) + @assert !isempty(cable.design_data.components) "Cable $i has no components defined" + + # Validate conductor-insulator pairs + for (j, comp) in enumerate(cable.design_data.components) + @assert !isempty(comp.conductor_group.layers) "Component $j in cable $i has no conductor layers" + @assert !isempty(comp.insulator_group.layers) "Component $j in cable $i has no insulator layers" + + # Validate monotonic increase of radii + @assert comp.conductor_group.radius_ext > comp.conductor_group.radius_in "Component $j in cable $i: conductor outer radius must be larger than inner radius" + @assert comp.insulator_group.radius_ext > comp.insulator_group.radius_in "Component $j in cable $i: insulator outer radius must be larger than inner radius" + + # Validate geometric continuity between conductor and insulator + r_ext_cond = comp.conductor_group.radius_ext + r_in_ins = comp.insulator_group.radius_in + @assert abs(r_ext_cond - r_in_ins) < 1e-10 "Geometric mismatch in cable $i component $j: conductor outer radius ≠ insulator inner radius" + + # Validate electromagnetic properties + # Conductor properties + @assert comp.conductor_props.rho > 0 "Component $j in cable $i: conductor resistivity must be positive" + @assert comp.conductor_props.mu_r > 0 "Component $j in cable $i: conductor relative permeability must be positive" + @assert comp.conductor_props.eps_r >= 0 "Component $j in cable $i: conductor relative permittivity grater than or equal to zero" + + # Insulator properties + @assert comp.insulator_props.rho > 0 "Component $j in cable $i: insulator resistivity must be positive" + @assert comp.insulator_props.mu_r > 0 "Component $j in cable $i: insulator relative permeability must be positive" + @assert comp.insulator_props.eps_r > 0 "Component $j in cable $i: insulator relative permittivity must be positive" + end + end + + # 4. Temperature range validation + @assert abs(temperature - T₀) < ΔTmax """ +Temperature is outside the valid range for linear resistivity model: +T = $temperature +T₀ = $T₀ +ΔTmax = $ΔTmax +|T - T₀| = $(abs(temperature - T₀))""" + + # 5. Frequency range validation + @assert !isempty(frequencies) "Frequency vector cannot be empty" + @assert all(f -> f > 0, frequencies) "All frequencies must be positive" + @assert issorted(frequencies) "Frequency vector must be monotonically increasing" + if maximum(frequencies) > 1e8 + @warn "Frequencies above 100 MHz exceed quasi-TEM validity limit. High-frequency results should be interpreted with caution." maxfreq = + maximum(frequencies) + end + + # 6. Earth model validation + @assert length(earth_props.layers[end].rho_g) == length(frequencies) """Earth model frequencies must match analysis frequencies + Earth model frequencies = $(length(earth_props.layers[end].rho_g)) + Analysis frequencies = $(length(frequencies)) + """ + + # 7. Geometric validation + positions = [ + ( + cable.horz, + cable.vert, + maximum( + comp.insulator_group.radius_ext + for comp in cable.design_data.components + ), + ) + for cable in system.cables + ] + + for i in eachindex(positions) + for j in (i+1):lastindex(positions) + # Calculate center-to-center distance + dist = sqrt( + (positions[i][1] - positions[j][1])^2 + + (positions[i][2] - positions[j][2])^2, + ) + + # Get outermost radii for both cables + r_outer_i = positions[i][3] + r_outer_j = positions[j][3] + + # Check if cables overlap + min_allowed_dist = r_outer_i + r_outer_j + + @assert dist > min_allowed_dist """ + Cables $i and $j overlap! + Center-to-center distance: $(dist) m + Minimum required distance: $(min_allowed_dist) m + Cable $i outer radius: $(r_outer_i) m + Cable $j outer radius: $(r_outer_j) m""" + end + end + + T = resolve_T(system, temperature, earth_props, frequencies) + return new{T}( + coerce_to_T(system, T), + coerce_to_T(temperature, T), + coerce_to_T(earth_props, T), + coerce_to_T(frequencies, T), + ) + end +end +""" +$(TYPEDEF) + +Represents an Magneto-thermal computation problem for a given physical cable system +and a specific energization. + +$(TYPEDFIELDS) +""" +struct AmpacityProblem{T <: REALSCALAR} <: ProblemDefinition + "The physical cable system to analyze." + system::LineCableSystem{T} + "Ambient operating temperature [°C]." + temperature::T + "Earth properties model." + earth_props::EarthModel{T} + "Vector of frequencies at which the analysis is performed [Hz]." + frequencies::Vector{T} + "Vector of energizing currents [A]. Index corresponds to phase number." + energizations::Vector{Complex{T}} + "Velocity of the ambient wind [m/s]." + wind_velocity::T + + @doc """ + $(TYPEDSIGNATURES) + + Constructs an [`AmpacityProblem`](@ref) instance. + + # Arguments + + - `system`: The cable system to analyze ([`LineCableSystem`](@ref)). + - `temperature`: Ambient temperature [°C]. Default: `T₀`. + - `earth_props`: Earth properties model ([`EarthModel`](@ref)). + - `frequencies`: Vector of frequency for analysis [Hz]. Default: [`f₀`](@ref). + - `energizations`: Vector of phase currents [A]. Must match `system.num_phases`. + + # Returns + + - An [`AmpacityProblem`](@ref) object with validated inputs. + + # Examples + + ```julia + prob = $(FUNCTIONNAME)(system; + temperature=25.0, + earth_props=earth, + frequencies=[60.0], + energizations=[100.0 + 0im, 100.0 * cis(-2pi/3), 100.0 * cis(2pi/3)] + ) + ``` + """ + function AmpacityProblem( + system::LineCableSystem; + temperature::REALSCALAR = (T₀), + earth_props::EarthModel, + frequencies::Vector{<:Number} = [f₀], + energizations::Vector{<:Number}, + wind_velocity::REALSCALAR = 1.0, + ) + + # 1. System structure validation + @assert !isempty(system.cables) "LineCableSystem must contain at least one cable" + + # 2. Phase assignment validation + phase_numbers = unique(vcat([cable.conn for cable in system.cables]...)) + @assert !isempty(filter(x -> x > 0, phase_numbers)) "At least one conductor must be assigned to a phase (>0)" + @assert maximum(phase_numbers) <= system.num_phases "Invalid phase number detected" + + # 3. Cable components validation + for (i, cable) in enumerate(system.cables) + @assert !isempty(cable.design_data.components) "Cable $i has no components defined" + + # Validate conductor-insulator pairs + for (j, comp) in enumerate(cable.design_data.components) + @assert !isempty(comp.conductor_group.layers) "Component $j in cable $i has no conductor layers" + @assert !isempty(comp.insulator_group.layers) "Component $j in cable $i has no insulator layers" + + # Validate monotonic increase of radii + @assert comp.conductor_group.radius_ext > comp.conductor_group.radius_in "Component $j in cable $i: conductor outer radius must be larger than inner radius" + @assert comp.insulator_group.radius_ext > comp.insulator_group.radius_in "Component $j in cable $i: insulator outer radius must be larger than inner radius" + + # Validate geometric continuity between conductor and insulator + r_ext_cond = comp.conductor_group.radius_ext + r_in_ins = comp.insulator_group.radius_in + @assert abs(r_ext_cond - r_in_ins) < 1e-10 "Geometric mismatch in cable $i component $j: conductor outer radius ≠ insulator inner radius" + + # Validate electromagnetic properties + # Conductor properties + @assert comp.conductor_props.rho > 0 "Component $j in cable $i: conductor resistivity must be positive" + @assert comp.conductor_props.mu_r > 0 "Component $j in cable $i: conductor relative permeability must be positive" + @assert comp.conductor_props.eps_r >= 0 "Component $j in cable $i: conductor relative permittivity grater than or equal to zero" + + # Insulator properties + @assert comp.insulator_props.rho > 0 "Component $j in cable $i: insulator resistivity must be positive" + @assert comp.insulator_props.mu_r > 0 "Component $j in cable $i: insulator relative permeability must be positive" + @assert comp.insulator_props.eps_r > 0 "Component $j in cable $i: insulator relative permittivity must be positive" + end + end + + # 4. Temperature range validation + @assert abs(temperature - T₀) < ΔTmax """ +Temperature is outside the valid range for linear resistivity model: +T = $temperature +T₀ = $T₀ +ΔTmax = $ΔTmax +|T - T₀| = $(abs(temperature - T₀))""" + # 5. Wind velocity validation + @assert wind_velocity >= 0.0 "Wind velocity must be non-negative" + @assert wind_velocity <= 140.0 "Wind velocity exceeds typical maximum values (140 m/s)" + + # 6. Frequency range validation + @assert !isempty(frequencies) "Frequency vector cannot be empty" + @assert all(f -> f > 0, frequencies) "All frequencies must be positive" + @assert issorted(frequencies) "Frequency vector must be monotonically increasing" + if maximum(frequencies) > 1e8 + @warn "Frequencies above 100 MHz exceed quasi-TEM validity limit. High-frequency results should be interpreted with caution." maxfreq = + maximum(frequencies) + end + + # 7. Earth model validation (Adapted for single frequency) + @assert length(earth_props.layers[end].rho_g) == 1 """Earth model frequencies must match analysis frequency count (must be 1) + Earth model frequencies = $(length(earth_props.layers[end].rho_g)) + Analysis frequencies = 1 + """ + + # 8. Geometric validation + positions = [ + ( + cable.horz, + cable.vert, + maximum( + comp.insulator_group.radius_ext + for comp in cable.design_data.components + ), + ) + for cable in system.cables + ] + + for i in eachindex(positions) + for j in (i+1):lastindex(positions) + # Calculate center-to-center distance + dist = sqrt( + (positions[i][1] - positions[j][1])^2 + + (positions[i][2] - positions[j][2])^2, + ) + + # Get outermost radii for both cables + r_outer_i = positions[i][3] + r_outer_j = positions[j][3] + + # Check if cables overlap + min_allowed_dist = r_outer_i + r_outer_j + + @assert dist > min_allowed_dist """ + Cables $i and $j overlap! + Center-to-center distance: $(dist) m + Minimum required distance: $(min_allowed_dist) m + Cable $i outer radius: $(r_outer_i) m + Cable $j outer radius: $(r_outer_j) m""" + end + end + + # 9. Energization validation + @assert length(energizations) == system.num_phases """ + Number of energizations must match the number of phases in the system. + Phases in system: $(system.num_phases) + Energizations provided: $(length(energizations))""" + + + # 10. Type resolution and final construction + T = resolve_T(system, temperature, earth_props, frequencies, wind_velocity) + + # Coerce energizations to Complex{T} + complex_energizations = coerce_to_T(energizations, Complex{T}) + + return new{T}( + coerce_to_T(system, T), + coerce_to_T(temperature, T), + coerce_to_T(earth_props, T), + coerce_to_T(frequencies, T), + complex_energizations, + coerce_to_T(wind_velocity, T), + ) + end +end + +# @kwdef struct EMTOptions <: AbstractFormulationOptions +# "Skip user confirmation for overwriting results" +# force_overwrite::Bool = false +# "Reduce bundle conductors to equivalent single conductor" +# reduce_bundle::Bool = true +# "Eliminate grounded conductors from the system (Kron reduction)" +# kron_reduction::Bool = true +# "Enforce ideal transposition/snaking" +# ideal_transposition::Bool = true +# "Temperature correction" +# temperature_correction::Bool = true +# "Save path for output files" +# save_path::String = joinpath(".", "lineparams_output") +# "Verbosity level" +# verbosity::Int = 0 +# "Log file path" +# logfile::Union{String, Nothing} = nothing +# end + +# The one-line constructor to "promote" a NamedTuple +# EMTOptions(opts::NamedTuple) = EMTOptions(; opts...) + +""" +$(TYPEDEF) + +Represents the electromagnetic transient (EMT) formulation set for cable or line systems, containing all required impedance and admittance models for internal and earth effects. + +$(TYPEDFIELDS) +""" +struct EMTFormulation <: AbstractFormulationSet + "Internal impedance formulation." + internal_impedance::InternalImpedanceFormulation + "Insulation impedance formulation." + insulation_impedance::InsulationImpedanceFormulation + "Earth impedance formulation." + earth_impedance::EarthImpedanceFormulation + "Insulation admittance formulation." + insulation_admittance::InsulationAdmittanceFormulation + "Earth admittance formulation." + earth_admittance::EarthAdmittanceFormulation + "Modal transformation method." + modal_transform::AbstractTransformFormulation + "Equivalent homogeneous earth model (EHEM) formulation." + equivalent_earth::Union{AbstractEHEMFormulation, Nothing} + "Solver options for EMT-type computations." + options::EMTOptions + + @doc """ + $(TYPEDSIGNATURES) + + Constructs an [`EMTFormulation`](@ref) instance. + + # Arguments + + - `internal_impedance`: Internal impedance formulation. + - `insulation_impedance`: Insulation impedance formulation. + - `earth_impedance`: Earth impedance formulation. + - `insulation_admittance`: Insulation admittance formulation. + - `earth_admittance`: Earth admittance formulation. + - `modal_transform`: Modal transformation method. + - `equivalent_earth`: Equivalent homogeneous earth model (EHEM) formulation. + - `options`: Solver options for EMT-type computations. + + # Returns + + - An [`EMTFormulation`](@ref) object containing the specified methods. + + # Examples + + ```julia + emt = $(FUNCTIONNAME)(...) + ``` + """ + function EMTFormulation(; + internal_impedance::InternalImpedanceFormulation, + insulation_impedance::InsulationImpedanceFormulation, + earth_impedance::EarthImpedanceFormulation, + insulation_admittance::InsulationAdmittanceFormulation, + earth_admittance::EarthAdmittanceFormulation, + modal_transform::AbstractTransformFormulation, + equivalent_earth::Union{AbstractEHEMFormulation, Nothing}, + options::EMTOptions, + ) + return new( + internal_impedance, insulation_impedance, earth_impedance, + insulation_admittance, earth_admittance, modal_transform, equivalent_earth, + options, + ) + end +end + +function FormulationSet(::Val{:EMT}; + internal_impedance::InternalImpedanceFormulation = InternalImpedance.ScaledBessel(), + insulation_impedance::InsulationImpedanceFormulation = InsulationImpedance.Lossless(), + earth_impedance::EarthImpedanceFormulation = EarthImpedance.Papadopoulos(), + insulation_admittance::InsulationAdmittanceFormulation = InsulationAdmittance.Lossless(), + earth_admittance::EarthAdmittanceFormulation = EarthAdmittance.Papadopoulos(), + modal_transform::AbstractTransformFormulation = Transforms.Fortescue(), + equivalent_earth::Union{AbstractEHEMFormulation, Nothing} = nothing, + options = (;), +) + emt_opts = build_options(EMTOptions, options; strict = true) + return EMTFormulation(; internal_impedance, insulation_impedance, earth_impedance, + insulation_admittance, earth_admittance, modal_transform, equivalent_earth, + options = emt_opts, + ) +end + diff --git a/src/engine/reduction.jl b/src/engine/reduction.jl index 8ceed953..0d71eec8 100644 --- a/src/engine/reduction.jl +++ b/src/engine/reduction.jl @@ -1,150 +1,150 @@ -using LinearAlgebra: BLAS, BlasFloat - -function reorder_indices(map::AbstractVector{<:Integer}) - n = length(map) - phases = Int[] # encounter order of phases > 0 - firsts = Int[] - sizehint!(firsts, n) - zeros = Int[] - sizehint!(zeros, n) - tails = Dict{Int, Vector{Int}}() # phase => remaining indices - - seen = Set{Int}() - @inbounds for (i, p) in pairs(map) - if p > 0 - if !(p in seen) - push!(seen, p) - push!(phases, p) - push!(firsts, i) - else - push!(get!(tails, p, Int[]), i) - end - else - push!(zeros, i) - end - end - - perm = Vector{Int}(undef, n) - k = 1 - @inbounds begin - for i in firsts - perm[k] = i - k += 1 - end - for p in phases - if haskey(tails, p) - for i in tails[p] - perm[k] = i - k += 1 - end - end - end - for i in zeros - perm[k] = i - k += 1 - end - end - return perm -end - -# Non-mutating reorder (2D) -function reorder_M(M::AbstractMatrix, map::AbstractVector{<:Integer}) - n = size(M, 1) - n == size(M, 2) == length(map) || throw(ArgumentError("shape mismatch")) - perm = reorder_indices(map) - return M[perm, perm], map[perm] -end - - -""" - kronify(M, phase_map) - Kron elimination -""" -function kronify( - M::Matrix{Complex{T}}, - phase_map::Vector{Int}, -) where {T <: REALSCALAR} - keep = findall(!=(0), phase_map) - eliminate = findall(==(0), phase_map) - - M11 = M[keep, keep] - M12 = M[keep, eliminate] - M21 = M[eliminate, keep] - M22 = M[eliminate, eliminate] - - return M11 - (M12 * inv(M22)) * M21 -end - -""" - kronify!(M, phase_map, Mred) - Kron's angry little brother (in-place) -""" -function kronify!( - M::Matrix{Complex{T}}, - phase_map::Vector{Int}, - Mred::Matrix{Complex{T}}, -) where {T <: REALSCALAR} - keep = findall(!=(0), phase_map) - eliminate = findall(==(0), phase_map) - - M11 = M[keep, keep] - M12 = M[keep, eliminate] - M21 = M[eliminate, keep] - M22 = M[eliminate, eliminate] - @views @inbounds Mred .= M11 - (M12 * inv(M22)) * M21 - return nothing -end - -# In-place: columns tail -= first (from original), then rows tail -= first (after col pass). -function merge_bundles!(M::AbstractMatrix{T}, ph::AbstractVector{<:Integer}) where {T} - n = size(M, 1) - (size(M, 2) == n && length(ph) == n) || throw(ArgumentError("shape mismatch")) - - # Encounter-ordered groups (include phase 0) - groups = Vector{Vector{Int}}() - index_of = Dict{Int, Int}() - @inbounds for (i, p) in pairs(ph) - gi = get(index_of, p, 0) - if gi == 0 - push!(groups, Int[]) - gi = length(groups) - index_of[p] = gi - end - push!(groups[gi], i) - end - - # -------- Pass 1: columns -------- - @inbounds for grp in groups - length(grp) > 1 || continue - i1 = grp[1] - base_col = @view M[:, i1] # original column kept intact in this pass - for t in Iterators.drop(eachindex(grp), 1) - j = grp[t] - col = @view M[:, j] - if (M isa StridedMatrix{T}) && (T <: BlasFloat) - BLAS.axpy!(-one(T), base_col, col) # col -= base_col - else - col .-= base_col - end - end - end - - # -------- Pass 2: rows -------- - newmap = copy(ph) - @inbounds for grp in groups - length(grp) > 1 || continue - i1 = grp[1] - base_row = @view M[i1, :] # uses row after pass 1 (matches Z1→Z2) - for t in Iterators.drop(eachindex(grp), 1) - i = grp[t] - row = @view M[i, :] - if (M isa StridedMatrix{T}) && (T <: BlasFloat) - BLAS.axpy!(-one(T), base_row, row) # row -= base_row - else - row .-= base_row - end - newmap[i] = 0 - end - end - return M, newmap -end +using LinearAlgebra: BLAS, BlasFloat + +function reorder_indices(map::AbstractVector{<:Integer}) + n = length(map) + phases = Int[] # encounter order of phases > 0 + firsts = Int[] + sizehint!(firsts, n) + zeros = Int[] + sizehint!(zeros, n) + tails = Dict{Int, Vector{Int}}() # phase => remaining indices + + seen = Set{Int}() + @inbounds for (i, p) in pairs(map) + if p > 0 + if !(p in seen) + push!(seen, p) + push!(phases, p) + push!(firsts, i) + else + push!(get!(tails, p, Int[]), i) + end + else + push!(zeros, i) + end + end + + perm = Vector{Int}(undef, n) + k = 1 + @inbounds begin + for i in firsts + perm[k] = i + k += 1 + end + for p in phases + if haskey(tails, p) + for i in tails[p] + perm[k] = i + k += 1 + end + end + end + for i in zeros + perm[k] = i + k += 1 + end + end + return perm +end + +# Non-mutating reorder (2D) +function reorder_M(M::AbstractMatrix, map::AbstractVector{<:Integer}) + n = size(M, 1) + n == size(M, 2) == length(map) || throw(ArgumentError("shape mismatch")) + perm = reorder_indices(map) + return M[perm, perm], map[perm] +end + + +""" + kronify(M, phase_map) + Kron elimination +""" +function kronify( + M::Matrix{Complex{T}}, + phase_map::Vector{Int}, +) where {T <: REALSCALAR} + keep = findall(!=(0), phase_map) + eliminate = findall(==(0), phase_map) + + M11 = M[keep, keep] + M12 = M[keep, eliminate] + M21 = M[eliminate, keep] + M22 = M[eliminate, eliminate] + + return M11 - (M12 * inv(M22)) * M21 +end + +""" + kronify!(M, phase_map, Mred) + Kron's angry little brother (in-place) +""" +function kronify!( + M::Matrix{Complex{T}}, + phase_map::Vector{Int}, + Mred::Matrix{Complex{T}}, +) where {T <: REALSCALAR} + keep = findall(!=(0), phase_map) + eliminate = findall(==(0), phase_map) + + M11 = M[keep, keep] + M12 = M[keep, eliminate] + M21 = M[eliminate, keep] + M22 = M[eliminate, eliminate] + @views @inbounds Mred .= M11 - (M12 * inv(M22)) * M21 + return nothing +end + +# In-place: columns tail -= first (from original), then rows tail -= first (after col pass). +function merge_bundles!(M::AbstractMatrix{T}, ph::AbstractVector{<:Integer}) where {T} + n = size(M, 1) + (size(M, 2) == n && length(ph) == n) || throw(ArgumentError("shape mismatch")) + + # Encounter-ordered groups (include phase 0) + groups = Vector{Vector{Int}}() + index_of = Dict{Int, Int}() + @inbounds for (i, p) in pairs(ph) + gi = get(index_of, p, 0) + if gi == 0 + push!(groups, Int[]) + gi = length(groups) + index_of[p] = gi + end + push!(groups[gi], i) + end + + # -------- Pass 1: columns -------- + @inbounds for grp in groups + length(grp) > 1 || continue + i1 = grp[1] + base_col = @view M[:, i1] # original column kept intact in this pass + for t in Iterators.drop(eachindex(grp), 1) + j = grp[t] + col = @view M[:, j] + if (M isa StridedMatrix{T}) && (T <: BlasFloat) + BLAS.axpy!(-one(T), base_col, col) # col -= base_col + else + col .-= base_col + end + end + end + + # -------- Pass 2: rows -------- + newmap = copy(ph) + @inbounds for grp in groups + length(grp) > 1 || continue + i1 = grp[1] + base_row = @view M[i1, :] # uses row after pass 1 (matches Z1→Z2) + for t in Iterators.drop(eachindex(grp), 1) + i = grp[t] + row = @view M[i, :] + if (M isa StridedMatrix{T}) && (T <: BlasFloat) + BLAS.axpy!(-one(T), base_row, row) # row -= base_row + else + row .-= base_row + end + newmap[i] = 0 + end + end + return M, newmap +end diff --git a/src/engine/solver.jl b/src/engine/solver.jl index 7f50d1b9..ddd0b359 100644 --- a/src/engine/solver.jl +++ b/src/engine/solver.jl @@ -1,361 +1,361 @@ -function compute!( - problem::LineParametersProblem{T}, - formulation::EMTFormulation, -) where {T <: REALSCALAR} - - @info "Preallocating arrays" - - ws = init_workspace(problem, formulation) - nph, nfreq = ws.n_phases, ws.n_frequencies - - # --- full matrices are built per slice (no 3D alloc) ---------------------- - Zbuf = Matrix{Complex{T}}(undef, nph, nph) # reordered scratch (mutated by merge_bundles!) - Pbuf = Matrix{Complex{T}}(undef, nph, nph) - inv_Pbuf = similar(Pbuf) # buffer to hold inv(Pbuf) - - Ztmp = Matrix{Complex{T}}(undef, nph, nph) # raw slice coming from builders - Ptmp = Matrix{Complex{T}}(undef, nph, nph) - - # --- index plan (constant across k) --------------------------------------- - phase_map = ws.phase_map::Vector{Int} - perm = reorder_indices(phase_map) - map_r = phase_map[perm] # reordered map - - # bundle tails mask (same logic as merge_bundles!, but map-only) - reduced_map = let m = copy(map_r), seen = Set{Int}() - @inbounds for (i, p) in pairs(map_r) - if p > 0 && (p in seen) - ; - m[i]=0 - else - ; - p>0 && push!(seen, p) - end - end - m - end - - # decide what Kron shall smite upon - kron_map = if formulation.options.reduce_bundle - if formulation.options.kron_reduction - reduced_map # kill tails and keep nonzero labels - else - km = copy(reduced_map) # kill only tails; keep phase-0 explicit - @inbounds for i in eachindex(km) - if map_r[i] == 0 - ; - km[i] = -1 - end - end - km - end - else - formulation.options.kron_reduction ? map_r : nothing - end - - nkeep = kron_map === nothing ? nph : count(!=(0), kron_map) - Zout = Array{Complex{T}, 3}(undef, nkeep, nkeep, nfreq) - Yout = Array{Complex{T}, 3}(undef, nkeep, nkeep, nfreq) - Mred = Matrix{Complex{T}}(undef, nkeep, nkeep) # buffer to hold Mred - inv_Mred = similar(Mred) # buffer to hold inv(Mred) - - # tiny gather helper to avoid per-slice allocs - @inline function _reorder_into!(dest::AbstractMatrix{Complex{T}}, - src::AbstractMatrix{Complex{T}}, - perm::AbstractVector{Int}) - n = length(perm) - @inbounds for j in 1:n, i in 1:n - dest[i, j] = src[perm[i], perm[j]] - end - return dest - end - - # apply temperature correction if needed - if formulation.options.temperature_correction - ΔT = ws.temp - T₀ - @. ws.rho_cond *= 1 + ws.alpha_cond * ΔT - end - - # pre-allocate LU factorization for admittance inversion - I_nph = Matrix{Complex{T}}(I, nph, nph) # identity for full size - I_nkeep = Matrix{Complex{T}}(I, nkeep, nkeep) # identity for reduced size - - # --- per-frequency pipeline ------------------------------------------------ - @info "Starting line parameters computation" - for k in 1:nfreq - - compute_impedance_matrix!(Ztmp, ws, k, formulation) - compute_admittance_matrix!(Ptmp, ws, k, formulation) - - # 1) reorder - _reorder_into!(Zbuf, Ztmp, perm) - _reorder_into!(Pbuf, Ptmp, perm) - - # 2) bundle reduction (in-place) - if formulation.options.reduce_bundle - merge_bundles!(Zbuf, map_r) - merge_bundles!(Pbuf, map_r) - end - - # 3) kron - if kron_map === nothing - symtrans!(Zbuf) - formulation.options.ideal_transposition || line_transpose!(Zbuf) - @views @inbounds Zout[:, :, k] .= Zbuf - - try - F = cholesky!(Hermitian(Pbuf)) # in-place factorization - ldiv!(inv_Pbuf, F, I_nph) # inv_Pbuf := P^{-1} - catch - F = lu!(Pbuf) # overwrite Pbuf with LU - ldiv!(inv_Pbuf, F, I_nph) # inv_Pbuf := P^{-1} - end - # inv_Pbuf = pBuf - inv_Pbuf .*= ws.jω[k] - symtrans!(inv_Pbuf) - formulation.options.ideal_transposition || line_transpose!(inv_Pbuf) - @views @inbounds Yout[:, :, k] .= inv_Pbuf - else - kronify!(Zbuf, kron_map, Mred) - symtrans!(Mred) - formulation.options.ideal_transposition || line_transpose!(Mred) - @views @inbounds Zout[:, :, k] .= Mred - - kronify!(Pbuf, kron_map, Mred) - try - F = cholesky!(Hermitian(Mred)) - ldiv!(inv_Mred, F, I_nkeep) - catch - F = lu!(Mred) - ldiv!(inv_Mred, F, I_nkeep) - end - # inv_Mred = Mred - inv_Mred .*= ws.jω[k] - symtrans!(inv_Mred) - formulation.options.ideal_transposition && line_transpose!(inv_Mred) - - @views @inbounds Yout[:, :, k] .= inv_Mred - end - end - # fill!(Yout, zero(Complex{T})) - - @info "Line parameters computation completed successfully" - return ws, LineParameters(Zout, Yout, ws.freq) -end - -@inline function stash!(slice_or_nothing, k::Int, src::AbstractMatrix) - slice_or_nothing === nothing && return nothing - @views copyto!(slice_or_nothing[:, :, k], src) - nothing -end - -# Builds an Nc×Nc earth matrix using the functors f(h, y, ρ[:,k], ε[:,k], μ[:,k], jω) -@inline function compute_earth_return_matrix!( - E::AbstractMatrix{Complex{T}}, - cables::AbstractVector{Int}, - ws, - k::Int, - functor, # formulation.earth_impedance or .earth_admittance -) where {T} - ρ = @view ws.rho_g[:, k] - ε = @view ws.eps_g[:, k] - μ = @view ws.mu_g[:, k] - jω = ws.jω[k] - - Nc = length(cables) - - @inbounds for cj in 1:Nc - i = cables[cj] - for ck in 1:Nc - j = cables[ck] - # y: diagonal blocks use cable outer radius; off-diagonals use center distance - yij = ws.horz_sep[i, j] - hij = @view ws.vert[[i, j]] - E[cj, ck] = - cj == ck ? functor(Val(:self), hij, yij, ρ, ε, μ, jω) : - functor(Val(:mutual), hij, yij, ρ, ε, μ, jω) - end - end - - return nothing -end - - -function compute_impedance_matrix!( - Ztmp::AbstractMatrix{Complex{T}}, - ws, - k::Int, - formulation, -) where {T <: REALSCALAR} - - @inbounds fill!(Ztmp, zero(Complex{T})) - @assert length(ws.r_ins_ext) == ws.n_phases "ws.r_ins_ext length mismatch" - @assert length(ws.mu_ins) == ws.n_phases "ws.mu_ins length mismatch" - - Nc = ws.n_cables - jω = ws.jω[k] - - cons_in_cable, cables = _get_cable_indices(ws) - - # Earth return impedance (Nc×Nc) - Zext = Matrix{Complex{T}}(undef, Nc, Nc) - compute_earth_return_matrix!(Zext, cables, ws, k, formulation.earth_impedance) - stash!(ws.Zg, k, Zext) - - # ws.Zg[:, :, k] .= Zext # store in workspace for later use - - zinfunctor = formulation.internal_impedance - zinsfunctor = formulation.insulation_impedance - - @inbounds for c in 1:Nc - cons = cons_in_cable[c]; - n = length(cons) - - for p ∈ n:-1:1 - i = cons[p] - rin = ws.r_in[i] - rex = ws.r_ext[i] - ρc = ws.rho_cond[i] - μrc = ws.mu_cond[i] - - z_outer = zinfunctor(:outer, rin, rex, ρc, μrc, jω) - z_inner = (p < n) ? zinfunctor(:inner, - ws.r_in[cons[p+1]], - ws.r_ext[cons[p+1]], - ws.rho_cond[cons[p+1]], - ws.mu_cond[cons[p+1]], jω) : zero(z_outer) - z_mutual = zinfunctor(:mutual, rin, rex, ρc, μrc, jω) - - # insulation series - r_ins_ext = ws.r_ins_ext[i] - μr_ins = ws.mu_ins[i] - z_ins = zinsfunctor(rex, r_ins_ext, μr_ins, jω) - - z_loop = z_outer + z_inner + z_ins - - if p > 1 - for a in 1:(p-1), b in 1:(p-1) - Ztmp[cons[a], cons[b]] += (z_loop - 2*z_mutual) - end - for a in 1:(p-1) - Ztmp[cons[p], cons[a]] += (z_loop - z_mutual) - Ztmp[cons[a], cons[p]] += (z_loop - z_mutual) - end - end - Ztmp[cons[p], cons[p]] += z_loop - end - - stash!(ws.Zin, k, Ztmp) - - # self earth-return on intra-cable block - zgself = Zext[c, c] - for a in 1:n, b in 1:n - Ztmp[cons[a], cons[b]] += zgself - end - end - - # mutual earth-return off-blocks - @inbounds for cj in 1:(Nc-1) - cons_j = cons_in_cable[cj]; - nj = length(cons_j) - for ck in (cj+1):Nc - zgmut = Zext[cj, ck] - cons_k = cons_in_cable[ck]; - nk = length(cons_k) - for a in 1:nj, b in 1:nk - Ztmp[cons_j[a], cons_k[b]] += zgmut - Ztmp[cons_k[b], cons_j[a]] += zgmut - end - end - end - - stash!(ws.Z, k, Ztmp) - return nothing -end - -function compute_admittance_matrix!( - Ptmp::AbstractMatrix{Complex{T}}, - ws, - k::Int, - formulation, -) where {T <: REALSCALAR} - - # Earth return (Nc×Nc) - @inbounds fill!(Ptmp, zero(Complex{T})) - @assert length(ws.r_ins_ext) == ws.n_phases "ws.r_ins_ext length mismatch" - @assert length(ws.mu_ins) == ws.n_phases "ws.mu_ins length mismatch" - - Nc = ws.n_cables - jω = ws.jω[k] - - cons_in_cable, cables = _get_cable_indices(ws) - - # Earth return admittance (Nc×Nc) - Pext = Matrix{Complex{T}}(undef, Nc, Nc) - compute_earth_return_matrix!(Pext, cables, ws, k, formulation.earth_admittance) - ws.Pg[:, :, k] .= Pext # store in workspace for later use - - # --- internal Maxwell coefficients (Ametani tail-sum) ------------------------- - pinsfunctor = formulation.insulation_admittance - @inbounds for c in 1:Nc - cons = cons_in_cable[c] - n = length(cons) - if n <= 1 - continue - end - - # gap coefficients p_g for gaps g = 1..n-1 (between cons[g] and cons[g+1]) - p = Vector{Complex{T}}(undef, n-1) - @inbounds for g in 1:(n-1) - i = cons[g] - r_in_ins = ws.r_ins_in[i] # = r_ext of conductor g - r_ex_ins = ws.r_ins_ext[i] - eps_ins = ws.eps_ins[i] # eps relative - p[g] = pinsfunctor(r_in_ins, r_ex_ins, eps_ins, jω) - end - - # tail sums S[k] = sum_{g=k}^{n-1} p_g, with S[n] = 0 - S = Vector{Complex{T}}(undef, n) - S[n] = zero(Complex{T}) - @inbounds for k in (n-1):-1:1 - S[k] = p[k] + S[k+1] - end - - # P_in[a,b] = S[max(a,b)] - @inbounds for a in 1:n - ia = cons[a] - for b in 1:n - Ptmp[ia, cons[b]] += S[max(a, b)] - end - end - end - stash!(ws.Pin, k, Ptmp) - - # stamp earth terms - @inbounds for c in 1:Nc - cons = cons_in_cable[c]; - n = length(cons) - pgself = Pext[c, c] - for a in 1:n, b in 1:n - Ptmp[cons[a], cons[b]] += pgself - end - end - - @inbounds for cj in 1:(Nc-1) - cons_j = cons_in_cable[cj]; - nj = length(cons_j) - for ck in (cj+1):Nc - pgmut = Pext[cj, ck] - cons_k = cons_in_cable[ck]; - nk = length(cons_k) - for a in 1:nj, b in 1:nk - Ptmp[cons_j[a], cons_k[b]] += pgmut - Ptmp[cons_k[b], cons_j[a]] += pgmut - end - end - end - - stash!(ws.P, k, Ptmp) - - return nothing -end +function compute!( + problem::LineParametersProblem{T}, + formulation::EMTFormulation, +) where {T <: REALSCALAR} + + @info "Preallocating arrays" + + ws = init_workspace(problem, formulation) + nph, nfreq = ws.n_phases, ws.n_frequencies + + # --- full matrices are built per slice (no 3D alloc) ---------------------- + Zbuf = Matrix{Complex{T}}(undef, nph, nph) # reordered scratch (mutated by merge_bundles!) + Pbuf = Matrix{Complex{T}}(undef, nph, nph) + inv_Pbuf = similar(Pbuf) # buffer to hold inv(Pbuf) + + Ztmp = Matrix{Complex{T}}(undef, nph, nph) # raw slice coming from builders + Ptmp = Matrix{Complex{T}}(undef, nph, nph) + + # --- index plan (constant across k) --------------------------------------- + phase_map = ws.phase_map::Vector{Int} + perm = reorder_indices(phase_map) + map_r = phase_map[perm] # reordered map + + # bundle tails mask (same logic as merge_bundles!, but map-only) + reduced_map = let m = copy(map_r), seen = Set{Int}() + @inbounds for (i, p) in pairs(map_r) + if p > 0 && (p in seen) + ; + m[i]=0 + else + ; + p>0 && push!(seen, p) + end + end + m + end + + # decide what Kron shall smite upon + kron_map = if formulation.options.reduce_bundle + if formulation.options.kron_reduction + reduced_map # kill tails and keep nonzero labels + else + km = copy(reduced_map) # kill only tails; keep phase-0 explicit + @inbounds for i in eachindex(km) + if map_r[i] == 0 + ; + km[i] = -1 + end + end + km + end + else + formulation.options.kron_reduction ? map_r : nothing + end + + nkeep = kron_map === nothing ? nph : count(!=(0), kron_map) + Zout = Array{Complex{T}, 3}(undef, nkeep, nkeep, nfreq) + Yout = Array{Complex{T}, 3}(undef, nkeep, nkeep, nfreq) + Mred = Matrix{Complex{T}}(undef, nkeep, nkeep) # buffer to hold Mred + inv_Mred = similar(Mred) # buffer to hold inv(Mred) + + # tiny gather helper to avoid per-slice allocs + @inline function _reorder_into!(dest::AbstractMatrix{Complex{T}}, + src::AbstractMatrix{Complex{T}}, + perm::AbstractVector{Int}) + n = length(perm) + @inbounds for j in 1:n, i in 1:n + dest[i, j] = src[perm[i], perm[j]] + end + return dest + end + + # apply temperature correction if needed + if formulation.options.temperature_correction + ΔT = ws.temp - T₀ + @. ws.rho_cond *= 1 + ws.alpha_cond * ΔT + end + + # pre-allocate LU factorization for admittance inversion + I_nph = Matrix{Complex{T}}(I, nph, nph) # identity for full size + I_nkeep = Matrix{Complex{T}}(I, nkeep, nkeep) # identity for reduced size + + # --- per-frequency pipeline ------------------------------------------------ + @info "Starting line parameters computation" + for k in 1:nfreq + + compute_impedance_matrix!(Ztmp, ws, k, formulation) + compute_admittance_matrix!(Ptmp, ws, k, formulation) + + # 1) reorder + _reorder_into!(Zbuf, Ztmp, perm) + _reorder_into!(Pbuf, Ptmp, perm) + + # 2) bundle reduction (in-place) + if formulation.options.reduce_bundle + merge_bundles!(Zbuf, map_r) + merge_bundles!(Pbuf, map_r) + end + + # 3) kron + if kron_map === nothing + symtrans!(Zbuf) + formulation.options.ideal_transposition || line_transpose!(Zbuf) + @views @inbounds Zout[:, :, k] .= Zbuf + + try + F = cholesky!(Hermitian(Pbuf)) # in-place factorization + ldiv!(inv_Pbuf, F, I_nph) # inv_Pbuf := P^{-1} + catch + F = lu!(Pbuf) # overwrite Pbuf with LU + ldiv!(inv_Pbuf, F, I_nph) # inv_Pbuf := P^{-1} + end + # inv_Pbuf = pBuf + inv_Pbuf .*= ws.jω[k] + symtrans!(inv_Pbuf) + formulation.options.ideal_transposition || line_transpose!(inv_Pbuf) + @views @inbounds Yout[:, :, k] .= inv_Pbuf + else + kronify!(Zbuf, kron_map, Mred) + symtrans!(Mred) + formulation.options.ideal_transposition || line_transpose!(Mred) + @views @inbounds Zout[:, :, k] .= Mred + + kronify!(Pbuf, kron_map, Mred) + try + F = cholesky!(Hermitian(Mred)) + ldiv!(inv_Mred, F, I_nkeep) + catch + F = lu!(Mred) + ldiv!(inv_Mred, F, I_nkeep) + end + # inv_Mred = Mred + inv_Mred .*= ws.jω[k] + symtrans!(inv_Mred) + formulation.options.ideal_transposition && line_transpose!(inv_Mred) + + @views @inbounds Yout[:, :, k] .= inv_Mred + end + end + # fill!(Yout, zero(Complex{T})) + + @info "Line parameters computation completed successfully" + return ws, LineParameters(Zout, Yout, ws.freq) +end + +@inline function stash!(slice_or_nothing, k::Int, src::AbstractMatrix) + slice_or_nothing === nothing && return nothing + @views copyto!(slice_or_nothing[:, :, k], src) + nothing +end + +# Builds an Nc×Nc earth matrix using the functors f(h, y, ρ[:,k], ε[:,k], μ[:,k], jω) +@inline function compute_earth_return_matrix!( + E::AbstractMatrix{Complex{T}}, + cables::AbstractVector{Int}, + ws, + k::Int, + functor, # formulation.earth_impedance or .earth_admittance +) where {T} + ρ = @view ws.rho_g[:, k] + ε = @view ws.eps_g[:, k] + μ = @view ws.mu_g[:, k] + jω = ws.jω[k] + + Nc = length(cables) + + @inbounds for cj in 1:Nc + i = cables[cj] + for ck in 1:Nc + j = cables[ck] + # y: diagonal blocks use cable outer radius; off-diagonals use center distance + yij = ws.horz_sep[i, j] + hij = @view ws.vert[[i, j]] + E[cj, ck] = + cj == ck ? functor(Val(:self), hij, yij, ρ, ε, μ, jω) : + functor(Val(:mutual), hij, yij, ρ, ε, μ, jω) + end + end + + return nothing +end + + +function compute_impedance_matrix!( + Ztmp::AbstractMatrix{Complex{T}}, + ws, + k::Int, + formulation, +) where {T <: REALSCALAR} + + @inbounds fill!(Ztmp, zero(Complex{T})) + @assert length(ws.r_ins_ext) == ws.n_phases "ws.r_ins_ext length mismatch" + @assert length(ws.mu_ins) == ws.n_phases "ws.mu_ins length mismatch" + + Nc = ws.n_cables + jω = ws.jω[k] + + cons_in_cable, cables = _get_cable_indices(ws) + + # Earth return impedance (Nc×Nc) + Zext = Matrix{Complex{T}}(undef, Nc, Nc) + compute_earth_return_matrix!(Zext, cables, ws, k, formulation.earth_impedance) + stash!(ws.Zg, k, Zext) + + # ws.Zg[:, :, k] .= Zext # store in workspace for later use + + zinfunctor = formulation.internal_impedance + zinsfunctor = formulation.insulation_impedance + + @inbounds for c in 1:Nc + cons = cons_in_cable[c]; + n = length(cons) + + for p ∈ n:-1:1 + i = cons[p] + rin = ws.r_in[i] + rex = ws.r_ext[i] + ρc = ws.rho_cond[i] + μrc = ws.mu_cond[i] + + z_outer = zinfunctor(:outer, rin, rex, ρc, μrc, jω) + z_inner = (p < n) ? zinfunctor(:inner, + ws.r_in[cons[p+1]], + ws.r_ext[cons[p+1]], + ws.rho_cond[cons[p+1]], + ws.mu_cond[cons[p+1]], jω) : zero(z_outer) + z_mutual = zinfunctor(:mutual, rin, rex, ρc, μrc, jω) + + # insulation series + r_ins_ext = ws.r_ins_ext[i] + μr_ins = ws.mu_ins[i] + z_ins = zinsfunctor(rex, r_ins_ext, μr_ins, jω) + + z_loop = z_outer + z_inner + z_ins + + if p > 1 + for a in 1:(p-1), b in 1:(p-1) + Ztmp[cons[a], cons[b]] += (z_loop - 2*z_mutual) + end + for a in 1:(p-1) + Ztmp[cons[p], cons[a]] += (z_loop - z_mutual) + Ztmp[cons[a], cons[p]] += (z_loop - z_mutual) + end + end + Ztmp[cons[p], cons[p]] += z_loop + end + + stash!(ws.Zin, k, Ztmp) + + # self earth-return on intra-cable block + zgself = Zext[c, c] + for a in 1:n, b in 1:n + Ztmp[cons[a], cons[b]] += zgself + end + end + + # mutual earth-return off-blocks + @inbounds for cj in 1:(Nc-1) + cons_j = cons_in_cable[cj]; + nj = length(cons_j) + for ck in (cj+1):Nc + zgmut = Zext[cj, ck] + cons_k = cons_in_cable[ck]; + nk = length(cons_k) + for a in 1:nj, b in 1:nk + Ztmp[cons_j[a], cons_k[b]] += zgmut + Ztmp[cons_k[b], cons_j[a]] += zgmut + end + end + end + + stash!(ws.Z, k, Ztmp) + return nothing +end + +function compute_admittance_matrix!( + Ptmp::AbstractMatrix{Complex{T}}, + ws, + k::Int, + formulation, +) where {T <: REALSCALAR} + + # Earth return (Nc×Nc) + @inbounds fill!(Ptmp, zero(Complex{T})) + @assert length(ws.r_ins_ext) == ws.n_phases "ws.r_ins_ext length mismatch" + @assert length(ws.mu_ins) == ws.n_phases "ws.mu_ins length mismatch" + + Nc = ws.n_cables + jω = ws.jω[k] + + cons_in_cable, cables = _get_cable_indices(ws) + + # Earth return admittance (Nc×Nc) + Pext = Matrix{Complex{T}}(undef, Nc, Nc) + compute_earth_return_matrix!(Pext, cables, ws, k, formulation.earth_admittance) + ws.Pg[:, :, k] .= Pext # store in workspace for later use + + # --- internal Maxwell coefficients (Ametani tail-sum) ------------------------- + pinsfunctor = formulation.insulation_admittance + @inbounds for c in 1:Nc + cons = cons_in_cable[c] + n = length(cons) + if n <= 1 + continue + end + + # gap coefficients p_g for gaps g = 1..n-1 (between cons[g] and cons[g+1]) + p = Vector{Complex{T}}(undef, n-1) + @inbounds for g in 1:(n-1) + i = cons[g] + r_in_ins = ws.r_ins_in[i] # = r_ext of conductor g + r_ex_ins = ws.r_ins_ext[i] + eps_ins = ws.eps_ins[i] # eps relative + p[g] = pinsfunctor(r_in_ins, r_ex_ins, eps_ins, jω) + end + + # tail sums S[k] = sum_{g=k}^{n-1} p_g, with S[n] = 0 + S = Vector{Complex{T}}(undef, n) + S[n] = zero(Complex{T}) + @inbounds for k in (n-1):-1:1 + S[k] = p[k] + S[k+1] + end + + # P_in[a,b] = S[max(a,b)] + @inbounds for a in 1:n + ia = cons[a] + for b in 1:n + Ptmp[ia, cons[b]] += S[max(a, b)] + end + end + end + stash!(ws.Pin, k, Ptmp) + + # stamp earth terms + @inbounds for c in 1:Nc + cons = cons_in_cable[c]; + n = length(cons) + pgself = Pext[c, c] + for a in 1:n, b in 1:n + Ptmp[cons[a], cons[b]] += pgself + end + end + + @inbounds for cj in 1:(Nc-1) + cons_j = cons_in_cable[cj]; + nj = length(cons_j) + for ck in (cj+1):Nc + pgmut = Pext[cj, ck] + cons_k = cons_in_cable[ck]; + nk = length(cons_k) + for a in 1:nj, b in 1:nk + Ptmp[cons_j[a], cons_k[b]] += pgmut + Ptmp[cons_k[b], cons_j[a]] += pgmut + end + end + end + + stash!(ws.P, k, Ptmp) + + return nothing +end diff --git a/src/engine/transforms/Transforms.jl b/src/engine/transforms/Transforms.jl index 94fee833..04270862 100644 --- a/src/engine/transforms/Transforms.jl +++ b/src/engine/transforms/Transforms.jl @@ -1,33 +1,33 @@ -""" - LineCableModels.Engine.Transforms - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module Transforms - -# Export public API -export Fortescue - -# Module-specific dependencies -using ...Commons -import ...Commons: get_description -import ...Utils: symtrans, offdiag_ratio, to_nominal -import ..Engine: - AbstractTransformFormulation, LineParameters, SeriesImpedance, ShuntAdmittance -using Measurements -using LinearAlgebra -# using GenericLinearAlgebra -using NLsolve - - -include("fortescue.jl") -include("eiglevenberg.jl") - - -end # module Transforms +""" + LineCableModels.Engine.Transforms + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module Transforms + +# Export public API +export Fortescue + +# Module-specific dependencies +using ...Commons +import ...Commons: get_description +import ...Utils: symtrans, offdiag_ratio, to_nominal +import ..Engine: + AbstractTransformFormulation, LineParameters, SeriesImpedance, ShuntAdmittance +using Measurements +using LinearAlgebra +# using GenericLinearAlgebra +using NLsolve + + +include("fortescue.jl") +include("eiglevenberg.jl") + + +end # module Transforms diff --git a/src/engine/transforms/eiglevenberg.jl b/src/engine/transforms/eiglevenberg.jl index 8c9abb3d..945e1f26 100644 --- a/src/engine/transforms/eiglevenberg.jl +++ b/src/engine/transforms/eiglevenberg.jl @@ -1,335 +1,335 @@ -struct Levenberg <: AbstractTransformFormulation - tol::BASE_FLOAT -end - -const LMTOL = 1e-8 # default LM tolerance - -# Convenient ctor -Levenberg(; tol::U = U(LMTOL)) where {U <: REALSCALAR} = - Levenberg(BASE_FLOAT(tol)) # explicit downcast to Float64 - -get_description( - ::Levenberg, -) = "Levenberg–Marquardt (frequency-tracked eigen decomposition)" - -""" -$(TYPEDSIGNATURES) - -Apply Levenberg–Marquardt modal decomposition to a frequency-dependent -[`LineParameters`](@ref) object. Returns the (frequency-tracked) modal -transformation matrices and a **modal-domain** `LineParameters` holding the -**modal characteristic** impedance/admittance (diagonal per frequency). - -# Arguments - -- `lp`: Phase-domain line parameters (series `Z`, shunt `Y`, and `f`). -- `f::Levenberg`: Functor with solver tolerance. - -# Returns - -- `Tv`: Transformation matrices `T(•)` as a 3-tensor `n×n×nfreq` (columns are modes). -- `LineParameters`: Modal-domain characteristic parameters: - - `Z.values[:,:,k] = Diagonal(Zcₖ)`, where `Zcₖ = sqrt.(diag(Zmₖ))./sqrt.(diag(Ymₖ))`. - - `Y.values[:,:,k] = Diagonal(Ycₖ)`, with `Ycₖ = 1 ./ Zcₖ`. - -# Notes - -- Columns are **phase→modal** voltage transformation (same convention as your legacy code). -- Rotation `rot!` is applied per frequency to minimize the imaginary part of each column - (Gustavsen’s scheme), stabilizing mode identity across the sweep. -""" -function (f::Levenberg)(lp::LineParameters) - n, n2, nfreq = size(lp.Z.values) - n == n2 || throw(DimensionMismatch("Z must be square")) - size(lp.Y.values) == (n, n, nfreq) || throw(DimensionMismatch("Y must be n×n×nfreq")) - - # 1) Deterministic eigen/LM on nominal arrays - Z_nom = to_nominal(lp.Z.values) - Y_nom = to_nominal(lp.Y.values) - f_nom = to_nominal(lp.f) - Ti, _g_nom = _calc_transformation_matrix_LM(n, Z_nom, Y_nom, f_nom; tol = f.tol) - _rot_min_imag!(Ti) - - # 2) Apply deterministic T to uncertain (or plain) inputs for *physical* outputs - Zm, Ym, Zc_mod, Yc_mod, Zch, Ych = - _calc_modal_quantities(Ti, lp.Z.values, lp.Y.values) - Gdiag = _calc_gamma(Ti, lp.Z.values, lp.Y.values) - - # Keep your original return (Ti, modal characteristic) for compatibility, - # but you now also have Zm, Ym, Zch, Ych, Gdiag available for downstream use. - return Ti, LineParameters(SeriesImpedance(Zc_mod), ShuntAdmittance(Yc_mod), lp.f), - LineParameters(SeriesImpedance(Zm), ShuntAdmittance(Ym), lp.f), - LineParameters(SeriesImpedance(Zch), ShuntAdmittance(Ych), lp.f), Gdiag -end - -#= --------------------------------------------------------------------------- -Internals ------------------------------------------------------------------------------=# - -# Propagate γ with uncertainty WITHOUT eigen(): -# γ̂_k = sqrt.( diag( inv(T_k) * (Y_k*Z_k) * T_k ) ) -function _calc_gamma( - Ti::AbstractArray{Tc, 3}, - Z::AbstractArray{Tu, 3}, - Y::AbstractArray{Tu, 3}, -) where {Tc <: Complex, Tu <: COMPLEXSCALAR} - n, n2, nfreq = size(Ti) - n == n2 || throw(DimensionMismatch("Ti must be n×n×nfreq")) - size(Z) == size(Y) == (n, n, nfreq) || throw(DimensionMismatch("Z,Y must be n×n×nfreq")) - - # Element type follows uncertain inputs - Tγ = promote_type(eltype(Z), eltype(Y)) - Gdiag = zeros(Tγ, n, n, nfreq) # store as diagonal matrices for consistency - - Tk = zeros(Tc, n, n) - invT = zeros(Tc, n, n) - - @inbounds for k in 1:nfreq - Tk .= @view Ti[:, :, k] - invT .= inv(Tk) - - S_k = @view(Y[:, :, k]) * @view(Z[:, :, k]) # Complex{Measurement} ok - λdiag = diag(invT * S_k * Tk) - γdiag = sqrt.(λdiag) - @views Gdiag[:, :, k] .= Diagonal(γdiag) - end - return Gdiag -end - -# Frequency-tracked Levenberg–Marquardt eigen solution -function _calc_transformation_matrix_LM( - n::Int, - Z::AbstractArray{T, 3}, - Y::AbstractArray{T, 3}, - f::AbstractVector{U}; - tol::U = LMTOL, -) where {T <: Complex, U <: Real} - - # Constants - ε0 = U(ε₀) # [F/m] - μ0 = U(μ₀) - - nfreq = size(Z, 3) - Ti = zeros(T, n, n, nfreq) - g = zeros(T, n, n, nfreq) # store as diagonalized in n×n×nfreq for convenience - - Zk = zeros(T, n, n) - Yk = zeros(T, n, n) - - # k = 1 → plain eigen-decomposition seed - Zk .= @view Z[:, :, 1] - Yk .= @view Y[:, :, 1] - S = Yk * Zk - E = eigen(S) # S*v = λ*v - Ti[:, :, 1] .= E.vectors - g[:, :, 1] .= Diagonal(sqrt.(E.values)) # γ = sqrt(λ) - - # k ≥ 2 → LM tracking - ord_sq = n^2 - for k in 2:nfreq - Zk .= @view Z[:, :, k] - Yk .= @view Y[:, :, k] - - S = Yk * Zk - - # Normalize as in legacy: (S / norm_val) - I - ω = 2π * f[k] - nrm = -(ω^2) * ε0 * μ0 - S̃ = (S ./ nrm) - I - - # Seed from previous step - Tseed = @view Ti[:, :, k-1] - gseed = @view g[:, :, k-1] - λseed = (diag(gseed) .^ 2 ./ nrm) .- 1 # since S̃*T = T*Λ with Λ = λ̃ = (λ/nrm)-1 - - # Build real-valued unknown vector: [Re(T); Im(T); Re(λ); Im(λ)] - x0 = [ - vec(real(Tseed)); - vec(imag(Tseed)); - real(λseed); - imag(λseed) - ] - - function _residual!( - F::AbstractVector{<:R}, - x::AbstractVector{<:R}, - ) where {R <: Real} - # Unpack - Tr = reshape(@view(x[1:ord_sq]), n, n) - Ti_ = reshape(@view(x[(ord_sq+1):(2*ord_sq)]), n, n) - - λr = @view x[(2*ord_sq+1):(2*ord_sq+n)] - λi = @view x[(2*ord_sq+n+1):(2*ord_sq+2n)] - - Λr = Diagonal(λr) - Λi = Diagonal(λi) - - Sr = real(S̃); - Si = imag(S̃) - - # Residual of S̃*T - T*Λ = 0, split into real/imag - Rr = (Sr*Tr - Si*Ti_) - (Tr*Λr - Ti_*Λi) - Ri = (Sr*Ti_ + Si*Tr) - (Tr*Λi + Ti_*Λr) - - F[1:ord_sq] .= vec(Rr) - F[(ord_sq+1):(2*ord_sq)] .= vec(Ri) - - # Column normalization constraints - # For each column j: ||t_r||^2 - ||t_i||^2 = 1 and t_r ⋅ t_i = 0 - c1 = sum(abs2.(Tr), dims = 1) .- sum(abs2.(Ti_), dims = 1) .- 1 - c2 = sum(Tr .* Ti_, dims = 1) - idx = 2*ord_sq - @inbounds for j in 1:n - F[idx+2j-1] = c1[j] - F[idx+2j] = c2[j] - end - return nothing - end - - sol = nlsolve( - _residual!, - x0; - method = :trust_region, - autodiff = :forward, - xtol = tol, - ftol = tol, - ) - - if !converged(sol) - @warn "LM solver did not converge at k=$k, using seed eigen-decomposition fallback" - E = eigen(S) - Ti[:, :, k] .= E.vectors - g[:, :, k] .= Diagonal(sqrt.(E.values)) - continue - end - - x = sol.zero - Tr = reshape(@view(x[1:ord_sq]), n, n) - Ti_ = reshape(@view(x[(ord_sq+1):(2*ord_sq)]), n, n) - T̂ = Tr .+ im .* Ti_ - - λr = @view x[(2*ord_sq+1):(2*ord_sq+n)] - λi = @view x[(2*ord_sq+n+1):(2*ord_sq+2n)] - λ̃ = λr .+ im .* λi # normalized eigenvalues - - # Undo normalization: λ = (λ̃ + 1) * nrm ; γ = sqrt(λ) - λ = (λ̃ .+ one(eltype(λ̃))) .* nrm - γ = sqrt.(λ) - - Ti[:, :, k] .= T̂ - g[:, :, k] .= Diagonal(γ) - end - - return Ti, g -end - -# In-place rotation to minimize imag part column-wise (per frequency slice) -function _rot_min_imag!(Ti::AbstractArray{T, 3}) where {T <: Complex} - n, n2, nfreq = size(Ti) - n == n2 || throw(DimensionMismatch("Ti must be n×n×nfreq")) - tmp = zeros(T, n, n) - @inbounds for k in 1:nfreq - tmp .= @view Ti[:, :, k] - rot!(tmp) # column-wise rotation in-place - Ti[:, :, k] .= tmp - end - return Ti -end - -# Full modal + characteristic + phase back-projection -# Returns: -# Zm, Ym :: n×n×nfreq (modal-domain series/shunt matrices) -# Zc_mod,Yc_mod :: n×n×nfreq (diagonal: per-mode characteristic) -# Zch, Ych :: n×n×nfreq (phase-domain characteristic back-projected) -function _calc_modal_quantities( - Ti::AbstractArray{Tc, 3}, - Z::AbstractArray{Tu, 3}, - Y::AbstractArray{Tu, 3}, -) where {Tc <: Complex, Tu <: COMPLEXSCALAR} - - n, n2, nfreq = size(Ti) - n == n2 || throw(DimensionMismatch("Ti must be n×n×nfreq")) - size(Z) == size(Y) == (n, n, nfreq) || throw(DimensionMismatch("Z,Y must be n×n×nfreq")) - - Tz = promote_type(eltype(Z), eltype(Y)) # keep uncertainties - Zm = zeros(Tz, n, n, nfreq) - Ym = zeros(Tz, n, n, nfreq) - Zc_mod = zeros(Tz, n, n, nfreq) - Yc_mod = zeros(Tz, n, n, nfreq) - Zch = zeros(Tz, n, n, nfreq) - Ych = zeros(Tz, n, n, nfreq) - - Tk = zeros(Tc, n, n) - Zk = zeros(Tz, n, n) - Yk = zeros(Tz, n, n) - invT = zeros(Tc, n, n) - - @inbounds for k in 1:nfreq - Tk .= @view Ti[:, :, k] - invT .= inv(Tk) - Zk .= @view Z[:, :, k] - Yk .= @view Y[:, :, k] - - # Modal matrices (carry uncertainties) - @views Zm[:, :, k] .= transpose(Tk) * Zk * Tk - @views Ym[:, :, k] .= invT * Yk * transpose(invT) - - # Characteristic per-mode (diagonal) in modal domain - zc = sqrt.(diag(@view Zm[:, :, k])) ./ sqrt.(diag(@view Ym[:, :, k])) - @views Zc_mod[:, :, k] .= Diagonal(zc) - @views Yc_mod[:, :, k] .= Diagonal(inv.(zc)) - - # Phase-domain characteristic back-projection - @views Zch[:, :, k] .= transpose(invT) * Zc_mod[:, :, k] * invT - @views Ych[:, :, k] .= Tk * Yc_mod[:, :, k] * transpose(Tk) - end - return Zm, Ym, Zc_mod, Yc_mod, Zch, Ych -end - -# column rotation to minimize imag parts -function rot!(S::AbstractMatrix{T}) where {T <: COMPLEXSCALAR} - n, m = size(S) - n == m || throw(DimensionMismatch("Input must be square")) - @inbounds for j in 1:n - col = @view S[:, j] - - # optimal angle - num = -2 * sum(real.(col) .* imag.(col)) # real - den = sum(real.(col) .^ 2 .- imag.(col) .^ 2) # real - ang = BASE_FLOAT(0.5) * atan(num, den) # real - - s1 = cis(ang) - s2 = cis(ang + BASE_FLOAT(pi/2)) - - A = col .* s1 - B = col .* s2 - - # all-real quadratic metrics - Ar = real.(A); - Ai = imag.(A) - Br = real.(B); - Bi = imag.(B) - - aaa1 = sum(Ai .^ 2) - bbb1 = sum(Ar .* Ai) - ccc1 = sum(Ar .^ 2) - err1 = aaa1 * cos(ang)^2 + bbb1 * sin(2*ang) + ccc1 * sin(ang)^2 # real - - aaa2 = sum(Bi .^ 2) - bbb2 = sum(Br .* Bi) - ccc2 = sum(Br .^ 2) - err2 = aaa2 * cos(ang)^2 + bbb2 * sin(2*ang) + ccc2 * sin(ang)^2 # real - - col .*= (err1 < err2 ? s1 : s2) - end - return S -end - - -# tiny helper: in-place imag (for metric term; avoids repeated allocations) -@inline function imag!(x::AbstractVector{<:Complex}) - @inbounds for i in eachindex(x) - x[i] = imag(x[i]) - end - return x -end +struct Levenberg <: AbstractTransformFormulation + tol::BASE_FLOAT +end + +const LMTOL = 1e-8 # default LM tolerance + +# Convenient ctor +Levenberg(; tol::U = U(LMTOL)) where {U <: REALSCALAR} = + Levenberg(BASE_FLOAT(tol)) # explicit downcast to Float64 + +get_description( + ::Levenberg, +) = "Levenberg–Marquardt (frequency-tracked eigen decomposition)" + +""" +$(TYPEDSIGNATURES) + +Apply Levenberg–Marquardt modal decomposition to a frequency-dependent +[`LineParameters`](@ref) object. Returns the (frequency-tracked) modal +transformation matrices and a **modal-domain** `LineParameters` holding the +**modal characteristic** impedance/admittance (diagonal per frequency). + +# Arguments + +- `lp`: Phase-domain line parameters (series `Z`, shunt `Y`, and `f`). +- `f::Levenberg`: Functor with solver tolerance. + +# Returns + +- `Tv`: Transformation matrices `T(•)` as a 3-tensor `n×n×nfreq` (columns are modes). +- `LineParameters`: Modal-domain characteristic parameters: + - `Z.values[:,:,k] = Diagonal(Zcₖ)`, where `Zcₖ = sqrt.(diag(Zmₖ))./sqrt.(diag(Ymₖ))`. + - `Y.values[:,:,k] = Diagonal(Ycₖ)`, with `Ycₖ = 1 ./ Zcₖ`. + +# Notes + +- Columns are **phase→modal** voltage transformation (same convention as your legacy code). +- Rotation `rot!` is applied per frequency to minimize the imaginary part of each column + (Gustavsen’s scheme), stabilizing mode identity across the sweep. +""" +function (f::Levenberg)(lp::LineParameters) + n, n2, nfreq = size(lp.Z.values) + n == n2 || throw(DimensionMismatch("Z must be square")) + size(lp.Y.values) == (n, n, nfreq) || throw(DimensionMismatch("Y must be n×n×nfreq")) + + # 1) Deterministic eigen/LM on nominal arrays + Z_nom = to_nominal(lp.Z.values) + Y_nom = to_nominal(lp.Y.values) + f_nom = to_nominal(lp.f) + Ti, _g_nom = _calc_transformation_matrix_LM(n, Z_nom, Y_nom, f_nom; tol = f.tol) + _rot_min_imag!(Ti) + + # 2) Apply deterministic T to uncertain (or plain) inputs for *physical* outputs + Zm, Ym, Zc_mod, Yc_mod, Zch, Ych = + _calc_modal_quantities(Ti, lp.Z.values, lp.Y.values) + Gdiag = _calc_gamma(Ti, lp.Z.values, lp.Y.values) + + # Keep your original return (Ti, modal characteristic) for compatibility, + # but you now also have Zm, Ym, Zch, Ych, Gdiag available for downstream use. + return Ti, LineParameters(SeriesImpedance(Zc_mod), ShuntAdmittance(Yc_mod), lp.f), + LineParameters(SeriesImpedance(Zm), ShuntAdmittance(Ym), lp.f), + LineParameters(SeriesImpedance(Zch), ShuntAdmittance(Ych), lp.f), Gdiag +end + +#= --------------------------------------------------------------------------- +Internals +-----------------------------------------------------------------------------=# + +# Propagate γ with uncertainty WITHOUT eigen(): +# γ̂_k = sqrt.( diag( inv(T_k) * (Y_k*Z_k) * T_k ) ) +function _calc_gamma( + Ti::AbstractArray{Tc, 3}, + Z::AbstractArray{Tu, 3}, + Y::AbstractArray{Tu, 3}, +) where {Tc <: Complex, Tu <: COMPLEXSCALAR} + n, n2, nfreq = size(Ti) + n == n2 || throw(DimensionMismatch("Ti must be n×n×nfreq")) + size(Z) == size(Y) == (n, n, nfreq) || throw(DimensionMismatch("Z,Y must be n×n×nfreq")) + + # Element type follows uncertain inputs + Tγ = promote_type(eltype(Z), eltype(Y)) + Gdiag = zeros(Tγ, n, n, nfreq) # store as diagonal matrices for consistency + + Tk = zeros(Tc, n, n) + invT = zeros(Tc, n, n) + + @inbounds for k in 1:nfreq + Tk .= @view Ti[:, :, k] + invT .= inv(Tk) + + S_k = @view(Y[:, :, k]) * @view(Z[:, :, k]) # Complex{Measurement} ok + λdiag = diag(invT * S_k * Tk) + γdiag = sqrt.(λdiag) + @views Gdiag[:, :, k] .= Diagonal(γdiag) + end + return Gdiag +end + +# Frequency-tracked Levenberg–Marquardt eigen solution +function _calc_transformation_matrix_LM( + n::Int, + Z::AbstractArray{T, 3}, + Y::AbstractArray{T, 3}, + f::AbstractVector{U}; + tol::U = LMTOL, +) where {T <: Complex, U <: Real} + + # Constants + ε0 = U(ε₀) # [F/m] + μ0 = U(μ₀) + + nfreq = size(Z, 3) + Ti = zeros(T, n, n, nfreq) + g = zeros(T, n, n, nfreq) # store as diagonalized in n×n×nfreq for convenience + + Zk = zeros(T, n, n) + Yk = zeros(T, n, n) + + # k = 1 → plain eigen-decomposition seed + Zk .= @view Z[:, :, 1] + Yk .= @view Y[:, :, 1] + S = Yk * Zk + E = eigen(S) # S*v = λ*v + Ti[:, :, 1] .= E.vectors + g[:, :, 1] .= Diagonal(sqrt.(E.values)) # γ = sqrt(λ) + + # k ≥ 2 → LM tracking + ord_sq = n^2 + for k in 2:nfreq + Zk .= @view Z[:, :, k] + Yk .= @view Y[:, :, k] + + S = Yk * Zk + + # Normalize as in legacy: (S / norm_val) - I + ω = 2π * f[k] + nrm = -(ω^2) * ε0 * μ0 + S̃ = (S ./ nrm) - I + + # Seed from previous step + Tseed = @view Ti[:, :, k-1] + gseed = @view g[:, :, k-1] + λseed = (diag(gseed) .^ 2 ./ nrm) .- 1 # since S̃*T = T*Λ with Λ = λ̃ = (λ/nrm)-1 + + # Build real-valued unknown vector: [Re(T); Im(T); Re(λ); Im(λ)] + x0 = [ + vec(real(Tseed)); + vec(imag(Tseed)); + real(λseed); + imag(λseed) + ] + + function _residual!( + F::AbstractVector{<:R}, + x::AbstractVector{<:R}, + ) where {R <: Real} + # Unpack + Tr = reshape(@view(x[1:ord_sq]), n, n) + Ti_ = reshape(@view(x[(ord_sq+1):(2*ord_sq)]), n, n) + + λr = @view x[(2*ord_sq+1):(2*ord_sq+n)] + λi = @view x[(2*ord_sq+n+1):(2*ord_sq+2n)] + + Λr = Diagonal(λr) + Λi = Diagonal(λi) + + Sr = real(S̃); + Si = imag(S̃) + + # Residual of S̃*T - T*Λ = 0, split into real/imag + Rr = (Sr*Tr - Si*Ti_) - (Tr*Λr - Ti_*Λi) + Ri = (Sr*Ti_ + Si*Tr) - (Tr*Λi + Ti_*Λr) + + F[1:ord_sq] .= vec(Rr) + F[(ord_sq+1):(2*ord_sq)] .= vec(Ri) + + # Column normalization constraints + # For each column j: ||t_r||^2 - ||t_i||^2 = 1 and t_r ⋅ t_i = 0 + c1 = sum(abs2.(Tr), dims = 1) .- sum(abs2.(Ti_), dims = 1) .- 1 + c2 = sum(Tr .* Ti_, dims = 1) + idx = 2*ord_sq + @inbounds for j in 1:n + F[idx+2j-1] = c1[j] + F[idx+2j] = c2[j] + end + return nothing + end + + sol = nlsolve( + _residual!, + x0; + method = :trust_region, + autodiff = :forward, + xtol = tol, + ftol = tol, + ) + + if !converged(sol) + @warn "LM solver did not converge at k=$k, using seed eigen-decomposition fallback" + E = eigen(S) + Ti[:, :, k] .= E.vectors + g[:, :, k] .= Diagonal(sqrt.(E.values)) + continue + end + + x = sol.zero + Tr = reshape(@view(x[1:ord_sq]), n, n) + Ti_ = reshape(@view(x[(ord_sq+1):(2*ord_sq)]), n, n) + T̂ = Tr .+ im .* Ti_ + + λr = @view x[(2*ord_sq+1):(2*ord_sq+n)] + λi = @view x[(2*ord_sq+n+1):(2*ord_sq+2n)] + λ̃ = λr .+ im .* λi # normalized eigenvalues + + # Undo normalization: λ = (λ̃ + 1) * nrm ; γ = sqrt(λ) + λ = (λ̃ .+ one(eltype(λ̃))) .* nrm + γ = sqrt.(λ) + + Ti[:, :, k] .= T̂ + g[:, :, k] .= Diagonal(γ) + end + + return Ti, g +end + +# In-place rotation to minimize imag part column-wise (per frequency slice) +function _rot_min_imag!(Ti::AbstractArray{T, 3}) where {T <: Complex} + n, n2, nfreq = size(Ti) + n == n2 || throw(DimensionMismatch("Ti must be n×n×nfreq")) + tmp = zeros(T, n, n) + @inbounds for k in 1:nfreq + tmp .= @view Ti[:, :, k] + rot!(tmp) # column-wise rotation in-place + Ti[:, :, k] .= tmp + end + return Ti +end + +# Full modal + characteristic + phase back-projection +# Returns: +# Zm, Ym :: n×n×nfreq (modal-domain series/shunt matrices) +# Zc_mod,Yc_mod :: n×n×nfreq (diagonal: per-mode characteristic) +# Zch, Ych :: n×n×nfreq (phase-domain characteristic back-projected) +function _calc_modal_quantities( + Ti::AbstractArray{Tc, 3}, + Z::AbstractArray{Tu, 3}, + Y::AbstractArray{Tu, 3}, +) where {Tc <: Complex, Tu <: COMPLEXSCALAR} + + n, n2, nfreq = size(Ti) + n == n2 || throw(DimensionMismatch("Ti must be n×n×nfreq")) + size(Z) == size(Y) == (n, n, nfreq) || throw(DimensionMismatch("Z,Y must be n×n×nfreq")) + + Tz = promote_type(eltype(Z), eltype(Y)) # keep uncertainties + Zm = zeros(Tz, n, n, nfreq) + Ym = zeros(Tz, n, n, nfreq) + Zc_mod = zeros(Tz, n, n, nfreq) + Yc_mod = zeros(Tz, n, n, nfreq) + Zch = zeros(Tz, n, n, nfreq) + Ych = zeros(Tz, n, n, nfreq) + + Tk = zeros(Tc, n, n) + Zk = zeros(Tz, n, n) + Yk = zeros(Tz, n, n) + invT = zeros(Tc, n, n) + + @inbounds for k in 1:nfreq + Tk .= @view Ti[:, :, k] + invT .= inv(Tk) + Zk .= @view Z[:, :, k] + Yk .= @view Y[:, :, k] + + # Modal matrices (carry uncertainties) + @views Zm[:, :, k] .= transpose(Tk) * Zk * Tk + @views Ym[:, :, k] .= invT * Yk * transpose(invT) + + # Characteristic per-mode (diagonal) in modal domain + zc = sqrt.(diag(@view Zm[:, :, k])) ./ sqrt.(diag(@view Ym[:, :, k])) + @views Zc_mod[:, :, k] .= Diagonal(zc) + @views Yc_mod[:, :, k] .= Diagonal(inv.(zc)) + + # Phase-domain characteristic back-projection + @views Zch[:, :, k] .= transpose(invT) * Zc_mod[:, :, k] * invT + @views Ych[:, :, k] .= Tk * Yc_mod[:, :, k] * transpose(Tk) + end + return Zm, Ym, Zc_mod, Yc_mod, Zch, Ych +end + +# column rotation to minimize imag parts +function rot!(S::AbstractMatrix{T}) where {T <: COMPLEXSCALAR} + n, m = size(S) + n == m || throw(DimensionMismatch("Input must be square")) + @inbounds for j in 1:n + col = @view S[:, j] + + # optimal angle + num = -2 * sum(real.(col) .* imag.(col)) # real + den = sum(real.(col) .^ 2 .- imag.(col) .^ 2) # real + ang = BASE_FLOAT(0.5) * atan(num, den) # real + + s1 = cis(ang) + s2 = cis(ang + BASE_FLOAT(pi/2)) + + A = col .* s1 + B = col .* s2 + + # all-real quadratic metrics + Ar = real.(A); + Ai = imag.(A) + Br = real.(B); + Bi = imag.(B) + + aaa1 = sum(Ai .^ 2) + bbb1 = sum(Ar .* Ai) + ccc1 = sum(Ar .^ 2) + err1 = aaa1 * cos(ang)^2 + bbb1 * sin(2*ang) + ccc1 * sin(ang)^2 # real + + aaa2 = sum(Bi .^ 2) + bbb2 = sum(Br .* Bi) + ccc2 = sum(Br .^ 2) + err2 = aaa2 * cos(ang)^2 + bbb2 * sin(2*ang) + ccc2 * sin(ang)^2 # real + + col .*= (err1 < err2 ? s1 : s2) + end + return S +end + + +# tiny helper: in-place imag (for metric term; avoids repeated allocations) +@inline function imag!(x::AbstractVector{<:Complex}) + @inbounds for i in eachindex(x) + x[i] = imag(x[i]) + end + return x +end diff --git a/src/engine/transforms/fortescue.jl b/src/engine/transforms/fortescue.jl index 3cc2d0dc..9a4f21a5 100644 --- a/src/engine/transforms/fortescue.jl +++ b/src/engine/transforms/fortescue.jl @@ -1,49 +1,49 @@ -struct Fortescue <: AbstractTransformFormulation - tol::BASE_FLOAT -end -# Convenient constructor with default tolerance -Fortescue(; tol::BASE_FLOAT = BASE_FLOAT(1e-4)) = Fortescue(tol) -get_description(::Fortescue) = "Fortescue (symmetrical components)" - -""" -$(TYPEDSIGNATURES) - -Functor implementation for `Fortescue`. -""" -function (f::Fortescue)(lp::LineParameters{Tc}) where {Tc <: COMPLEXSCALAR} - _, nph, nfreq = size(lp.Z.values) - Tr = typeof(real(zero(Tc))) - Tv = fortescue_F(nph, Tr) # unitary; inverse is F' - Z012 = similar(lp.Z.values) - Y012 = similar(lp.Y.values) - - for k in 1:nfreq - Zs = symtrans(lp.Z.values[:, :, k]) # enforce reciprocity - Ys = symtrans(lp.Y.values[:, :, k]) - - Zseq = Tv * Zs * Tv' # NOT inv(T)*Z*T — use unitary similarity - Yseq = Tv * Ys * Tv' - - if offdiag_ratio(Zseq) > f.tol - @warn "Fortescue: transformed Z not diagonal enough, check your results" ratio = - offdiag_ratio(Zseq) - end - if offdiag_ratio(Yseq) > f.tol - @warn "Fortescue: transformed Y not diagonal enough, check your results" ratio = - offdiag_ratio(Yseq) - end - - Z012[:, :, k] = Matrix(Diagonal(diag(Zseq))) - Y012[:, :, k] = Matrix(Diagonal(diag(Yseq))) - end - return Tv, LineParameters(Z012, Y012, lp.f) -end - -# Unitary N-point DFT (Fortescue) matrix -function fortescue_F(N::Integer, ::Type{T} = BASE_FLOAT) where {T <: REALSCALAR} - N ≥ 1 || throw(ArgumentError("N ≥ 1")) - θ = T(2π) / T(N) - s = one(T) / sqrt(T(N)) - a = cis(θ) - return s .* [a^(k * m) for k in 0:(N-1), m in 0:(N-1)] # F; inverse is F' -end +struct Fortescue <: AbstractTransformFormulation + tol::BASE_FLOAT +end +# Convenient constructor with default tolerance +Fortescue(; tol::BASE_FLOAT = BASE_FLOAT(1e-4)) = Fortescue(tol) +get_description(::Fortescue) = "Fortescue (symmetrical components)" + +""" +$(TYPEDSIGNATURES) + +Functor implementation for `Fortescue`. +""" +function (f::Fortescue)(lp::LineParameters{Tc}) where {Tc <: COMPLEXSCALAR} + _, nph, nfreq = size(lp.Z.values) + Tr = typeof(real(zero(Tc))) + Tv = fortescue_F(nph, Tr) # unitary; inverse is F' + Z012 = similar(lp.Z.values) + Y012 = similar(lp.Y.values) + + for k in 1:nfreq + Zs = symtrans(lp.Z.values[:, :, k]) # enforce reciprocity + Ys = symtrans(lp.Y.values[:, :, k]) + + Zseq = Tv * Zs * Tv' # NOT inv(T)*Z*T — use unitary similarity + Yseq = Tv * Ys * Tv' + + if offdiag_ratio(Zseq) > f.tol + @warn "Fortescue: transformed Z not diagonal enough, check your results" ratio = + offdiag_ratio(Zseq) + end + if offdiag_ratio(Yseq) > f.tol + @warn "Fortescue: transformed Y not diagonal enough, check your results" ratio = + offdiag_ratio(Yseq) + end + + Z012[:, :, k] = Matrix(Diagonal(diag(Zseq))) + Y012[:, :, k] = Matrix(Diagonal(diag(Yseq))) + end + return Tv, LineParameters(Z012, Y012, lp.f) +end + +# Unitary N-point DFT (Fortescue) matrix +function fortescue_F(N::Integer, ::Type{T} = BASE_FLOAT) where {T <: REALSCALAR} + N ≥ 1 || throw(ArgumentError("N ≥ 1")) + θ = T(2π) / T(N) + s = one(T) / sqrt(T(N)) + a = cis(θ) + return s .* [a^(k * m) for k in 0:(N-1), m in 0:(N-1)] # F; inverse is F' +end diff --git a/src/engine/types.jl b/src/engine/types.jl index ec6582df..793b8a29 100644 --- a/src/engine/types.jl +++ b/src/engine/types.jl @@ -1,44 +1,45 @@ -""" -$(TYPEDEF) - -Abstract base type for all problem definitions in the [`LineCableModels.jl`](index.md) computation framework. -""" -abstract type ProblemDefinition end - -# Formulation abstract types -abstract type AbstractFormulationSet end - -abstract type AbstractImpedanceFormulation <: AbstractFormulationSet end -abstract type InternalImpedanceFormulation <: AbstractImpedanceFormulation end -abstract type InsulationImpedanceFormulation <: AbstractImpedanceFormulation end -abstract type EarthImpedanceFormulation <: AbstractImpedanceFormulation end - -abstract type AbstractAdmittanceFormulation <: AbstractFormulationSet end -abstract type InsulationAdmittanceFormulation <: AbstractAdmittanceFormulation end -abstract type EarthAdmittanceFormulation <: AbstractAdmittanceFormulation end - -abstract type AbstractTransformFormulation <: AbstractFormulationSet end - -""" - FormulationSet(...) - -Constructs a specific formulation object based on the provided keyword arguments. -The system will infer the correct formulation type. -""" -FormulationSet(engine::Symbol; kwargs...) = FormulationSet(Val(engine); kwargs...) - - -""" -$(TYPEDEF) - -Abstract type representing different equivalent homogeneous earth models (EHEM). Used in the multi-dispatch implementation of [`_calc_ehem_properties!`](@ref). - -# Currently available formulations - -- [`EnforceLayer`](@ref): Effective parameters defined according to a specific earth layer. -""" -abstract type AbstractEHEMFormulation <: AbstractFormulationSet end - -abstract type AbstractFormulationOptions end - - +""" +$(TYPEDEF) + +Abstract base type for all problem definitions in the [`LineCableModels.jl`](index.md) computation framework. +""" +abstract type ProblemDefinition end + +# Formulation abstract types +abstract type AbstractFormulationSet end + +abstract type AbstractImpedanceFormulation <: AbstractFormulationSet end +abstract type InternalImpedanceFormulation <: AbstractImpedanceFormulation end +abstract type InsulationImpedanceFormulation <: AbstractImpedanceFormulation end +abstract type EarthImpedanceFormulation <: AbstractImpedanceFormulation end + +abstract type AbstractAdmittanceFormulation <: AbstractFormulationSet end +abstract type InsulationAdmittanceFormulation <: AbstractAdmittanceFormulation end +abstract type EarthAdmittanceFormulation <: AbstractAdmittanceFormulation end + +abstract type AbstractTransformFormulation <: AbstractFormulationSet end + +abstract type AmpacityFormulation <: AbstractFormulationSet end +""" + FormulationSet(...) + +Constructs a specific formulation object based on the provided keyword arguments. +The system will infer the correct formulation type. +""" +FormulationSet(engine::Symbol; kwargs...) = FormulationSet(Val(engine); kwargs...) + + +""" +$(TYPEDEF) + +Abstract type representing different equivalent homogeneous earth models (EHEM). Used in the multi-dispatch implementation of [`_calc_ehem_properties!`](@ref). + +# Currently available formulations + +- [`EnforceLayer`](@ref): Effective parameters defined according to a specific earth layer. +""" +abstract type AbstractEHEMFormulation <: AbstractFormulationSet end + +abstract type AbstractFormulationOptions end + + diff --git a/src/engine/workspace.jl b/src/engine/workspace.jl index 1332596a..c2beda34 100644 --- a/src/engine/workspace.jl +++ b/src/engine/workspace.jl @@ -1,200 +1,200 @@ -""" -$(TYPEDEF) - -A container for the flattened, type-stable data arrays derived from a -[`LineParametersProblem`](@ref). This struct serves as the primary data source -for all subsequent computational steps. - -# Fields -$(TYPEDFIELDS) -""" -@kwdef struct EMTWorkspace{T <: REALSCALAR} - "Vector of frequency values [Hz]." - freq::Vector{T} - "Vector of complex frequency values cast as `σ + jω` [rad/s]." - jω::Vector{Complex{T}} - "Vector of horizontal positions [m]." - horz::Vector{T} - "Vector of horizontal separations [m]." - horz_sep::Matrix{T} - "Vector of vertical positions [m]." - vert::Vector{T} - "Vector of internal conductor radii [m]." - r_in::Vector{T} - "Vector of external conductor radii [m]." - r_ext::Vector{T} - "Vector of internal insulator radii [m]." - r_ins_in::Vector{T} - "Vector of external insulator radii [m]." - r_ins_ext::Vector{T} - "Vector of conductor resistivities [Ω·m]." - rho_cond::Vector{T} - "Vector of conductor temperature coefficients [1/°C]." - alpha_cond::Vector{T} - "Vector of conductor relative permeabilities." - mu_cond::Vector{T} - "Vector of conductor relative permittivities." - eps_cond::Vector{T} - "Vector of insulator resistivities [Ω·m]." - rho_ins::Vector{T} - "Vector of insulator relative permeabilities." - mu_ins::Vector{T} - "Vector of insulator relative permittivities." - eps_ins::Vector{T} - "Vector of insulator loss tangents." - tan_ins::Vector{T} - "Vector of phase mapping indices." - phase_map::Vector{Int} - "Vector of cable mapping indices." - cable_map::Vector{Int} - "Effective earth resistivity (layers × freq)." - rho_g::Matrix{T} - "Effective earth permittivity (layers × freq)." - eps_g::Matrix{T} - "Effective earth permeability (layers × freq)." - mu_g::Matrix{T} - "Operating temperature [°C]." - temp::T - "Number of frequency samples." - n_frequencies::Int - "Number of phases in the system." - n_phases::Int - "Number of cables in the system." - n_cables::Int - "Full component-based Z matrix (before bundling/reduction)." - Z::Array{Complex{T}, 3} - "Full component-based P matrix (before bundling/reduction)." - P::Array{Complex{T}, 3} - "Full internal impedance matrix (before bundling/reduction)." - Zin::Array{Complex{T}, 3} - "Full internal potential coefficient matrix (before bundling/reduction)." - Pin::Array{Complex{T}, 3} - "Earth impedance matrix (n_cables x n_cables)." - Zg::Array{Complex{T}, 3} - "Earth potential coefficient matrix (n_cables x n_cables)." - Pg::Array{Complex{T}, 3} -end - - - -""" -$(TYPEDSIGNATURES) - -Initializes and populates the [`EMTWorkspace`](@ref) by normalizing a -[`LineParametersProblem`](@ref) into flat, type-stable arrays. -""" -function init_workspace( - problem::LineParametersProblem{T}, - formulation::EMTFormulation, -) where {T} - - opts = formulation.options - - system = problem.system - n_frequencies = length(problem.frequencies) - n_phases = sum(length(cable.design_data.components) for cable in system.cables) - n_cables = system.num_cables - - # Pre-allocate 1D arrays - freq = Vector{T}(undef, n_frequencies) - jω = Vector{Complex{T}}(undef, n_frequencies) - horz = Vector{T}(undef, n_phases) - horz_sep = Matrix{T}(undef, n_phases, n_phases) - vert = Vector{T}(undef, n_phases) - r_in = Vector{T}(undef, n_phases) - r_ext = Vector{T}(undef, n_phases) - r_ins_in = Vector{T}(undef, n_phases) - r_ins_ext = Vector{T}(undef, n_phases) - rho_cond = Vector{T}(undef, n_phases) - alpha_cond = Vector{T}(undef, n_phases) - mu_cond = Vector{T}(undef, n_phases) - eps_cond = Vector{T}(undef, n_phases) - rho_ins = Vector{T}(undef, n_phases) - mu_ins = Vector{T}(undef, n_phases) - eps_ins = Vector{T}(undef, n_phases) - tan_ins = Vector{T}(undef, n_phases) # Loss tangent for insulator - phase_map = Vector{Int}(undef, n_phases) - cable_map = Vector{Int}(undef, n_phases) - Z = - opts.store_primitive_matrices ? - zeros(Complex{T}, n_phases, n_phases, n_frequencies) : nothing - P = - opts.store_primitive_matrices ? - zeros(Complex{T}, n_phases, n_phases, n_frequencies) : nothing - Zin = - opts.store_primitive_matrices ? - zeros(Complex{T}, n_phases, n_phases, n_frequencies) : nothing - Pin = - opts.store_primitive_matrices ? - zeros(Complex{T}, n_phases, n_phases, n_frequencies) : nothing - Zg = - opts.store_primitive_matrices ? - zeros(Complex{T}, n_cables, n_cables, n_frequencies) : nothing - Pg = - opts.store_primitive_matrices ? - zeros(Complex{T}, n_cables, n_cables, n_frequencies) : nothing - - # Fill arrays, ensuring type promotion - freq .= problem.frequencies - jω .= 1im * 2π * freq - - idx = 0 - for (cable_idx, cable) in enumerate(system.cables) - for (comp_idx, component) in enumerate(cable.design_data.components) - idx += 1 - # Geometric properties - horz[idx] = T(cable.horz) - vert[idx] = T(cable.vert) - r_in[idx] = T(component.conductor_group.radius_in) - r_ext[idx] = T(component.conductor_group.radius_ext) - r_ins_in[idx] = T(component.insulator_group.radius_in) - r_ins_ext[idx] = T(component.insulator_group.radius_ext) - - # Material properties - rho_cond[idx] = T(component.conductor_props.rho) - alpha_cond[idx] = T(component.conductor_props.alpha) - mu_cond[idx] = T(component.conductor_props.mu_r) - eps_cond[idx] = T(component.conductor_props.eps_r) - rho_ins[idx] = T(component.insulator_props.rho) - mu_ins[idx] = T(component.insulator_props.mu_r) - eps_ins[idx] = T(component.insulator_props.eps_r) - - # Calculate loss factor from resistivity - ω = 2 * π * f₀ # Using default frequency - C_eq = T(component.insulator_group.shunt_capacitance) - G_eq = T(component.insulator_group.shunt_conductance) - tan_ins[idx] = G_eq / (ω * C_eq) - - # Mapping - phase_map[idx] = cable.conn[comp_idx] - cable_map[idx] = cable_idx - end - end - - # Precompute Euclidean distances, use max radius for self-distances - _calc_horz_sep!(horz_sep, horz, r_ext, r_ins_ext, cable_map) - - (rho_g, eps_g, mu_g) = _get_earth_data( - formulation.equivalent_earth, - problem.earth_props, - freq, - T, - ) - - temp = T(problem.temperature) - - # Construct and return the EMTWorkspace struct - return EMTWorkspace{T}( - freq = freq, jω = jω, - horz = horz, horz_sep = horz_sep, vert = vert, - r_in = r_in, r_ext = r_ext, - r_ins_in = r_ins_in, r_ins_ext = r_ins_ext, - rho_cond = rho_cond, alpha_cond = alpha_cond, mu_cond = mu_cond, - eps_cond = eps_cond, rho_ins = rho_ins, mu_ins = mu_ins, eps_ins = eps_ins, - tan_ins = tan_ins, phase_map = phase_map, cable_map = cable_map, rho_g = rho_g, - eps_g = eps_g, mu_g = mu_g, - temp = temp, n_frequencies = n_frequencies, n_phases = n_phases, - n_cables = n_cables, Z = Z, P = P, Zin = Zin, Pin = Pin, Zg = Zg, - Pg = Pg, - ) -end +""" +$(TYPEDEF) + +A container for the flattened, type-stable data arrays derived from a +[`LineParametersProblem`](@ref). This struct serves as the primary data source +for all subsequent computational steps. + +# Fields +$(TYPEDFIELDS) +""" +@kwdef struct EMTWorkspace{T <: REALSCALAR} + "Vector of frequency values [Hz]." + freq::Vector{T} + "Vector of complex frequency values cast as `σ + jω` [rad/s]." + jω::Vector{Complex{T}} + "Vector of horizontal positions [m]." + horz::Vector{T} + "Vector of horizontal separations [m]." + horz_sep::Matrix{T} + "Vector of vertical positions [m]." + vert::Vector{T} + "Vector of internal conductor radii [m]." + r_in::Vector{T} + "Vector of external conductor radii [m]." + r_ext::Vector{T} + "Vector of internal insulator radii [m]." + r_ins_in::Vector{T} + "Vector of external insulator radii [m]." + r_ins_ext::Vector{T} + "Vector of conductor resistivities [Ω·m]." + rho_cond::Vector{T} + "Vector of conductor temperature coefficients [1/°C]." + alpha_cond::Vector{T} + "Vector of conductor relative permeabilities." + mu_cond::Vector{T} + "Vector of conductor relative permittivities." + eps_cond::Vector{T} + "Vector of insulator resistivities [Ω·m]." + rho_ins::Vector{T} + "Vector of insulator relative permeabilities." + mu_ins::Vector{T} + "Vector of insulator relative permittivities." + eps_ins::Vector{T} + "Vector of insulator loss tangents." + tan_ins::Vector{T} + "Vector of phase mapping indices." + phase_map::Vector{Int} + "Vector of cable mapping indices." + cable_map::Vector{Int} + "Effective earth resistivity (layers × freq)." + rho_g::Matrix{T} + "Effective earth permittivity (layers × freq)." + eps_g::Matrix{T} + "Effective earth permeability (layers × freq)." + mu_g::Matrix{T} + "Operating temperature [°C]." + temp::T + "Number of frequency samples." + n_frequencies::Int + "Number of phases in the system." + n_phases::Int + "Number of cables in the system." + n_cables::Int + "Full component-based Z matrix (before bundling/reduction)." + Z::Array{Complex{T}, 3} + "Full component-based P matrix (before bundling/reduction)." + P::Array{Complex{T}, 3} + "Full internal impedance matrix (before bundling/reduction)." + Zin::Array{Complex{T}, 3} + "Full internal potential coefficient matrix (before bundling/reduction)." + Pin::Array{Complex{T}, 3} + "Earth impedance matrix (n_cables x n_cables)." + Zg::Array{Complex{T}, 3} + "Earth potential coefficient matrix (n_cables x n_cables)." + Pg::Array{Complex{T}, 3} +end + + + +""" +$(TYPEDSIGNATURES) + +Initializes and populates the [`EMTWorkspace`](@ref) by normalizing a +[`LineParametersProblem`](@ref) into flat, type-stable arrays. +""" +function init_workspace( + problem::LineParametersProblem{T}, + formulation::EMTFormulation, +) where {T} + + opts = formulation.options + + system = problem.system + n_frequencies = length(problem.frequencies) + n_phases = sum(length(cable.design_data.components) for cable in system.cables) + n_cables = system.num_cables + + # Pre-allocate 1D arrays + freq = Vector{T}(undef, n_frequencies) + jω = Vector{Complex{T}}(undef, n_frequencies) + horz = Vector{T}(undef, n_phases) + horz_sep = Matrix{T}(undef, n_phases, n_phases) + vert = Vector{T}(undef, n_phases) + r_in = Vector{T}(undef, n_phases) + r_ext = Vector{T}(undef, n_phases) + r_ins_in = Vector{T}(undef, n_phases) + r_ins_ext = Vector{T}(undef, n_phases) + rho_cond = Vector{T}(undef, n_phases) + alpha_cond = Vector{T}(undef, n_phases) + mu_cond = Vector{T}(undef, n_phases) + eps_cond = Vector{T}(undef, n_phases) + rho_ins = Vector{T}(undef, n_phases) + mu_ins = Vector{T}(undef, n_phases) + eps_ins = Vector{T}(undef, n_phases) + tan_ins = Vector{T}(undef, n_phases) # Loss tangent for insulator + phase_map = Vector{Int}(undef, n_phases) + cable_map = Vector{Int}(undef, n_phases) + Z = + opts.store_primitive_matrices ? + zeros(Complex{T}, n_phases, n_phases, n_frequencies) : nothing + P = + opts.store_primitive_matrices ? + zeros(Complex{T}, n_phases, n_phases, n_frequencies) : nothing + Zin = + opts.store_primitive_matrices ? + zeros(Complex{T}, n_phases, n_phases, n_frequencies) : nothing + Pin = + opts.store_primitive_matrices ? + zeros(Complex{T}, n_phases, n_phases, n_frequencies) : nothing + Zg = + opts.store_primitive_matrices ? + zeros(Complex{T}, n_cables, n_cables, n_frequencies) : nothing + Pg = + opts.store_primitive_matrices ? + zeros(Complex{T}, n_cables, n_cables, n_frequencies) : nothing + + # Fill arrays, ensuring type promotion + freq .= problem.frequencies + jω .= 1im * 2π * freq + + idx = 0 + for (cable_idx, cable) in enumerate(system.cables) + for (comp_idx, component) in enumerate(cable.design_data.components) + idx += 1 + # Geometric properties + horz[idx] = T(cable.horz) + vert[idx] = T(cable.vert) + r_in[idx] = T(component.conductor_group.radius_in) + r_ext[idx] = T(component.conductor_group.radius_ext) + r_ins_in[idx] = T(component.insulator_group.radius_in) + r_ins_ext[idx] = T(component.insulator_group.radius_ext) + + # Material properties + rho_cond[idx] = T(component.conductor_props.rho) + alpha_cond[idx] = T(component.conductor_props.alpha) + mu_cond[idx] = T(component.conductor_props.mu_r) + eps_cond[idx] = T(component.conductor_props.eps_r) + rho_ins[idx] = T(component.insulator_props.rho) + mu_ins[idx] = T(component.insulator_props.mu_r) + eps_ins[idx] = T(component.insulator_props.eps_r) + + # Calculate loss factor from resistivity + ω = 2 * π * f₀ # Using default frequency + C_eq = T(component.insulator_group.shunt_capacitance) + G_eq = T(component.insulator_group.shunt_conductance) + tan_ins[idx] = G_eq / (ω * C_eq) + + # Mapping + phase_map[idx] = cable.conn[comp_idx] + cable_map[idx] = cable_idx + end + end + + # Precompute Euclidean distances, use max radius for self-distances + _calc_horz_sep!(horz_sep, horz, r_ext, r_ins_ext, cable_map) + + (rho_g, eps_g, mu_g) = _get_earth_data( + formulation.equivalent_earth, + problem.earth_props, + freq, + T, + ) + + temp = T(problem.temperature) + + # Construct and return the EMTWorkspace struct + return EMTWorkspace{T}( + freq = freq, jω = jω, + horz = horz, horz_sep = horz_sep, vert = vert, + r_in = r_in, r_ext = r_ext, + r_ins_in = r_ins_in, r_ins_ext = r_ins_ext, + rho_cond = rho_cond, alpha_cond = alpha_cond, mu_cond = mu_cond, + eps_cond = eps_cond, rho_ins = rho_ins, mu_ins = mu_ins, eps_ins = eps_ins, + tan_ins = tan_ins, phase_map = phase_map, cable_map = cable_map, rho_g = rho_g, + eps_g = eps_g, mu_g = mu_g, + temp = temp, n_frequencies = n_frequencies, n_phases = n_phases, + n_cables = n_cables, Z = Z, P = P, Zin = Zin, Pin = Pin, Zg = Zg, + Pg = Pg, + ) +end diff --git a/src/importexport/ImportExport.jl b/src/importexport/ImportExport.jl index ae0a75d0..ca2b643a 100644 --- a/src/importexport/ImportExport.jl +++ b/src/importexport/ImportExport.jl @@ -1,73 +1,73 @@ -""" - LineCableModels.ImportExport - -The [`ImportExport`](@ref) module provides methods for serializing and deserializing data structures in [`LineCableModels.jl`](index.md), and data exchange with external programs. - -# Overview - -This module provides functionality for: - -- Saving and loading cable designs and material libraries to/from JSON and other formats. -- Exporting cable system models to PSCAD and ATP formats. -- Serializing custom types with special handling for measurements and complex numbers. - -The module implements a generic serialization framework with automatic type reconstruction -and proper handling of Julia-specific types like `Measurement` objects and `Inf`/`NaN` values. - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module ImportExport - -# Export public API -export export_data -export read_data -export save -export load! - - -# Module-specific dependencies -using ..Commons -using ..Utils: display_path, to_nominal, resolve_T, coerce_to_T -using ..Materials: Material, MaterialsLibrary -using ..EarthProps: EarthModel -using ..DataModel: CablesLibrary, CableDesign, CableComponent, ConductorGroup, - InsulatorGroup, WireArray, Strip, Tubular, Semicon, Insulator, LineCableSystem, - NominalData -import ..Engine: LineParameters, SeriesImpedance, ShuntAdmittance -using Measurements -using EzXML -using Dates -using Printf # For ATP export -using JSON3 -using Serialization # For .jls format -using LinearAlgebra - - -""" -$(TYPEDSIGNATURES) - -Export [`LineCableModels`](@ref) data for use in different EMT-type programs. - -# Methods - -$(METHODLIST) -""" -# function export_data end -export_data(backend::Symbol, args...; kwargs...) = - export_data(Val(backend), args...; kwargs...) - -include("serialize.jl") -include("deserialize.jl") -include("cableslibrary.jl") -include("materialslibrary.jl") -include("pscad.jl") -include("atp.jl") -include("tralin.jl") - -end # module ImportExport +""" + LineCableModels.ImportExport + +The [`ImportExport`](@ref) module provides methods for serializing and deserializing data structures in [`LineCableModels.jl`](index.md), and data exchange with external programs. + +# Overview + +This module provides functionality for: + +- Saving and loading cable designs and material libraries to/from JSON and other formats. +- Exporting cable system models to PSCAD and ATP formats. +- Serializing custom types with special handling for measurements and complex numbers. + +The module implements a generic serialization framework with automatic type reconstruction +and proper handling of Julia-specific types like `Measurement` objects and `Inf`/`NaN` values. + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module ImportExport + +# Export public API +export export_data +export read_data +export save +export load! + + +# Module-specific dependencies +using ..Commons +using ..Utils: display_path, to_nominal, resolve_T, coerce_to_T +using ..Materials: Material, MaterialsLibrary +using ..EarthProps: EarthModel +using ..DataModel: CablesLibrary, CableDesign, CableComponent, ConductorGroup, + InsulatorGroup, WireArray, Strip, Tubular, Semicon, Insulator, LineCableSystem, + NominalData +import ..Engine: LineParameters, SeriesImpedance, ShuntAdmittance +using Measurements +using EzXML +using Dates +using Printf # For ATP export +using JSON3 +using Serialization # For .jls format +using LinearAlgebra + + +""" +$(TYPEDSIGNATURES) + +Export [`LineCableModels`](@ref) data for use in different EMT-type programs. + +# Methods + +$(METHODLIST) +""" +# function export_data end +export_data(backend::Symbol, args...; kwargs...) = + export_data(Val(backend), args...; kwargs...) + +include("serialize.jl") +include("deserialize.jl") +include("cableslibrary.jl") +include("materialslibrary.jl") +include("pscad.jl") +include("atp.jl") +include("tralin.jl") + +end # module ImportExport diff --git a/src/importexport/atp.jl b/src/importexport/atp.jl index c86d0e85..b8368363 100644 --- a/src/importexport/atp.jl +++ b/src/importexport/atp.jl @@ -1,554 +1,554 @@ -"""$(TYPEDSIGNATURES) - -Export a [`LineCableSystem`](@ref) to an **ATPDraw‑compatible** XML file (LCC component with input data). - -This routine serializes the cable system geometry (positions and outer radii) and the -already‑computed, frequency‑specific equivalent parameters of each cable component to the -ATPDraw XML schema. The result is written to disk and the absolute file path is returned -on success. - -# Arguments - -- `::Val{:atp}`: Backend selector for the ATP/ATPDraw exporter. -- `cable_system::LineCableSystem`: The system to export. Each entry in `cable_system.cables` provides one phase position and its associated [`CableDesign`](@ref). The number of phases exported equals `length(cable_system.cables)`. -- `earth_props::EarthModel`: Ground model used to populate ATP soil parameters. The exporter -uses the **last** layer’s base resistivity as *Grnd resis*. -- `base_freq::Number = f₀` \\[Hz\\]: System frequency written to ATP (`SysFreq`) and stored in component metadata. *This exporter does not recompute R/L/C/G; it writes the values as -present in the groups/components at the time of export.* -- `file_name::String = "*_export.xml"`: Output file name or path. If a relative path is given, it is resolved against the exporter’s source directory. The absolute path of the saved file is returned. - -# Behavior - -1. Create the ATPDraw `` root and header and insert a single **LCC** component with - `NumPhases = length(cable_system.cables)`. -2. For each [`CablePosition`](@ref) in `cable_system.cables`: - - * Write a `` element with: - - * `NumCond` = number of [`CableComponent`](@ref)s in the design, - * `Rout` = outermost radius of the design (m), - * `PosX`, `PosY` = cable coordinates (m). -3. For each [`CableComponent`](@ref) inside a cable: - - * Write one `` element with fields (all per unit length): - - * `Rin`, `Rout` — from the component’s conductor group, - * `rho` — conductor equivalence via [`calc_equivalent_rho`](@ref), - * `muC` — conductor relative permeability via [`calc_equivalent_mu`](@ref), - * `muI` — insulator relative permeability (taken from the first insulating layer’s material), - * `epsI` — insulation relative permittivity via [`calc_equivalent_eps`](@ref), - * `Cext`, `Gext` — shunt capacitance and conductance from the component’s insulator group. -4. Soil resistivity is written as *Grnd resis* using `earth_props.layers[end].base_rho_g`. -5. The XML is pretty‑printed and written to `file_name`. On I/O error, the function logs an error and returns `nothing`. - -# Units - -Units are printed in the XML file according to the ATPDraw specifications: - -- Radii (`Rin`, `Rout`, `Rout` of cable): \\[m\\] -- Coordinates (`PosX`, `PosY`): \\[m\\] -- Length (`Length` tag): \\[m\\] -- Frequency (`SysFreq`/`Freq`): \\[Hz\\] -- Resistivity (`rho`, *Grnd resis*): \\[Ω·m\\] -- Relative permittivity (`epsI`) / permeability (`muC`, `muI`): \\[dimensionless\\] -- Shunt capacitance (`Cext`): \\[F/m\\] -- Shunt conductance (`Gext`): \\[S/m\\] - -# Notes - -* The exporter assumes each component’s equivalent parameters (R/G/C and derived ρ/ε/μ) were - already computed by the design/group constructors at the operating conditions of interest. -* Mixed numeric types are supported; values are stringified for XML output. When using - uncertainty types (e.g., `Measurements.Measurement`), the uncertainty is removed. -* Overlap checks between cables are enforced when building the system, not during export. - -# Examples - -```julia -# Build or load a system `sys` and an earth model `earth` -file = $(FUNCTIONNAME)(Val(:atp), sys, earth; base_freq = 50.0, - file_name = "system_id_export.xml") -println("Exported to: ", file) -``` - -# See also - -* [`LineCableSystem`](@ref), [`CablePosition`](@ref), [`CableComponent`](@ref) -* [`EarthModel`](@ref) -* [`calc_equivalent_rho`](@ref), [`calc_equivalent_mu`](@ref), [`calc_equivalent_eps`](@ref) - """ -function export_data(::Val{:atp}, - cable_system::LineCableSystem, - earth_props::EarthModel; - base_freq = f₀, - file_name::Union{String, Nothing} = nothing, -)::Union{String, Nothing} - - function _set_attributes!(element::EzXML.Node, attrs::Dict) - for (k, v) in attrs - element[k] = string(v) - end - end - # --- 1. Setup Constants and Variables --- - if isnothing(file_name) - # caller didn't supply a name -> derive from cable_system if present - file_name = joinpath(@__DIR__, "$(cable_system.system_id)_export.xml") - else - # caller supplied a path/name -> respect directory, but prepend system_id to basename - requested = isabspath(file_name) ? file_name : joinpath(@__DIR__, file_name) - if isnothing(cable_system) - file_name = requested - else - dir = dirname(requested) - base = basename(requested) - file_name = joinpath(dir, "$(cable_system.system_id)_$base") - end - end - - num_phases = length(cable_system.cables) - - # Create XML Structure and LCC Component - doc = XMLDocument() - project = ElementNode("project") - setroot!(doc, project) - _set_attributes!( - project, - Dict("Application" => "ATPDraw", "Version" => "7.3", "VersionXML" => "1"), - ) - header = addelement!(project, "header") - _set_attributes!( - header, - Dict( - "Timestep" => 1e-6, - "Tmax" => 0.1, - "XOPT" => 0, - "COPT" => 0, - "SysFreq" => base_freq, - "TopLeftX" => 200, - "TopLeftY" => 0, - ), - ) - objects = addelement!(project, "objects") - variables = addelement!(project, "variables") - comp = addelement!(objects, "comp") - _set_attributes!( - comp, - Dict( - "Name" => "LCC", - "Id" => "$(cable_system.system_id)_1", - "Capangl" => 90, - "CapPosX" => -10, - "CapPosY" => -25, - "Caption" => "", - ), - ) - comp_content = addelement!(comp, "comp_content") - _set_attributes!( - comp_content, - Dict( - "PosX" => 280, - "PosY" => 360, - "NumPhases" => num_phases, - "Icon" => "default", - "SinglePhaseIcon" => "true", - ), - ) - for side in ["IN", "OUT"] - y0 = -20 - for k in 1:num_phases - y0 += 10 - node = addelement!(comp_content, "node") - _set_attributes!( - node, - Dict( - "Name" => "$side$k", - "Value" => "C$(k)$(side=="IN" ? "SND" : "RCV")", - "UserNamed" => "true", - "Kind" => k, - "PosX" => side == "IN" ? -20 : 20, - "PosY" => y0, - "NamePosX" => 0, - "NamePosY" => 0, - ), - ) - end - end - - line_length = to_nominal(cable_system.line_length) - soil_rho = to_nominal(earth_props.layers[end].base_rho_g) - for (name, value) in - [("Length", line_length), ("Freq", base_freq), ("Grnd resis", soil_rho)] - data_node = addelement!(comp_content, "data") - _set_attributes!(data_node, Dict("Name" => name, "Value" => value)) - end - - # Populate the LCC Sub-structure with CORRECTLY Structured Cable Data - lcc_node = addelement!(comp, "LCC") - _set_attributes!( - lcc_node, - Dict( - "NumPhases" => num_phases, - "IconLength" => "true", - "LineCablePipe" => 2, - "ModelType" => 1, - ), - ) - cable_header = addelement!(lcc_node, "cable_header") - _set_attributes!( - cable_header, - Dict("InAirGrnd" => 1, "MatrixOutput" => "true", "ExtraCG" => "$(num_phases)"), - ) - - for (k, cable) in enumerate(cable_system.cables) - cable_node = addelement!(cable_header, "cable") - - num_components = length(cable.design_data.components) - outermost_radius = - to_nominal(cable.design_data.components[end].insulator_group.radius_ext) - - _set_attributes!( - cable_node, - Dict( - "NumCond" => num_components, - "Rout" => outermost_radius, - "PosX" => to_nominal(cable.horz), - "PosY" => to_nominal(cable.vert), - ), - ) - - for component in cable.design_data.components - conductor_node = addelement!(cable_node, "conductor") - - cond_group = component.conductor_group - cond_props = component.conductor_props - ins_group = component.insulator_group - ins_props = component.insulator_props - - rho_eq = (cond_props.rho) - mu_r_cond = (cond_props.mu_r) - mu_r_ins = (ins_props.mu_r) - eps_eq = (ins_props.eps_r) - - _set_attributes!( - conductor_node, - Dict( - "Rin" => to_nominal(cond_group.radius_in), - "Rout" => to_nominal(cond_group.radius_ext), - "rho" => to_nominal(rho_eq), - "muC" => to_nominal(mu_r_cond), - "muI" => to_nominal(mu_r_ins), - "epsI" => to_nominal(eps_eq), - "Cext" => to_nominal(ins_group.shunt_capacitance), - "Gext" => to_nominal(ins_group.shunt_conductance), - ), - ) - end - end - - # Finalize and Write to File - _set_attributes!(variables, Dict("NumSim" => 1, "IOPCVP" => 0, "UseParser" => "false")) - - try - open(file_name, "w") do fid - prettyprint(fid, doc) - end - @info "XML file saved to: $(display_path(file_name))" - return file_name - catch e - @error "Failed to write XML file '$(display_path(file_name))'" exception = - (e, catch_backtrace()) - return nothing - end -end - - - -# TODO: Develop `.lis` import and tests -# Issue URL: https://github.com/Electa-Git/LineCableModels.jl/issues/12 -function read_data end -# I TEST THEREFORE I EXIST -# I DON´T TEST THEREFORE GO TO THE GARBAGE -# """ -# read_atp_data(file_name::String, cable_system::LineCableSystem) - -# Reads an ATP `.lis` output file, extracts the Ze and Zi matrices, and dynamically -# reorders them to a grouped-by-phase format based on the provided `cable_system` -# structure. It correctly handles systems with a variable number of components per cable. - -# # Arguments -# - `file_name`: The path to the `.lis` file. -# - `cable_system`: The `LineCableSystem` object corresponding to the data in the file. - -# # Returns -# - `Array{T, 2}`: A 2D complex matrix representing the total reordered series -# impedance `Z = Ze + Zi` for a single frequency. -# - `nothing`: If the file cannot be found, parsed, or if the matrix dimensions in the -# file do not match the provided `cable_system` structure. -# """ -# function read_data(::Val{:atp}, -# cable_system::LineCableSystem, -# freq::AbstractFloat; -# file_name::String="$(cable_system.system_id)_1.lis" -# )::Union{Array{COMPLEXSCALAR,2},Nothing} -# # --- Inner helper function to parse a matrix block from text lines --- -# function parse_block(block_lines::Vector{String}) -# data_lines = filter(line -> !isempty(strip(line)), block_lines) -# if isempty(data_lines) -# return Matrix{ComplexF64}(undef, 0, 0) -# end -# matrix_size = length(split(data_lines[1])) -# real_parts = zeros(Float64, matrix_size, matrix_size) -# imag_parts = zeros(Float64, matrix_size, matrix_size) -# row_counter = 1 -# for i in 1:2:length(data_lines) -# if i + 1 > length(data_lines) -# break -# end -# real_line, imag_line = data_lines[i], data_lines[i+1] -# try -# real_parts[row_counter, :] = [parse(Float64, s) for s in split(real_line)[1:matrix_size]] -# imag_parts[row_counter, :] = [parse(Float64, s) for s in split(imag_line)[1:matrix_size]] -# catch e -# @error "Parsing failed" exception = (e, catch_backtrace()) -# return nothing -# end -# row_counter += 1 -# if row_counter > matrix_size -# break -# end -# end -# return real_parts + im * imag_parts -# end - -# # --- Main Function Logic --- -# if !isfile(file_name) -# @error "File not found: $file_name" -# return nothing -# end -# lines = readlines(file_name) -# ze_start_idx = findfirst(occursin.("Earth impedance [Ze]", lines)) -# zi_start_idx = findfirst(occursin.("Conductor internal impedance [Zi]", lines)) -# if isnothing(ze_start_idx) || isnothing(zi_start_idx) -# @error "Could not find Ze/Zi headers." -# return nothing -# end - -# Ze = parse_block(lines[ze_start_idx+1:zi_start_idx-1]) -# Zi = parse_block(lines[zi_start_idx+1:end]) -# if isnothing(Ze) || isnothing(Zi) -# return nothing -# end - -# # --- DYNAMICALLY GENERATE PERMUTATION INDICES (Numerical Method) --- -# component_counts = [length(c.design_data.components) for c in cable_system.cables] -# total_conductors = sum(component_counts) -# num_phases = length(component_counts) -# max_components = isempty(component_counts) ? 0 : maximum(component_counts) - -# if size(Ze, 1) != total_conductors -# @error "Matrix size from file ($(size(Ze,1))x$(size(Ze,1))) does not match total components in cable_system ($total_conductors)." -# return nothing -# end - -# num_conductors_per_type = [sum(c >= i for c in component_counts) for i in 1:max_components] -# type_offsets = cumsum([0; num_conductors_per_type[1:end-1]]) - -# permutation_indices = Int[] -# sizehint!(permutation_indices, total_conductors) -# instance_counters = ones(Int, max_components) -# for phase_idx in 1:num_phases -# for comp_type_idx in 1:component_counts[phase_idx] -# instance = instance_counters[comp_type_idx] -# original_idx = type_offsets[comp_type_idx] + instance -# push!(permutation_indices, original_idx) -# instance_counters[comp_type_idx] += 1 -# end -# end - -# Ze_reordered = Ze[permutation_indices, permutation_indices] -# Zi_reordered = Zi[permutation_indices, permutation_indices] - -# return Ze_reordered + Zi_reordered -# end - - -"""$(TYPEDSIGNATURES) - -Export calculated [`LineParameters`](@ref) (series impedance **Z** and shunt admittance **Y**) to an **compliant** `ZY` XML file. - -This routine writes the complex **Z** and **Y** matrices versus frequency into a compact XML -structure understood by external tools. Rows are emitted as comma‑separated complex entries -(`R+Xi` / `G+Bi`) with one ``/`` block per frequency sample. - -# Arguments - -- `::Val{:atp}`: Backend selector for the ATP/ATPDraw ZY exporter. -- `line_params::LineParameters`: Object holding the frequency‑dependent matrices `Z[:,:,k]`, `Y[:,:,k]`, and `f[k]` in `line_params.f`. -- `file_name::String = "ZY_export.xml"`: Output file name or path. If relative, it is resolved against the exporter’s source directory. The absolute path of the saved file is returned. -- `cable_system::Union{LineCableSystem,Nothing} = nothing`: Optional system used only to derive a default name. When provided and `file_name` is not overridden, the exporter uses `"\$(cable_system.system_id)_ZY_export.xml"`. - -# Behavior - -1. The root tag `` includes `NumPhases`, `Length` (fixed to `1.0`), and format attributes `ZFmt="R+Xi"`, `YFmt="G+Bi"`. -2. For each frequency `fᵏ = line_params.f[k]`: - - * Emit a `` block with `num_phases` lines, each line the `k`‑th slice of row `i` formatted as `real(Z[i,j,k]) + imag(Z[i,j,k])i`. - * Emit a `` block in the same fashion (default `G+Bi`). -3. Close the `` element and write to disk. On I/O error the function logs and returns `nothing`. - -# Units - -Units are printed in the XML file according to the ATPDraw specifications: - -- `freq` (XML `Freq` attribute): \\[Hz\\] -- `Z` entries: \\[Ω/km\\] (per unit length) -- `Y` entries: \\[S/km\\] (per unit length) when `YFmt = "G+Bi"` -- XML `Length` attribute: \\[m\\] - -# Notes - -- The exporter assumes `size(line_params.Z, 1) == size(line_params.Z, 2) == size(line_params.Y, 1) == size(line_params.Y, 2)` and `length(line_params.f) == size(Z,3) == size(Y,3)`. -- Numeric types are stringified; mixed numeric backends (e.g., with uncertainties) are acceptable as long as they can be printed via `@sprintf`. -- This exporter **does not** modify or recompute matrices; it serializes exactly what is in `line_params`. - -# Examples - -```julia -# Z, Y, f have already been computed into `lp::LineParameters` -file = $(FUNCTIONNAME)(:atp, lp; file_name = "ZY_export.xml") -println("Exported ZY to: ", file) - -# Naming based on a cable system -file2 = $(FUNCTIONNAME)(:atp, lp; cable_system = sys) -println("Exported ZY to: ", file2) # => "\$(sys.system_id)_ZY_export.xml" -``` - -# See also - -* [`LineParameters`](@ref) -* [`LineCableSystem`](@ref) -* [`export_data(::Val{:atp}, cable_system, ...)`](@ref) — exporter that writes full LCC input data - """ -function export_data(::Val{:atp}, - line_params::LineParameters; - file_name::Union{String, Nothing} = nothing, - cable_system::Union{LineCableSystem, Nothing} = nothing, -)::Union{String, Nothing} - - # Resolve final file_name while preserving any user-supplied path. - if isnothing(file_name) - # caller didn't supply a name -> derive from cable_system if present - if isnothing(cable_system) - file_name = joinpath(@__DIR__, "ZY_export.xml") - else - file_name = joinpath(@__DIR__, "$(cable_system.system_id)_ZY_export.xml") - end - else - # caller supplied a path/name -> respect directory, but prepend system_id to basename if cable_system provided - requested = isabspath(file_name) ? file_name : joinpath(@__DIR__, file_name) - if isnothing(cable_system) - file_name = requested - else - dir = dirname(requested) - base = basename(requested) - file_name = joinpath(dir, "$(cable_system.system_id)_$base") - end - end - - freq = line_params.f - - @debug ("ZY export called", - :method => "ZY", - :cable_system_isnothing => isnothing(cable_system), - :cable_system_type => (isnothing(cable_system) ? :nothing : typeof(cable_system)), - :file_name_in => file_name) - - cable_length = isnothing(cable_system) ? 1.0 : to_nominal(cable_system.line_length) - atp_format = "G+Bi" - # file_name = isabspath(file_name) ? file_name : joinpath(@__DIR__, file_name) - - open(file_name, "w") do fid - num_phases = size(line_params.Z, 1) - y_fmt = (atp_format == "C") ? "C" : "G+Bi" - - @printf( - fid, - "\n", - num_phases, - cable_length, - y_fmt - ) - - # --- Z Matrix Printing --- - for (k, freq_val) in enumerate(freq) - @printf(fid, " \n", to_nominal(freq_val)) - for i in 1:num_phases - row_str = join( - [ - @sprintf( - "%.16E%+.16Ei", - to_nominal(real(line_params.Z[i, j, k])), - to_nominal(imag(line_params.Z[i, j, k])) - ) for j in 1:num_phases - ], - ",", - ) - println(fid, row_str) - end - @printf(fid, " \n") - end - - # --- Y Matrix Printing --- - if atp_format == "C" - freq1 = to_nominal(freq[1]) - @printf(fid, " \n", freq1) - for i in 1:num_phases - row_str = join( - [ - @sprintf( - "%.16E", - to_nominal(imag(line_params.Y[i, j, 1]) / (2 * pi * freq1)) - ) for j in 1:num_phases - ], - ",", - ) - println(fid, row_str) - end - @printf(fid, " \n") - else # Case for "G+Bi" - for (k, freq_val) in enumerate(freq) - @printf(fid, " \n", to_nominal(freq_val)) - for i in 1:num_phases - row_str = join( - [ - @sprintf( - "%.16E%+.16Ei", - to_nominal(real(line_params.Y[i, j, k])), - to_nominal(imag(line_params.Y[i, j, k])) - ) for j in 1:num_phases - ], - ",", - ) - println(fid, row_str) - end - @printf(fid, " \n") - end - end - - # --- Footer --- - println(fid, "") - end - try - # Use pretty print option for debugging comparisons if needed - # open(filename, "w") do io; prettyprint(io, doc); end - if isfile(file_name) - @info "XML file saved to: $(display_path(file_name))" - end - return file_name - catch e - @error "Failed to write XML file '$(display_path(file_name))': $(e)" - isa(e, SystemError) && println("SystemError details: ", e.extrainfo) - return nothing - rethrow(e) # Rethrow to indicate failure clearly - end -end +"""$(TYPEDSIGNATURES) + +Export a [`LineCableSystem`](@ref) to an **ATPDraw‑compatible** XML file (LCC component with input data). + +This routine serializes the cable system geometry (positions and outer radii) and the +already‑computed, frequency‑specific equivalent parameters of each cable component to the +ATPDraw XML schema. The result is written to disk and the absolute file path is returned +on success. + +# Arguments + +- `::Val{:atp}`: Backend selector for the ATP/ATPDraw exporter. +- `cable_system::LineCableSystem`: The system to export. Each entry in `cable_system.cables` provides one phase position and its associated [`CableDesign`](@ref). The number of phases exported equals `length(cable_system.cables)`. +- `earth_props::EarthModel`: Ground model used to populate ATP soil parameters. The exporter +uses the **last** layer’s base resistivity as *Grnd resis*. +- `base_freq::Number = f₀` \\[Hz\\]: System frequency written to ATP (`SysFreq`) and stored in component metadata. *This exporter does not recompute R/L/C/G; it writes the values as +present in the groups/components at the time of export.* +- `file_name::String = "*_export.xml"`: Output file name or path. If a relative path is given, it is resolved against the exporter’s source directory. The absolute path of the saved file is returned. + +# Behavior + +1. Create the ATPDraw `` root and header and insert a single **LCC** component with + `NumPhases = length(cable_system.cables)`. +2. For each [`CablePosition`](@ref) in `cable_system.cables`: + + * Write a `` element with: + + * `NumCond` = number of [`CableComponent`](@ref)s in the design, + * `Rout` = outermost radius of the design (m), + * `PosX`, `PosY` = cable coordinates (m). +3. For each [`CableComponent`](@ref) inside a cable: + + * Write one `` element with fields (all per unit length): + + * `Rin`, `Rout` — from the component’s conductor group, + * `rho` — conductor equivalence via [`calc_equivalent_rho`](@ref), + * `muC` — conductor relative permeability via [`calc_equivalent_mu`](@ref), + * `muI` — insulator relative permeability (taken from the first insulating layer’s material), + * `epsI` — insulation relative permittivity via [`calc_equivalent_eps`](@ref), + * `Cext`, `Gext` — shunt capacitance and conductance from the component’s insulator group. +4. Soil resistivity is written as *Grnd resis* using `earth_props.layers[end].base_rho_g`. +5. The XML is pretty‑printed and written to `file_name`. On I/O error, the function logs an error and returns `nothing`. + +# Units + +Units are printed in the XML file according to the ATPDraw specifications: + +- Radii (`Rin`, `Rout`, `Rout` of cable): \\[m\\] +- Coordinates (`PosX`, `PosY`): \\[m\\] +- Length (`Length` tag): \\[m\\] +- Frequency (`SysFreq`/`Freq`): \\[Hz\\] +- Resistivity (`rho`, *Grnd resis*): \\[Ω·m\\] +- Relative permittivity (`epsI`) / permeability (`muC`, `muI`): \\[dimensionless\\] +- Shunt capacitance (`Cext`): \\[F/m\\] +- Shunt conductance (`Gext`): \\[S/m\\] + +# Notes + +* The exporter assumes each component’s equivalent parameters (R/G/C and derived ρ/ε/μ) were + already computed by the design/group constructors at the operating conditions of interest. +* Mixed numeric types are supported; values are stringified for XML output. When using + uncertainty types (e.g., `Measurements.Measurement`), the uncertainty is removed. +* Overlap checks between cables are enforced when building the system, not during export. + +# Examples + +```julia +# Build or load a system `sys` and an earth model `earth` +file = $(FUNCTIONNAME)(Val(:atp), sys, earth; base_freq = 50.0, + file_name = "system_id_export.xml") +println("Exported to: ", file) +``` + +# See also + +* [`LineCableSystem`](@ref), [`CablePosition`](@ref), [`CableComponent`](@ref) +* [`EarthModel`](@ref) +* [`calc_equivalent_rho`](@ref), [`calc_equivalent_mu`](@ref), [`calc_equivalent_eps`](@ref) + """ +function export_data(::Val{:atp}, + cable_system::LineCableSystem, + earth_props::EarthModel; + base_freq = f₀, + file_name::Union{String, Nothing} = nothing, +)::Union{String, Nothing} + + function _set_attributes!(element::EzXML.Node, attrs::Dict) + for (k, v) in attrs + element[k] = string(v) + end + end + # --- 1. Setup Constants and Variables --- + if isnothing(file_name) + # caller didn't supply a name -> derive from cable_system if present + file_name = joinpath(@__DIR__, "$(cable_system.system_id)_export.xml") + else + # caller supplied a path/name -> respect directory, but prepend system_id to basename + requested = isabspath(file_name) ? file_name : joinpath(@__DIR__, file_name) + if isnothing(cable_system) + file_name = requested + else + dir = dirname(requested) + base = basename(requested) + file_name = joinpath(dir, "$(cable_system.system_id)_$base") + end + end + + num_phases = length(cable_system.cables) + + # Create XML Structure and LCC Component + doc = XMLDocument() + project = ElementNode("project") + setroot!(doc, project) + _set_attributes!( + project, + Dict("Application" => "ATPDraw", "Version" => "7.3", "VersionXML" => "1"), + ) + header = addelement!(project, "header") + _set_attributes!( + header, + Dict( + "Timestep" => 1e-6, + "Tmax" => 0.1, + "XOPT" => 0, + "COPT" => 0, + "SysFreq" => base_freq, + "TopLeftX" => 200, + "TopLeftY" => 0, + ), + ) + objects = addelement!(project, "objects") + variables = addelement!(project, "variables") + comp = addelement!(objects, "comp") + _set_attributes!( + comp, + Dict( + "Name" => "LCC", + "Id" => "$(cable_system.system_id)_1", + "Capangl" => 90, + "CapPosX" => -10, + "CapPosY" => -25, + "Caption" => "", + ), + ) + comp_content = addelement!(comp, "comp_content") + _set_attributes!( + comp_content, + Dict( + "PosX" => 280, + "PosY" => 360, + "NumPhases" => num_phases, + "Icon" => "default", + "SinglePhaseIcon" => "true", + ), + ) + for side in ["IN", "OUT"] + y0 = -20 + for k in 1:num_phases + y0 += 10 + node = addelement!(comp_content, "node") + _set_attributes!( + node, + Dict( + "Name" => "$side$k", + "Value" => "C$(k)$(side=="IN" ? "SND" : "RCV")", + "UserNamed" => "true", + "Kind" => k, + "PosX" => side == "IN" ? -20 : 20, + "PosY" => y0, + "NamePosX" => 0, + "NamePosY" => 0, + ), + ) + end + end + + line_length = to_nominal(cable_system.line_length) + soil_rho = to_nominal(earth_props.layers[end].base_rho_g) + for (name, value) in + [("Length", line_length), ("Freq", base_freq), ("Grnd resis", soil_rho)] + data_node = addelement!(comp_content, "data") + _set_attributes!(data_node, Dict("Name" => name, "Value" => value)) + end + + # Populate the LCC Sub-structure with CORRECTLY Structured Cable Data + lcc_node = addelement!(comp, "LCC") + _set_attributes!( + lcc_node, + Dict( + "NumPhases" => num_phases, + "IconLength" => "true", + "LineCablePipe" => 2, + "ModelType" => 1, + ), + ) + cable_header = addelement!(lcc_node, "cable_header") + _set_attributes!( + cable_header, + Dict("InAirGrnd" => 1, "MatrixOutput" => "true", "ExtraCG" => "$(num_phases)"), + ) + + for (k, cable) in enumerate(cable_system.cables) + cable_node = addelement!(cable_header, "cable") + + num_components = length(cable.design_data.components) + outermost_radius = + to_nominal(cable.design_data.components[end].insulator_group.radius_ext) + + _set_attributes!( + cable_node, + Dict( + "NumCond" => num_components, + "Rout" => outermost_radius, + "PosX" => to_nominal(cable.horz), + "PosY" => to_nominal(cable.vert), + ), + ) + + for component in cable.design_data.components + conductor_node = addelement!(cable_node, "conductor") + + cond_group = component.conductor_group + cond_props = component.conductor_props + ins_group = component.insulator_group + ins_props = component.insulator_props + + rho_eq = (cond_props.rho) + mu_r_cond = (cond_props.mu_r) + mu_r_ins = (ins_props.mu_r) + eps_eq = (ins_props.eps_r) + + _set_attributes!( + conductor_node, + Dict( + "Rin" => to_nominal(cond_group.radius_in), + "Rout" => to_nominal(cond_group.radius_ext), + "rho" => to_nominal(rho_eq), + "muC" => to_nominal(mu_r_cond), + "muI" => to_nominal(mu_r_ins), + "epsI" => to_nominal(eps_eq), + "Cext" => to_nominal(ins_group.shunt_capacitance), + "Gext" => to_nominal(ins_group.shunt_conductance), + ), + ) + end + end + + # Finalize and Write to File + _set_attributes!(variables, Dict("NumSim" => 1, "IOPCVP" => 0, "UseParser" => "false")) + + try + open(file_name, "w") do fid + prettyprint(fid, doc) + end + @info "XML file saved to: $(display_path(file_name))" + return file_name + catch e + @error "Failed to write XML file '$(display_path(file_name))'" exception = + (e, catch_backtrace()) + return nothing + end +end + + + +# TODO: Develop `.lis` import and tests +# Issue URL: https://github.com/Electa-Git/LineCableModels.jl/issues/12 +function read_data end +# I TEST THEREFORE I EXIST +# I DON´T TEST THEREFORE GO TO THE GARBAGE +# """ +# read_atp_data(file_name::String, cable_system::LineCableSystem) + +# Reads an ATP `.lis` output file, extracts the Ze and Zi matrices, and dynamically +# reorders them to a grouped-by-phase format based on the provided `cable_system` +# structure. It correctly handles systems with a variable number of components per cable. + +# # Arguments +# - `file_name`: The path to the `.lis` file. +# - `cable_system`: The `LineCableSystem` object corresponding to the data in the file. + +# # Returns +# - `Array{T, 2}`: A 2D complex matrix representing the total reordered series +# impedance `Z = Ze + Zi` for a single frequency. +# - `nothing`: If the file cannot be found, parsed, or if the matrix dimensions in the +# file do not match the provided `cable_system` structure. +# """ +# function read_data(::Val{:atp}, +# cable_system::LineCableSystem, +# freq::AbstractFloat; +# file_name::String="$(cable_system.system_id)_1.lis" +# )::Union{Array{COMPLEXSCALAR,2},Nothing} +# # --- Inner helper function to parse a matrix block from text lines --- +# function parse_block(block_lines::Vector{String}) +# data_lines = filter(line -> !isempty(strip(line)), block_lines) +# if isempty(data_lines) +# return Matrix{ComplexF64}(undef, 0, 0) +# end +# matrix_size = length(split(data_lines[1])) +# real_parts = zeros(Float64, matrix_size, matrix_size) +# imag_parts = zeros(Float64, matrix_size, matrix_size) +# row_counter = 1 +# for i in 1:2:length(data_lines) +# if i + 1 > length(data_lines) +# break +# end +# real_line, imag_line = data_lines[i], data_lines[i+1] +# try +# real_parts[row_counter, :] = [parse(Float64, s) for s in split(real_line)[1:matrix_size]] +# imag_parts[row_counter, :] = [parse(Float64, s) for s in split(imag_line)[1:matrix_size]] +# catch e +# @error "Parsing failed" exception = (e, catch_backtrace()) +# return nothing +# end +# row_counter += 1 +# if row_counter > matrix_size +# break +# end +# end +# return real_parts + im * imag_parts +# end + +# # --- Main Function Logic --- +# if !isfile(file_name) +# @error "File not found: $file_name" +# return nothing +# end +# lines = readlines(file_name) +# ze_start_idx = findfirst(occursin.("Earth impedance [Ze]", lines)) +# zi_start_idx = findfirst(occursin.("Conductor internal impedance [Zi]", lines)) +# if isnothing(ze_start_idx) || isnothing(zi_start_idx) +# @error "Could not find Ze/Zi headers." +# return nothing +# end + +# Ze = parse_block(lines[ze_start_idx+1:zi_start_idx-1]) +# Zi = parse_block(lines[zi_start_idx+1:end]) +# if isnothing(Ze) || isnothing(Zi) +# return nothing +# end + +# # --- DYNAMICALLY GENERATE PERMUTATION INDICES (Numerical Method) --- +# component_counts = [length(c.design_data.components) for c in cable_system.cables] +# total_conductors = sum(component_counts) +# num_phases = length(component_counts) +# max_components = isempty(component_counts) ? 0 : maximum(component_counts) + +# if size(Ze, 1) != total_conductors +# @error "Matrix size from file ($(size(Ze,1))x$(size(Ze,1))) does not match total components in cable_system ($total_conductors)." +# return nothing +# end + +# num_conductors_per_type = [sum(c >= i for c in component_counts) for i in 1:max_components] +# type_offsets = cumsum([0; num_conductors_per_type[1:end-1]]) + +# permutation_indices = Int[] +# sizehint!(permutation_indices, total_conductors) +# instance_counters = ones(Int, max_components) +# for phase_idx in 1:num_phases +# for comp_type_idx in 1:component_counts[phase_idx] +# instance = instance_counters[comp_type_idx] +# original_idx = type_offsets[comp_type_idx] + instance +# push!(permutation_indices, original_idx) +# instance_counters[comp_type_idx] += 1 +# end +# end + +# Ze_reordered = Ze[permutation_indices, permutation_indices] +# Zi_reordered = Zi[permutation_indices, permutation_indices] + +# return Ze_reordered + Zi_reordered +# end + + +"""$(TYPEDSIGNATURES) + +Export calculated [`LineParameters`](@ref) (series impedance **Z** and shunt admittance **Y**) to an **compliant** `ZY` XML file. + +This routine writes the complex **Z** and **Y** matrices versus frequency into a compact XML +structure understood by external tools. Rows are emitted as comma‑separated complex entries +(`R+Xi` / `G+Bi`) with one ``/`` block per frequency sample. + +# Arguments + +- `::Val{:atp}`: Backend selector for the ATP/ATPDraw ZY exporter. +- `line_params::LineParameters`: Object holding the frequency‑dependent matrices `Z[:,:,k]`, `Y[:,:,k]`, and `f[k]` in `line_params.f`. +- `file_name::String = "ZY_export.xml"`: Output file name or path. If relative, it is resolved against the exporter’s source directory. The absolute path of the saved file is returned. +- `cable_system::Union{LineCableSystem,Nothing} = nothing`: Optional system used only to derive a default name. When provided and `file_name` is not overridden, the exporter uses `"\$(cable_system.system_id)_ZY_export.xml"`. + +# Behavior + +1. The root tag `` includes `NumPhases`, `Length` (fixed to `1.0`), and format attributes `ZFmt="R+Xi"`, `YFmt="G+Bi"`. +2. For each frequency `fᵏ = line_params.f[k]`: + + * Emit a `` block with `num_phases` lines, each line the `k`‑th slice of row `i` formatted as `real(Z[i,j,k]) + imag(Z[i,j,k])i`. + * Emit a `` block in the same fashion (default `G+Bi`). +3. Close the `` element and write to disk. On I/O error the function logs and returns `nothing`. + +# Units + +Units are printed in the XML file according to the ATPDraw specifications: + +- `freq` (XML `Freq` attribute): \\[Hz\\] +- `Z` entries: \\[Ω/km\\] (per unit length) +- `Y` entries: \\[S/km\\] (per unit length) when `YFmt = "G+Bi"` +- XML `Length` attribute: \\[m\\] + +# Notes + +- The exporter assumes `size(line_params.Z, 1) == size(line_params.Z, 2) == size(line_params.Y, 1) == size(line_params.Y, 2)` and `length(line_params.f) == size(Z,3) == size(Y,3)`. +- Numeric types are stringified; mixed numeric backends (e.g., with uncertainties) are acceptable as long as they can be printed via `@sprintf`. +- This exporter **does not** modify or recompute matrices; it serializes exactly what is in `line_params`. + +# Examples + +```julia +# Z, Y, f have already been computed into `lp::LineParameters` +file = $(FUNCTIONNAME)(:atp, lp; file_name = "ZY_export.xml") +println("Exported ZY to: ", file) + +# Naming based on a cable system +file2 = $(FUNCTIONNAME)(:atp, lp; cable_system = sys) +println("Exported ZY to: ", file2) # => "\$(sys.system_id)_ZY_export.xml" +``` + +# See also + +* [`LineParameters`](@ref) +* [`LineCableSystem`](@ref) +* [`export_data(::Val{:atp}, cable_system, ...)`](@ref) — exporter that writes full LCC input data + """ +function export_data(::Val{:atp}, + line_params::LineParameters; + file_name::Union{String, Nothing} = nothing, + cable_system::Union{LineCableSystem, Nothing} = nothing, +)::Union{String, Nothing} + + # Resolve final file_name while preserving any user-supplied path. + if isnothing(file_name) + # caller didn't supply a name -> derive from cable_system if present + if isnothing(cable_system) + file_name = joinpath(@__DIR__, "ZY_export.xml") + else + file_name = joinpath(@__DIR__, "$(cable_system.system_id)_ZY_export.xml") + end + else + # caller supplied a path/name -> respect directory, but prepend system_id to basename if cable_system provided + requested = isabspath(file_name) ? file_name : joinpath(@__DIR__, file_name) + if isnothing(cable_system) + file_name = requested + else + dir = dirname(requested) + base = basename(requested) + file_name = joinpath(dir, "$(cable_system.system_id)_$base") + end + end + + freq = line_params.f + + @debug ("ZY export called", + :method => "ZY", + :cable_system_isnothing => isnothing(cable_system), + :cable_system_type => (isnothing(cable_system) ? :nothing : typeof(cable_system)), + :file_name_in => file_name) + + cable_length = isnothing(cable_system) ? 1.0 : to_nominal(cable_system.line_length) + atp_format = "G+Bi" + # file_name = isabspath(file_name) ? file_name : joinpath(@__DIR__, file_name) + + open(file_name, "w") do fid + num_phases = size(line_params.Z, 1) + y_fmt = (atp_format == "C") ? "C" : "G+Bi" + + @printf( + fid, + "\n", + num_phases, + cable_length, + y_fmt + ) + + # --- Z Matrix Printing --- + for (k, freq_val) in enumerate(freq) + @printf(fid, " \n", to_nominal(freq_val)) + for i in 1:num_phases + row_str = join( + [ + @sprintf( + "%.16E%+.16Ei", + to_nominal(real(line_params.Z[i, j, k])), + to_nominal(imag(line_params.Z[i, j, k])) + ) for j in 1:num_phases + ], + ",", + ) + println(fid, row_str) + end + @printf(fid, " \n") + end + + # --- Y Matrix Printing --- + if atp_format == "C" + freq1 = to_nominal(freq[1]) + @printf(fid, " \n", freq1) + for i in 1:num_phases + row_str = join( + [ + @sprintf( + "%.16E", + to_nominal(imag(line_params.Y[i, j, 1]) / (2 * pi * freq1)) + ) for j in 1:num_phases + ], + ",", + ) + println(fid, row_str) + end + @printf(fid, " \n") + else # Case for "G+Bi" + for (k, freq_val) in enumerate(freq) + @printf(fid, " \n", to_nominal(freq_val)) + for i in 1:num_phases + row_str = join( + [ + @sprintf( + "%.16E%+.16Ei", + to_nominal(real(line_params.Y[i, j, k])), + to_nominal(imag(line_params.Y[i, j, k])) + ) for j in 1:num_phases + ], + ",", + ) + println(fid, row_str) + end + @printf(fid, " \n") + end + end + + # --- Footer --- + println(fid, "") + end + try + # Use pretty print option for debugging comparisons if needed + # open(filename, "w") do io; prettyprint(io, doc); end + if isfile(file_name) + @info "XML file saved to: $(display_path(file_name))" + end + return file_name + catch e + @error "Failed to write XML file '$(display_path(file_name))': $(e)" + isa(e, SystemError) && println("SystemError details: ", e.extrainfo) + return nothing + rethrow(e) # Rethrow to indicate failure clearly + end +end diff --git a/src/importexport/cableslibrary.jl b/src/importexport/cableslibrary.jl index 0ffde1a4..79e3b3b5 100644 --- a/src/importexport/cableslibrary.jl +++ b/src/importexport/cableslibrary.jl @@ -1,643 +1,643 @@ -""" -$(TYPEDSIGNATURES) - -Saves a [`CablesLibrary`](@ref) to a file. -The format is determined by the file extension: -- `.json`: Saves using the custom JSON serialization. -- `.jls`: Saves using Julia native binary serialization. - -# Arguments -- `library`: The [`CablesLibrary`](@ref) instance to save. -- `file_name`: The path to the output file (default: "cables_library.json"). - -# Returns -- The absolute path of the saved file, or `nothing` on failure. -""" -function save( - library::CablesLibrary; - file_name::String="cables_library.json", -)::Union{String,Nothing} - - file_name = isabspath(file_name) ? file_name : joinpath(@__DIR__, file_name) - - _, ext = splitext(file_name) - ext = lowercase(ext) - - try - if ext == ".jls" - return _save_cableslibrary_jls(library, file_name) - elseif ext == ".json" - return _save_cableslibrary_json(library, file_name) - else - @warn "Unrecognized file extension '$ext' for CablesLibrary. Defaulting to .json format." - # Ensure filename has .json extension if defaulting - if ext != ".json" - file_name = file_name * ".json" - end - return _save_cableslibrary_json(library, file_name) - end - catch e - @error "Error saving CablesLibrary to '$(display_path(file_name))': $e" - showerror(stderr, e, catch_backtrace()) - println(stderr) - return nothing - end -end - -""" -$(TYPEDSIGNATURES) - -Saves the [`CablesLibrary`](@ref) using Julia native binary serialization. -This format is generally not portable across Julia versions or machine architectures -but can be faster and preserves exact types. - -# Arguments -- `library`: The [`CablesLibrary`](@ref) instance. -- `file_name`: The output file path (should end in `.jls`). - -# Returns -- The absolute path of the saved file. -""" -function _save_cableslibrary_jls(library::CablesLibrary, file_name::String)::String - # Note: Serializing the whole library object directly might be problematic - # if the library struct itself changes. Serializing the core data (designs) is safer. - serialize(file_name, library.data) - @info "Cables library saved using Julia serialization to: $(display_path(file_name))" - return abspath(file_name) -end - -""" -$(TYPEDSIGNATURES) - -Saves the [`CablesLibrary`](@ref) to a JSON file using the custom serialization logic. - -# Arguments -- `library`: The [`CablesLibrary`](@ref) instance. -- `file_name`: The output file path (should end in `.json`). - -# Returns -- The absolute path of the saved file. -""" -function _save_cableslibrary_json(library::CablesLibrary, file_name::String)::String - # Use the generic _serialize_value, which will delegate to _serialize_obj - # for the library object, which in turn uses _serializable_fields(::CablesLibrary) - serialized_library = _serialize_value(library) - - open(file_name, "w") do io - # Use JSON3.pretty for human-readable output - # allow_inf=true is needed if Measurements or other fields might contain Inf - JSON3.pretty(io, serialized_library, allow_inf=true) - end - if isfile(file_name) - @info "Cables library saved to: $(display_path(file_name))" - end - return abspath(file_name) -end - -""" -$(TYPEDSIGNATURES) - -Loads cable designs from a file into an existing [`CablesLibrary`](@ref) object. -Modifies the library in-place. -The format is determined by the file extension: -- `.json`: Loads using the custom JSON deserialization and reconstruction. -- `.jls`: Loads using Julia's native binary deserialization. - -# Arguments -- `library`: The [`CablesLibrary`](@ref) instance to populate (modified in-place). -- `file_name`: Path to the file to load (default: "cables_library.json"). - -# Returns -- The modified [`CablesLibrary`](@ref) instance. -""" -function load!( - library::CablesLibrary; # Type annotation ensures it's the correct object - file_name::String="cables_library.json", -)::CablesLibrary # Return the modified library - if !isfile(file_name) - throw(ErrorException("Cables library file not found: '$(display_path(file_name))'")) # make caller receive an Exception - end - - _, ext = splitext(file_name) - ext = lowercase(ext) - - try - if ext == ".jls" - _load_cableslibrary_jls!(library, file_name) - elseif ext == ".json" - _load_cableslibrary_json!(library, file_name) - else - @warn "Unrecognized file extension '$ext' for CablesLibrary. Attempting to load as .json." - _load_cableslibrary_json!(library, file_name) - end - catch e - @error "Error loading CablesLibrary from '$(display_path(file_name))': $e" - showerror(stderr, e, catch_backtrace()) - println(stderr) - # Optionally clear the library or leave it partially loaded depending on desired robustness - # empty!(library.data) - end - return library # Return the modified library -end - -""" -$(TYPEDSIGNATURES) - -Loads cable designs from a Julia binary serialization file (`.jls`) -into the provided library object. - -# Arguments -- `library`: The [`CablesLibrary`](@ref) instance to modify. -- `file_name`: The path to the `.jls` file. - -# Returns -- Nothing. Modifies `library` in-place. -""" -function _load_cableslibrary_jls!(library::CablesLibrary, file_name::String) - loaded_data = deserialize(file_name) - - if isa(loaded_data, Dict{String,CableDesign}) - # Replace the existing designs - library.data = loaded_data - println( - "Cables library successfully loaded via Julia deserialization from: ", - display_path(file_name), - ) - else - # This indicates the .jls file did not contain the expected dictionary structure - @error "Invalid data format in '$(display_path(file_name))'. Expected Dict{String, CableDesign}, got $(typeof(loaded_data)). Library not loaded." - # Ensure library.data exists if it was potentially wiped before load attempt - if !isdefined(library, :data) || !(library.data isa AbstractDict) - library.data = Dict{String,CableDesign}() - end - end - return nothing -end - -""" -$(TYPEDSIGNATURES) - -Loads cable designs from a JSON file into the provided library object -using the detailed, sequential reconstruction logic. - -# Arguments -- `library`: The [`CablesLibrary`](@ref) instance to modify. -- `file_name`: The path to the `.json` file. - -# Returns -- Nothing. Modifies `library` in-place. -""" -function _load_cableslibrary_json!(library::CablesLibrary, file_name::String) - # Ensure library structure is initialized - if !isdefined(library, :data) || !(library.data isa AbstractDict) - @warn "Library 'data' field was not initialized or not a Dict. Initializing." - library.data = Dict{String,CableDesign}() - else - # Clear existing designs before loading (common behavior) - empty!(library.data) - end - - # Load the entire JSON structure - json_data = open(file_name, "r") do io - JSON3.read(io, Dict{String,Any}) # Read the top level as a Dict - end - - # The JSON might store designs directly under "data" key, - # or the top level might be the dictionary of designs itself. - local designs_to_process::Dict - if haskey(json_data, "data") && json_data["data"] isa AbstractDict - # Standard case: designs are under the "data" key - designs_to_process = json_data["data"] - elseif haskey(json_data, "__julia_type__") && - occursin("CablesLibrary", json_data["__julia_type__"]) && - haskey(json_data, "data") - # Case where the entire library object was serialized - designs_to_process = json_data["data"] - elseif all( - v -> - v isa AbstractDict && haskey(v, "__julia_type__") && - occursin("CableDesign", v["__julia_type__"]), - values(json_data), - ) - # Fallback: Assume the top-level dict *is* the designs dict - @info "Assuming top-level JSON object in '$(display_path(file_name))' is the dictionary of cable designs." - designs_to_process = json_data - else - @error "JSON file '$(display_path(file_name))' does not contain a recognizable 'data' dictionary or structure." - return nothing # Exit loading process - end - - @info "Loading cable designs from JSON: '$(display_path(file_name))'..." - num_loaded = 0 - num_failed = 0 - - # Process each cable design entry using manual reconstruction - for (cable_id, design_data) in designs_to_process - if !(design_data isa AbstractDict) - @warn "Skipping entry '$cable_id': Invalid data format (expected Dictionary, got $(typeof(design_data)))." - num_failed += 1 - continue - end - try - # Reconstruct the design using the dedicated function - reconstructed_design = - _reconstruct_cabledesign(string(cable_id), design_data) - # Store the fully reconstructed design in the library - library.data[string(cable_id)] = reconstructed_design - num_loaded += 1 - catch e - num_failed += 1 - @error "Failed to reconstruct cable design '$cable_id': $e" - # Show stacktrace for detailed debugging, especially for MethodErrors during construction - showerror(stderr, e, catch_backtrace()) - println(stderr) # Add newline for clarity - end - end - - @info "Finished loading from '$(display_path(file_name))'. Successfully loaded $num_loaded cable designs, failed to load $num_failed." - return nothing -end - -""" -$(TYPEDSIGNATURES) - -Helper function to reconstruct a [`ConductorGroup`](@ref) or [`InsulatorGroup`](@ref) object with the first layer of the respective [`AbstractCablePart`](@ref). Subsequent layers are added using `add!` methods. - -# Arguments -- `layer_data`: Dictionary containing the data for the first layer, parsed from JSON. - -# Returns -- A reconstructed [`ConductorGroup`](@ref) object with the initial [`AbstractCablePart`](@ref). - -# Throws -- Error if essential data is missing or the layer type is unsupported. -""" -function _reconstruct_partsgroup(layer_data::Dict) - if !haskey(layer_data, "__julia_type__") - Base.error("Layer data missing '__julia_type__' key: $layer_data") - end - type_str = layer_data["__julia_type__"] - LayerType = _resolve_type(type_str) - - # Use generic deserialization for the whole layer data first. - # _deserialize_value now returns Dict{Symbol, Any} for plain dicts - local deserialized_layer_dict::Dict{Symbol,Any} - try - # Temporarily remove type key to avoid recursive loop in _deserialize_value -> _deserialize_obj - temp_data = filter(p -> p.first != "__julia_type__", layer_data) - deserialized_layer_dict = _deserialize_value(temp_data) # Should return Dict{Symbol, Any} - catch e - # This fallback might not be strictly needed anymore if _deserialize_value is robust, - # but kept for safety. It also needs to produce Dict{Symbol, Any}. - @error "Initial deserialization failed for first layer data ($type_str): $e. Trying manual field extraction." - deserialized_layer_dict = Dict{Symbol,Any}() - for (k_str, v) in layer_data # k_str is String from JSON parsing - if k_str != "__julia_type__" - deserialized_layer_dict[Symbol(k_str)] = _deserialize_value(v) # Deserialize value, use Symbol key - end - end - end - - # Ensure the result is Dict{Symbol, Any} - if !(deserialized_layer_dict isa Dict{Symbol,Any}) - error( - "Internal error: deserialized_layer_dict is not Dict{Symbol, Any}, but $(typeof(deserialized_layer_dict))", - ) - end - - # Extract necessary fields using get with Symbol keys - radius_in = get_as(deserialized_layer_dict, :radius_in, missing, BASE_FLOAT) - material_props = get_as(deserialized_layer_dict, :material_props, missing, BASE_FLOAT) - temperature = get_as(deserialized_layer_dict, :temperature, T₀, BASE_FLOAT) - - # Check for essential properties common to most first layers - ismissing(radius_in) && - Base.error("Missing 'radius_in' for first layer type $LayerType in data: $layer_data") - ismissing(material_props) && error( - "Missing 'material_props' for first layer type $LayerType in data: $layer_data", - ) - !(material_props isa Material) && error( - "'material_props' did not deserialize to a Material object for first layer type $LayerType. Got: $(typeof(material_props))", - ) - - - # Type-specific reconstruction using POSITIONAL constructors + Keywords - # This requires knowing the exact constructor signatures. - try - - if LayerType == WireArray - radius_wire = get_as(deserialized_layer_dict, :radius_wire, missing, BASE_FLOAT) - num_wires = get_as(deserialized_layer_dict, :num_wires, missing, Int) - lay_ratio = get_as(deserialized_layer_dict, :lay_ratio, missing, BASE_FLOAT) - lay_direction = get_as(deserialized_layer_dict, :lay_direction, 1, Int) # Default lay_direction - # Validate required fields - any(ismissing, (radius_wire, num_wires, lay_ratio)) && error( - "Missing required field(s) (radius_wire, num_wires, lay_ratio) for WireArray first layer.", - ) - # Ensure num_wires is Int - num_wires_int = isa(num_wires, Int) ? num_wires : Int(num_wires) - lay_direction_int = isa(lay_direction, Int) ? lay_direction : Int(lay_direction) - return WireArray( - radius_in, - radius_wire, - num_wires_int, - lay_ratio, - material_props; - temperature=temperature, - lay_direction=lay_direction_int, - ) - elseif LayerType == Tubular - radius_ext = get_as(deserialized_layer_dict, :radius_ext, missing, BASE_FLOAT) - ismissing(radius_ext) && Base.error("Missing 'radius_ext' for Tubular first layer.") - return Tubular( - radius_in, radius_ext, material_props; temperature=temperature) - elseif LayerType == Strip - radius_ext = get_as(deserialized_layer_dict, :radius_ext, missing, BASE_FLOAT) - width = get_as(deserialized_layer_dict, :width, missing, BASE_FLOAT) - lay_ratio = get_as(deserialized_layer_dict, :lay_ratio, missing, BASE_FLOAT) - lay_direction = get(deserialized_layer_dict, :lay_direction, 1) - any(ismissing, (radius_ext, width, lay_ratio)) && error( - "Missing required field(s) (radius_ext, width, lay_ratio) for Strip first layer.", - ) - lay_direction_int = isa(lay_direction, Int) ? lay_direction : Int(lay_direction) - - return Strip( - radius_in, - radius_ext, - width, - lay_ratio, - material_props; - temperature=temperature, - lay_direction=lay_direction_int, - ) - elseif LayerType == Insulator - radius_ext = get_as(deserialized_layer_dict, :radius_ext, missing, BASE_FLOAT) - ismissing(radius_ext) && - Base.error("Missing 'radius_ext' for Insulator first layer.") - return Insulator( - radius_in, - radius_ext, - material_props; - temperature=temperature, - ) - elseif LayerType == Semicon - radius_ext = get_as(deserialized_layer_dict, :radius_ext, missing, BASE_FLOAT) - ismissing(radius_ext) && Base.error("Missing 'radius_ext' for Semicon first layer.") - return Semicon(radius_in, radius_ext, material_props; temperature=temperature) - else - Base.error("Unsupported layer type for first layer reconstruction: $LayerType") - end - catch e - @error "Construction failed for first layer of type $LayerType with data: $deserialized_layer_dict. Error: $e" - rethrow(e) - end -end - -""" -$(TYPEDSIGNATURES) - -Reconstructs a complete [`CableDesign`](@ref) object from its dictionary representation (parsed from JSON). -This function handles the sequential process of building cable designs: - 1. Deserialize [`NominalData`](@ref). - 2. Iterate through components. - 3. For each component: -    a. Reconstruct the first layer of the conductor group. -    b. Create the [`ConductorGroup`](@ref) with the first layer. -    c. Add subsequent conductor layers using [`add!`](@ref). -    d. Repeat a-c for the [`InsulatorGroup`](@ref). -    e. Create the [`CableComponent`](@ref). - 4. Create the [`CableDesign`](@ref) with the first component. - 5. Add subsequent components using [`add!`](@ref). - -# Arguments -- `cable_id`: The identifier string for the cable design. -- `design_data`: Dictionary containing the data for the cable design. - -# Returns -- A fully reconstructed [`CableDesign`](@ref) object. - -# Throws -- Error if reconstruction fails at any step. -""" -function _reconstruct_cabledesign( - cable_id::String, - design_data::Dict, -)::CableDesign - @info "Reconstructing CableDesign: $cable_id" - - # 1. Reconstruct NominalData using generic deserialization - local nominal_data::NominalData - if haskey(design_data, "nominal_data") - # Ensure the input to _deserialize_value is the Dict for NominalData - nominal_data_dict = design_data["nominal_data"] - if !(nominal_data_dict isa AbstractDict) - error( - "Invalid format for 'nominal_data' in $cable_id: Expected Dictionary, got $(typeof(nominal_data_dict))", - ) - end - nominal_data_val = _deserialize_value(nominal_data_dict) - if !(nominal_data_val isa NominalData) - # This error check relies on _deserialize_value returning the original dict on failure - error( - "Field 'nominal_data' did not deserialize to a NominalData object for $cable_id. Got: $(typeof(nominal_data_val))", - ) - end - nominal_data = nominal_data_val - @info " Reconstructed NominalData" - else - @warn "Missing 'nominal_data' for $cable_id. Using default NominalData()." - nominal_data = NominalData() # Use default if missing - end - - # 2. Process Components Sequentially - components_data = get(design_data, "components", []) - if isempty(components_data) || !(components_data isa AbstractVector) - Base.error("Missing or invalid 'components' array in design data for $cable_id") - end - - reconstructed_components = CableComponent[] # Store fully built components - - - for (idx, comp_data) in enumerate(components_data) - if !(comp_data isa AbstractDict) - @warn "Component data at index $idx for $cable_id is not a dictionary. Skipping." - continue - end - comp_id = get(comp_data, "id", "UNKNOWN_COMPONENT_ID_$idx") - @info " Processing Component $idx: $comp_id" - - # --- 2.1 Build Conductor Group --- - local conductor_group::ConductorGroup - conductor_group_data = get(comp_data, "conductor_group", Dict()) - cond_layers_data = get(conductor_group_data, "layers", []) - - if isempty(cond_layers_data) || !(cond_layers_data isa AbstractVector) - Base.error("Component '$comp_id' has missing or invalid conductor group layers.") - end - - # - Create the FIRST layer object - # Ensure the input to _reconstruct_partsgroup is the Dict for the layer - first_layer_dict = cond_layers_data[1] - if !(first_layer_dict isa AbstractDict) - error( - "Invalid format for first conductor layer in component '$comp_id': Expected Dictionary, got $(typeof(first_layer_dict))", - ) - end - first_cond_layer = _reconstruct_partsgroup(first_layer_dict) - - # - Initialize ConductorGroup using its constructor with the first layer - conductor_group = ConductorGroup(first_cond_layer) - @info " Created ConductorGroup with first layer: $(typeof(first_cond_layer))" - - # - Add remaining layers using add! - for i in 2:lastindex(cond_layers_data) - layer_data = cond_layers_data[i] - if !(layer_data isa AbstractDict) - @warn "Conductor layer data at index $i for component $comp_id is not a dictionary. Skipping." - continue - end - - # Extract Type and necessary arguments for add! - LayerType = _resolve_type(layer_data["__julia_type__"]) - material_props = get_as(layer_data, "material_props", missing, BASE_FLOAT) - material_props isa Material || Base.error("'material_props' must deserialize to Material, got $(typeof(material_props))") - - # Prepare args and kwargs based on LayerType for add! - args = [] - kwargs = Dict{Symbol,Any}() - kwargs[:temperature] = get_as(layer_data, "temperature", T₀, BASE_FLOAT) - if haskey(layer_data, "lay_direction") # Only add if present - kwargs[:lay_direction] = get_as(layer_data, "lay_direction", 1, Int) - end - - # Extract type-specific arguments needed by add! - try - if LayerType == WireArray - radius_wire = get_as(layer_data, "radius_wire", missing, BASE_FLOAT) - num_wires = get_as(layer_data, "num_wires", missing, Int) - lay_ratio = get_as(layer_data, "lay_ratio", missing, BASE_FLOAT) - any(ismissing, (radius_wire, num_wires, lay_ratio)) && error( - "Missing required field(s) for WireArray layer $i in $comp_id", - ) - args = [radius_wire, num_wires, lay_ratio, material_props] - elseif LayerType == Tubular - radius_ext = get_as(layer_data, "radius_ext", missing, BASE_FLOAT) - ismissing(radius_ext) && - Base.error("Missing 'radius_ext' for Tubular layer $i in $comp_id") - args = [radius_ext, material_props] - elseif LayerType == Strip - radius_ext = get_as(layer_data, "radius_ext", missing, BASE_FLOAT) - width = get_as(layer_data, "width", missing, BASE_FLOAT) - lay_ratio = get_as(layer_data, "lay_ratio", missing, BASE_FLOAT) - any(ismissing, (radius_ext, width, lay_ratio)) && - Base.error("Missing required field(s) for Strip layer $i in $comp_id") - args = [radius_ext, width, lay_ratio, material_props] - else - Base.error("Unsupported layer type '$LayerType' for add!") - end - - # Call add! with Type, args..., and kwargs... - add!(conductor_group, LayerType, args...; kwargs...) - @info " Added conductor layer $i: $LayerType" - catch e - @error "Failed to add conductor layer $i ($LayerType) to component $comp_id: $e" - println(stderr, " Layer Data: $layer_data") - println(stderr, " Args: $args") - println(stderr, " Kwargs: $kwargs") - rethrow(e) - end - end # End loop for conductor layers - - # --- 2.2 Build Insulator Group (Analogous logic) --- - local insulator_group::InsulatorGroup - insulator_group_data = get(comp_data, "insulator_group", Dict()) - insu_layers_data = get(insulator_group_data, "layers", []) - - if isempty(insu_layers_data) || !(insu_layers_data isa AbstractVector) - Base.error("Component '$comp_id' has missing or invalid insulator group layers.") - end - - # - Create the FIRST layer object - first_layer_dict_insu = insu_layers_data[1] - if !(first_layer_dict_insu isa AbstractDict) - error( - "Invalid format for first insulator layer in component '$comp_id': Expected Dictionary, got $(typeof(first_layer_dict_insu))", - ) - end - first_insu_layer = _reconstruct_partsgroup(first_layer_dict_insu) - - # - Initialize InsulatorGroup - insulator_group = InsulatorGroup(first_insu_layer) - @info " Created InsulatorGroup with first layer: $(typeof(first_insu_layer))" - - # - Add remaining layers using add! - for i in 2:lastindex(insu_layers_data) - layer_data = insu_layers_data[i] - if !(layer_data isa AbstractDict) - @warn "Insulator layer data at index $i for component $comp_id is not a dictionary. Skipping." - continue - end - - LayerType = _resolve_type(layer_data["__julia_type__"]) - material_props = get_as(layer_data, "material_props", missing, BASE_FLOAT) - material_props isa Material || Base.error("'material_props' must deserialize to Material, got $(typeof(material_props))") - - - args = [] - kwargs = Dict{Symbol,Any}() - kwargs[:temperature] = get_as(layer_data, "temperature", T₀, BASE_FLOAT) - - try - # All insulator types (Semicon, Insulator) take radius_ext, material_props - # for the add! method. - if LayerType in [Semicon, Insulator] - radius_ext = get_as(layer_data, "radius_ext", missing, BASE_FLOAT) - ismissing(radius_ext) && - Base.error("Missing 'radius_ext' for $LayerType layer $i in $comp_id") - args = [radius_ext, material_props] - else - Base.error("Unsupported layer type '$LayerType' for add!") - end - - # Call add! with Type, args..., and kwargs... - add!(insulator_group, LayerType, args...; kwargs...) - @info " Added insulator layer $i: $LayerType" - catch e - @error "Failed to add insulator layer $i ($LayerType) to component $comp_id: $e" - println(stderr, " Layer Data: $layer_data") - println(stderr, " Args: $args") - println(stderr, " Kwargs: $kwargs") - rethrow(e) - end - end # End loop for insulator layers - - # --- 2.3 Create the CableComponent object --- - component = CableComponent(comp_id, conductor_group, insulator_group) - push!(reconstructed_components, component) - @info " Created CableComponent: $comp_id" - - end # End loop through components_data - - # 3. Create the final CableDesign object using the first component - if isempty(reconstructed_components) - Base.error("Failed to reconstruct any valid components for cable design '$cable_id'") - end - # Use the CableDesign constructor which takes the first component - cable_design = - CableDesign(cable_id, reconstructed_components[1]; nominal_data=nominal_data) - @info " Created initial CableDesign with component: $(reconstructed_components[1].id)" - - # 4. Add remaining components to the design sequentially using add! - for i in 2:lastindex(reconstructed_components) - try - add!(cable_design, reconstructed_components[i]) - @info " Added component $(reconstructed_components[i].id) to CableDesign '$cable_id'" - catch e - @error "Failed to add component '$(reconstructed_components[i].id)' to CableDesign '$cable_id': $e" - rethrow(e) - end - end - - @info "Finished Reconstructing CableDesign: $cable_id" - return cable_design +""" +$(TYPEDSIGNATURES) + +Saves a [`CablesLibrary`](@ref) to a file. +The format is determined by the file extension: +- `.json`: Saves using the custom JSON serialization. +- `.jls`: Saves using Julia native binary serialization. + +# Arguments +- `library`: The [`CablesLibrary`](@ref) instance to save. +- `file_name`: The path to the output file (default: "cables_library.json"). + +# Returns +- The absolute path of the saved file, or `nothing` on failure. +""" +function save( + library::CablesLibrary; + file_name::String="cables_library.json", +)::Union{String,Nothing} + + file_name = isabspath(file_name) ? file_name : joinpath(@__DIR__, file_name) + + _, ext = splitext(file_name) + ext = lowercase(ext) + + try + if ext == ".jls" + return _save_cableslibrary_jls(library, file_name) + elseif ext == ".json" + return _save_cableslibrary_json(library, file_name) + else + @warn "Unrecognized file extension '$ext' for CablesLibrary. Defaulting to .json format." + # Ensure filename has .json extension if defaulting + if ext != ".json" + file_name = file_name * ".json" + end + return _save_cableslibrary_json(library, file_name) + end + catch e + @error "Error saving CablesLibrary to '$(display_path(file_name))': $e" + showerror(stderr, e, catch_backtrace()) + println(stderr) + return nothing + end +end + +""" +$(TYPEDSIGNATURES) + +Saves the [`CablesLibrary`](@ref) using Julia native binary serialization. +This format is generally not portable across Julia versions or machine architectures +but can be faster and preserves exact types. + +# Arguments +- `library`: The [`CablesLibrary`](@ref) instance. +- `file_name`: The output file path (should end in `.jls`). + +# Returns +- The absolute path of the saved file. +""" +function _save_cableslibrary_jls(library::CablesLibrary, file_name::String)::String + # Note: Serializing the whole library object directly might be problematic + # if the library struct itself changes. Serializing the core data (designs) is safer. + serialize(file_name, library.data) + @info "Cables library saved using Julia serialization to: $(display_path(file_name))" + return abspath(file_name) +end + +""" +$(TYPEDSIGNATURES) + +Saves the [`CablesLibrary`](@ref) to a JSON file using the custom serialization logic. + +# Arguments +- `library`: The [`CablesLibrary`](@ref) instance. +- `file_name`: The output file path (should end in `.json`). + +# Returns +- The absolute path of the saved file. +""" +function _save_cableslibrary_json(library::CablesLibrary, file_name::String)::String + # Use the generic _serialize_value, which will delegate to _serialize_obj + # for the library object, which in turn uses _serializable_fields(::CablesLibrary) + serialized_library = _serialize_value(library) + + open(file_name, "w") do io + # Use JSON3.pretty for human-readable output + # allow_inf=true is needed if Measurements or other fields might contain Inf + JSON3.pretty(io, serialized_library, allow_inf=true) + end + if isfile(file_name) + @info "Cables library saved to: $(display_path(file_name))" + end + return abspath(file_name) +end + +""" +$(TYPEDSIGNATURES) + +Loads cable designs from a file into an existing [`CablesLibrary`](@ref) object. +Modifies the library in-place. +The format is determined by the file extension: +- `.json`: Loads using the custom JSON deserialization and reconstruction. +- `.jls`: Loads using Julia's native binary deserialization. + +# Arguments +- `library`: The [`CablesLibrary`](@ref) instance to populate (modified in-place). +- `file_name`: Path to the file to load (default: "cables_library.json"). + +# Returns +- The modified [`CablesLibrary`](@ref) instance. +""" +function load!( + library::CablesLibrary; # Type annotation ensures it's the correct object + file_name::String="cables_library.json", +)::CablesLibrary # Return the modified library + if !isfile(file_name) + throw(ErrorException("Cables library file not found: '$(display_path(file_name))'")) # make caller receive an Exception + end + + _, ext = splitext(file_name) + ext = lowercase(ext) + + try + if ext == ".jls" + _load_cableslibrary_jls!(library, file_name) + elseif ext == ".json" + _load_cableslibrary_json!(library, file_name) + else + @warn "Unrecognized file extension '$ext' for CablesLibrary. Attempting to load as .json." + _load_cableslibrary_json!(library, file_name) + end + catch e + @error "Error loading CablesLibrary from '$(display_path(file_name))': $e" + showerror(stderr, e, catch_backtrace()) + println(stderr) + # Optionally clear the library or leave it partially loaded depending on desired robustness + # empty!(library.data) + end + return library # Return the modified library +end + +""" +$(TYPEDSIGNATURES) + +Loads cable designs from a Julia binary serialization file (`.jls`) +into the provided library object. + +# Arguments +- `library`: The [`CablesLibrary`](@ref) instance to modify. +- `file_name`: The path to the `.jls` file. + +# Returns +- Nothing. Modifies `library` in-place. +""" +function _load_cableslibrary_jls!(library::CablesLibrary, file_name::String) + loaded_data = deserialize(file_name) + + if isa(loaded_data, Dict{String,CableDesign}) + # Replace the existing designs + library.data = loaded_data + println( + "Cables library successfully loaded via Julia deserialization from: ", + display_path(file_name), + ) + else + # This indicates the .jls file did not contain the expected dictionary structure + @error "Invalid data format in '$(display_path(file_name))'. Expected Dict{String, CableDesign}, got $(typeof(loaded_data)). Library not loaded." + # Ensure library.data exists if it was potentially wiped before load attempt + if !isdefined(library, :data) || !(library.data isa AbstractDict) + library.data = Dict{String,CableDesign}() + end + end + return nothing +end + +""" +$(TYPEDSIGNATURES) + +Loads cable designs from a JSON file into the provided library object +using the detailed, sequential reconstruction logic. + +# Arguments +- `library`: The [`CablesLibrary`](@ref) instance to modify. +- `file_name`: The path to the `.json` file. + +# Returns +- Nothing. Modifies `library` in-place. +""" +function _load_cableslibrary_json!(library::CablesLibrary, file_name::String) + # Ensure library structure is initialized + if !isdefined(library, :data) || !(library.data isa AbstractDict) + @warn "Library 'data' field was not initialized or not a Dict. Initializing." + library.data = Dict{String,CableDesign}() + else + # Clear existing designs before loading (common behavior) + empty!(library.data) + end + + # Load the entire JSON structure + json_data = open(file_name, "r") do io + JSON3.read(io, Dict{String,Any}) # Read the top level as a Dict + end + + # The JSON might store designs directly under "data" key, + # or the top level might be the dictionary of designs itself. + local designs_to_process::Dict + if haskey(json_data, "data") && json_data["data"] isa AbstractDict + # Standard case: designs are under the "data" key + designs_to_process = json_data["data"] + elseif haskey(json_data, "__julia_type__") && + occursin("CablesLibrary", json_data["__julia_type__"]) && + haskey(json_data, "data") + # Case where the entire library object was serialized + designs_to_process = json_data["data"] + elseif all( + v -> + v isa AbstractDict && haskey(v, "__julia_type__") && + occursin("CableDesign", v["__julia_type__"]), + values(json_data), + ) + # Fallback: Assume the top-level dict *is* the designs dict + @info "Assuming top-level JSON object in '$(display_path(file_name))' is the dictionary of cable designs." + designs_to_process = json_data + else + @error "JSON file '$(display_path(file_name))' does not contain a recognizable 'data' dictionary or structure." + return nothing # Exit loading process + end + + @info "Loading cable designs from JSON: '$(display_path(file_name))'..." + num_loaded = 0 + num_failed = 0 + + # Process each cable design entry using manual reconstruction + for (cable_id, design_data) in designs_to_process + if !(design_data isa AbstractDict) + @warn "Skipping entry '$cable_id': Invalid data format (expected Dictionary, got $(typeof(design_data)))." + num_failed += 1 + continue + end + try + # Reconstruct the design using the dedicated function + reconstructed_design = + _reconstruct_cabledesign(string(cable_id), design_data) + # Store the fully reconstructed design in the library + library.data[string(cable_id)] = reconstructed_design + num_loaded += 1 + catch e + num_failed += 1 + @error "Failed to reconstruct cable design '$cable_id': $e" + # Show stacktrace for detailed debugging, especially for MethodErrors during construction + showerror(stderr, e, catch_backtrace()) + println(stderr) # Add newline for clarity + end + end + + @info "Finished loading from '$(display_path(file_name))'. Successfully loaded $num_loaded cable designs, failed to load $num_failed." + return nothing +end + +""" +$(TYPEDSIGNATURES) + +Helper function to reconstruct a [`ConductorGroup`](@ref) or [`InsulatorGroup`](@ref) object with the first layer of the respective [`AbstractCablePart`](@ref). Subsequent layers are added using `add!` methods. + +# Arguments +- `layer_data`: Dictionary containing the data for the first layer, parsed from JSON. + +# Returns +- A reconstructed [`ConductorGroup`](@ref) object with the initial [`AbstractCablePart`](@ref). + +# Throws +- Error if essential data is missing or the layer type is unsupported. +""" +function _reconstruct_partsgroup(layer_data::Dict) + if !haskey(layer_data, "__julia_type__") + Base.error("Layer data missing '__julia_type__' key: $layer_data") + end + type_str = layer_data["__julia_type__"] + LayerType = _resolve_type(type_str) + + # Use generic deserialization for the whole layer data first. + # _deserialize_value now returns Dict{Symbol, Any} for plain dicts + local deserialized_layer_dict::Dict{Symbol,Any} + try + # Temporarily remove type key to avoid recursive loop in _deserialize_value -> _deserialize_obj + temp_data = filter(p -> p.first != "__julia_type__", layer_data) + deserialized_layer_dict = _deserialize_value(temp_data) # Should return Dict{Symbol, Any} + catch e + # This fallback might not be strictly needed anymore if _deserialize_value is robust, + # but kept for safety. It also needs to produce Dict{Symbol, Any}. + @error "Initial deserialization failed for first layer data ($type_str): $e. Trying manual field extraction." + deserialized_layer_dict = Dict{Symbol,Any}() + for (k_str, v) in layer_data # k_str is String from JSON parsing + if k_str != "__julia_type__" + deserialized_layer_dict[Symbol(k_str)] = _deserialize_value(v) # Deserialize value, use Symbol key + end + end + end + + # Ensure the result is Dict{Symbol, Any} + if !(deserialized_layer_dict isa Dict{Symbol,Any}) + error( + "Internal error: deserialized_layer_dict is not Dict{Symbol, Any}, but $(typeof(deserialized_layer_dict))", + ) + end + + # Extract necessary fields using get with Symbol keys + radius_in = get_as(deserialized_layer_dict, :radius_in, missing, BASE_FLOAT) + material_props = get_as(deserialized_layer_dict, :material_props, missing, BASE_FLOAT) + temperature = get_as(deserialized_layer_dict, :temperature, T₀, BASE_FLOAT) + + # Check for essential properties common to most first layers + ismissing(radius_in) && + Base.error("Missing 'radius_in' for first layer type $LayerType in data: $layer_data") + ismissing(material_props) && error( + "Missing 'material_props' for first layer type $LayerType in data: $layer_data", + ) + !(material_props isa Material) && error( + "'material_props' did not deserialize to a Material object for first layer type $LayerType. Got: $(typeof(material_props))", + ) + + + # Type-specific reconstruction using POSITIONAL constructors + Keywords + # This requires knowing the exact constructor signatures. + try + + if LayerType == WireArray + radius_wire = get_as(deserialized_layer_dict, :radius_wire, missing, BASE_FLOAT) + num_wires = get_as(deserialized_layer_dict, :num_wires, missing, Int) + lay_ratio = get_as(deserialized_layer_dict, :lay_ratio, missing, BASE_FLOAT) + lay_direction = get_as(deserialized_layer_dict, :lay_direction, 1, Int) # Default lay_direction + # Validate required fields + any(ismissing, (radius_wire, num_wires, lay_ratio)) && error( + "Missing required field(s) (radius_wire, num_wires, lay_ratio) for WireArray first layer.", + ) + # Ensure num_wires is Int + num_wires_int = isa(num_wires, Int) ? num_wires : Int(num_wires) + lay_direction_int = isa(lay_direction, Int) ? lay_direction : Int(lay_direction) + return WireArray( + radius_in, + radius_wire, + num_wires_int, + lay_ratio, + material_props; + temperature=temperature, + lay_direction=lay_direction_int, + ) + elseif LayerType == Tubular + radius_ext = get_as(deserialized_layer_dict, :radius_ext, missing, BASE_FLOAT) + ismissing(radius_ext) && Base.error("Missing 'radius_ext' for Tubular first layer.") + return Tubular( + radius_in, radius_ext, material_props; temperature=temperature) + elseif LayerType == Strip + radius_ext = get_as(deserialized_layer_dict, :radius_ext, missing, BASE_FLOAT) + width = get_as(deserialized_layer_dict, :width, missing, BASE_FLOAT) + lay_ratio = get_as(deserialized_layer_dict, :lay_ratio, missing, BASE_FLOAT) + lay_direction = get(deserialized_layer_dict, :lay_direction, 1) + any(ismissing, (radius_ext, width, lay_ratio)) && error( + "Missing required field(s) (radius_ext, width, lay_ratio) for Strip first layer.", + ) + lay_direction_int = isa(lay_direction, Int) ? lay_direction : Int(lay_direction) + + return Strip( + radius_in, + radius_ext, + width, + lay_ratio, + material_props; + temperature=temperature, + lay_direction=lay_direction_int, + ) + elseif LayerType == Insulator + radius_ext = get_as(deserialized_layer_dict, :radius_ext, missing, BASE_FLOAT) + ismissing(radius_ext) && + Base.error("Missing 'radius_ext' for Insulator first layer.") + return Insulator( + radius_in, + radius_ext, + material_props; + temperature=temperature, + ) + elseif LayerType == Semicon + radius_ext = get_as(deserialized_layer_dict, :radius_ext, missing, BASE_FLOAT) + ismissing(radius_ext) && Base.error("Missing 'radius_ext' for Semicon first layer.") + return Semicon(radius_in, radius_ext, material_props; temperature=temperature) + else + Base.error("Unsupported layer type for first layer reconstruction: $LayerType") + end + catch e + @error "Construction failed for first layer of type $LayerType with data: $deserialized_layer_dict. Error: $e" + rethrow(e) + end +end + +""" +$(TYPEDSIGNATURES) + +Reconstructs a complete [`CableDesign`](@ref) object from its dictionary representation (parsed from JSON). +This function handles the sequential process of building cable designs: + 1. Deserialize [`NominalData`](@ref). + 2. Iterate through components. + 3. For each component: +    a. Reconstruct the first layer of the conductor group. +    b. Create the [`ConductorGroup`](@ref) with the first layer. +    c. Add subsequent conductor layers using [`add!`](@ref). +    d. Repeat a-c for the [`InsulatorGroup`](@ref). +    e. Create the [`CableComponent`](@ref). + 4. Create the [`CableDesign`](@ref) with the first component. + 5. Add subsequent components using [`add!`](@ref). + +# Arguments +- `cable_id`: The identifier string for the cable design. +- `design_data`: Dictionary containing the data for the cable design. + +# Returns +- A fully reconstructed [`CableDesign`](@ref) object. + +# Throws +- Error if reconstruction fails at any step. +""" +function _reconstruct_cabledesign( + cable_id::String, + design_data::Dict, +)::CableDesign + @info "Reconstructing CableDesign: $cable_id" + + # 1. Reconstruct NominalData using generic deserialization + local nominal_data::NominalData + if haskey(design_data, "nominal_data") + # Ensure the input to _deserialize_value is the Dict for NominalData + nominal_data_dict = design_data["nominal_data"] + if !(nominal_data_dict isa AbstractDict) + error( + "Invalid format for 'nominal_data' in $cable_id: Expected Dictionary, got $(typeof(nominal_data_dict))", + ) + end + nominal_data_val = _deserialize_value(nominal_data_dict) + if !(nominal_data_val isa NominalData) + # This error check relies on _deserialize_value returning the original dict on failure + error( + "Field 'nominal_data' did not deserialize to a NominalData object for $cable_id. Got: $(typeof(nominal_data_val))", + ) + end + nominal_data = nominal_data_val + @info " Reconstructed NominalData" + else + @warn "Missing 'nominal_data' for $cable_id. Using default NominalData()." + nominal_data = NominalData() # Use default if missing + end + + # 2. Process Components Sequentially + components_data = get(design_data, "components", []) + if isempty(components_data) || !(components_data isa AbstractVector) + Base.error("Missing or invalid 'components' array in design data for $cable_id") + end + + reconstructed_components = CableComponent[] # Store fully built components + + + for (idx, comp_data) in enumerate(components_data) + if !(comp_data isa AbstractDict) + @warn "Component data at index $idx for $cable_id is not a dictionary. Skipping." + continue + end + comp_id = get(comp_data, "id", "UNKNOWN_COMPONENT_ID_$idx") + @info " Processing Component $idx: $comp_id" + + # --- 2.1 Build Conductor Group --- + local conductor_group::ConductorGroup + conductor_group_data = get(comp_data, "conductor_group", Dict()) + cond_layers_data = get(conductor_group_data, "layers", []) + + if isempty(cond_layers_data) || !(cond_layers_data isa AbstractVector) + Base.error("Component '$comp_id' has missing or invalid conductor group layers.") + end + + # - Create the FIRST layer object + # Ensure the input to _reconstruct_partsgroup is the Dict for the layer + first_layer_dict = cond_layers_data[1] + if !(first_layer_dict isa AbstractDict) + error( + "Invalid format for first conductor layer in component '$comp_id': Expected Dictionary, got $(typeof(first_layer_dict))", + ) + end + first_cond_layer = _reconstruct_partsgroup(first_layer_dict) + + # - Initialize ConductorGroup using its constructor with the first layer + conductor_group = ConductorGroup(first_cond_layer) + @info " Created ConductorGroup with first layer: $(typeof(first_cond_layer))" + + # - Add remaining layers using add! + for i in 2:lastindex(cond_layers_data) + layer_data = cond_layers_data[i] + if !(layer_data isa AbstractDict) + @warn "Conductor layer data at index $i for component $comp_id is not a dictionary. Skipping." + continue + end + + # Extract Type and necessary arguments for add! + LayerType = _resolve_type(layer_data["__julia_type__"]) + material_props = get_as(layer_data, "material_props", missing, BASE_FLOAT) + material_props isa Material || Base.error("'material_props' must deserialize to Material, got $(typeof(material_props))") + + # Prepare args and kwargs based on LayerType for add! + args = [] + kwargs = Dict{Symbol,Any}() + kwargs[:temperature] = get_as(layer_data, "temperature", T₀, BASE_FLOAT) + if haskey(layer_data, "lay_direction") # Only add if present + kwargs[:lay_direction] = get_as(layer_data, "lay_direction", 1, Int) + end + + # Extract type-specific arguments needed by add! + try + if LayerType == WireArray + radius_wire = get_as(layer_data, "radius_wire", missing, BASE_FLOAT) + num_wires = get_as(layer_data, "num_wires", missing, Int) + lay_ratio = get_as(layer_data, "lay_ratio", missing, BASE_FLOAT) + any(ismissing, (radius_wire, num_wires, lay_ratio)) && error( + "Missing required field(s) for WireArray layer $i in $comp_id", + ) + args = [radius_wire, num_wires, lay_ratio, material_props] + elseif LayerType == Tubular + radius_ext = get_as(layer_data, "radius_ext", missing, BASE_FLOAT) + ismissing(radius_ext) && + Base.error("Missing 'radius_ext' for Tubular layer $i in $comp_id") + args = [radius_ext, material_props] + elseif LayerType == Strip + radius_ext = get_as(layer_data, "radius_ext", missing, BASE_FLOAT) + width = get_as(layer_data, "width", missing, BASE_FLOAT) + lay_ratio = get_as(layer_data, "lay_ratio", missing, BASE_FLOAT) + any(ismissing, (radius_ext, width, lay_ratio)) && + Base.error("Missing required field(s) for Strip layer $i in $comp_id") + args = [radius_ext, width, lay_ratio, material_props] + else + Base.error("Unsupported layer type '$LayerType' for add!") + end + + # Call add! with Type, args..., and kwargs... + add!(conductor_group, LayerType, args...; kwargs...) + @info " Added conductor layer $i: $LayerType" + catch e + @error "Failed to add conductor layer $i ($LayerType) to component $comp_id: $e" + println(stderr, " Layer Data: $layer_data") + println(stderr, " Args: $args") + println(stderr, " Kwargs: $kwargs") + rethrow(e) + end + end # End loop for conductor layers + + # --- 2.2 Build Insulator Group (Analogous logic) --- + local insulator_group::InsulatorGroup + insulator_group_data = get(comp_data, "insulator_group", Dict()) + insu_layers_data = get(insulator_group_data, "layers", []) + + if isempty(insu_layers_data) || !(insu_layers_data isa AbstractVector) + Base.error("Component '$comp_id' has missing or invalid insulator group layers.") + end + + # - Create the FIRST layer object + first_layer_dict_insu = insu_layers_data[1] + if !(first_layer_dict_insu isa AbstractDict) + error( + "Invalid format for first insulator layer in component '$comp_id': Expected Dictionary, got $(typeof(first_layer_dict_insu))", + ) + end + first_insu_layer = _reconstruct_partsgroup(first_layer_dict_insu) + + # - Initialize InsulatorGroup + insulator_group = InsulatorGroup(first_insu_layer) + @info " Created InsulatorGroup with first layer: $(typeof(first_insu_layer))" + + # - Add remaining layers using add! + for i in 2:lastindex(insu_layers_data) + layer_data = insu_layers_data[i] + if !(layer_data isa AbstractDict) + @warn "Insulator layer data at index $i for component $comp_id is not a dictionary. Skipping." + continue + end + + LayerType = _resolve_type(layer_data["__julia_type__"]) + material_props = get_as(layer_data, "material_props", missing, BASE_FLOAT) + material_props isa Material || Base.error("'material_props' must deserialize to Material, got $(typeof(material_props))") + + + args = [] + kwargs = Dict{Symbol,Any}() + kwargs[:temperature] = get_as(layer_data, "temperature", T₀, BASE_FLOAT) + + try + # All insulator types (Semicon, Insulator) take radius_ext, material_props + # for the add! method. + if LayerType in [Semicon, Insulator] + radius_ext = get_as(layer_data, "radius_ext", missing, BASE_FLOAT) + ismissing(radius_ext) && + Base.error("Missing 'radius_ext' for $LayerType layer $i in $comp_id") + args = [radius_ext, material_props] + else + Base.error("Unsupported layer type '$LayerType' for add!") + end + + # Call add! with Type, args..., and kwargs... + add!(insulator_group, LayerType, args...; kwargs...) + @info " Added insulator layer $i: $LayerType" + catch e + @error "Failed to add insulator layer $i ($LayerType) to component $comp_id: $e" + println(stderr, " Layer Data: $layer_data") + println(stderr, " Args: $args") + println(stderr, " Kwargs: $kwargs") + rethrow(e) + end + end # End loop for insulator layers + + # --- 2.3 Create the CableComponent object --- + component = CableComponent(comp_id, conductor_group, insulator_group) + push!(reconstructed_components, component) + @info " Created CableComponent: $comp_id" + + end # End loop through components_data + + # 3. Create the final CableDesign object using the first component + if isempty(reconstructed_components) + Base.error("Failed to reconstruct any valid components for cable design '$cable_id'") + end + # Use the CableDesign constructor which takes the first component + cable_design = + CableDesign(cable_id, reconstructed_components[1]; nominal_data=nominal_data) + @info " Created initial CableDesign with component: $(reconstructed_components[1].id)" + + # 4. Add remaining components to the design sequentially using add! + for i in 2:lastindex(reconstructed_components) + try + add!(cable_design, reconstructed_components[i]) + @info " Added component $(reconstructed_components[i].id) to CableDesign '$cable_id'" + catch e + @error "Failed to add component '$(reconstructed_components[i].id)' to CableDesign '$cable_id': $e" + rethrow(e) + end + end + + @info "Finished Reconstructing CableDesign: $cable_id" + return cable_design end \ No newline at end of file diff --git a/src/importexport/deserialize.jl b/src/importexport/deserialize.jl index 88551db2..8a14031f 100644 --- a/src/importexport/deserialize.jl +++ b/src/importexport/deserialize.jl @@ -1,328 +1,328 @@ -@inline function _resolve_dotted_in(path::String, root::Module) - cur = root - for p in split(path, '.') - s = Symbol(p) - if isdefined(cur, s) - cur = getfield(cur, s) - else - return nothing - end - end - return cur isa Type ? cur : nothing -end - -function _module_candidates() - pkg = parentmodule(@__MODULE__) # e.g., LineCableModels - cands = Module[@__MODULE__] - pkg !== nothing && push!(cands, pkg) - push!(cands, Main) - if pkg !== nothing - for name in (:DataModel, :Materials, :Engine, :EarthProps, :ImportExport) - if isdefined(pkg, name) - mod = getfield(pkg, name) - mod isa Module && push!(cands, mod) - end - end - end - return cands -end - - -""" -$(TYPEDSIGNATURES) - -Resolves a fully qualified type name string (e.g., \"Module.Type\") -into a Julia `Type` object. - -Resolution order: -1) If fully-qualified (contains '.') and not parametric, walk modules (no eval). -2) If bare name (no '.' and not parametric), search candidate modules. -3) Fallback: parse + eval in `Main` (handles parametric types like `Vector{Float64}`). - -# Arguments - -- `type_str`: The string representation of the type. - -# Returns - -- The corresponding Julia `Type` object. - -# Throws - -- `Error` if the type cannot be resolved. -""" -function _resolve_type(type_str::String) - pkg = parentmodule(@__MODULE__) - try - # 1) Fully-qualified, non-parametric: try walking - if occursin('.', type_str) && !occursin('{', type_str) - if (T = _resolve_dotted_in(type_str, Main)) !== nothing - return T - end - if pkg !== nothing - if (T = _resolve_dotted_in(type_str, pkg)) !== nothing - return T - end - end - end - - # 2) Bare name, non-parametric: search candidate modules - if !occursin('.', type_str) && !occursin('{', type_str) - sym = Symbol(type_str) - for m in _module_candidates() - if isdefined(m, sym) - val = getfield(m, sym) - if val isa Type - return val - end - end - end - end - - # 3) General case: parse + eval in package root (or Main as fallback) - return Base.eval(pkg === nothing ? Main : pkg, Meta.parse(type_str)) - catch e - @error "Could not resolve type '$type_str'" exception = (e, catch_backtrace()) - rethrow(e) - end -end -# function _resolve_type(type_str::String) -# try -# return Core.eval(@__MODULE__, Meta.parse(type_str)) -# catch e -# @error "Could not resolve type '$type_str'. Ensure module structure is correct and type is loaded in Main." -# rethrow(e) -# end -# end - -""" -$(TYPEDSIGNATURES) - -Deserializes a value from its JSON representation back into a Julia value. -Handles special type markers for `Measurements`, `Inf`/`NaN`, and custom structs -identified by `__julia_type__`. Ensures plain dictionaries use Symbol keys. - -# Arguments -- `value`: The JSON-parsed value (Dict, Vector, Number, String, Bool, Nothing). - -# Returns -- The deserialized Julia value. -""" -function _deserialize_value(value) - if value isa Dict - # Check for special type markers first - if haskey(value, "__type__") - type_marker = value["__type__"] - if type_marker == "Measurement" - # Reconstruct Measurement - uncval = get_as(value, "uncertainty", nothing, Measurement) - if isa(uncval, Measurement) - return uncval - else - @warn "Could not reconstruct Measurement from input: value=$(typeof(get_as(value, "value", nothing, BASE_FLOAT))), uncertainty=$(typeof(get_as(value, "uncertainty", nothing, BASE_FLOAT))). Returning original Dict." - return value # Return original dict if parts are invalid - end - - elseif type_marker == "SpecialFloat" - # Reconstruct Inf/NaN - val_str = get(value, "value", "") - if val_str == "Inf" - return Inf - end - if val_str == "-Inf" - return -Inf - end - if val_str == "NaN" - return NaN - end - @warn "Unknown SpecialFloat value: '$val_str'. Returning original Dict." - return value - - elseif type_marker == "Float" - return get_as(value, "value", nothing, BASE_FLOAT) - - elseif type_marker == "Int" - return get_as(value, "value", nothing, Int) - - elseif type_marker == "Complex" - return get_as(value, "value", nothing, Complex) - - else - @warn "Unknown __type__ marker: '$type_marker'. Processing as regular dictionary." - # Fall through to regular dictionary processing - end - end - - # Check for Julia object marker - if haskey(value, "__julia_type__") - type_str = value["__julia_type__"] - try - T = _resolve_type(type_str) - # Delegate object construction to _deserialize_obj - return _deserialize_obj(value, T) - catch e - # Catch errors specifically from _deserialize_obj or _resolve_type - @error "Failed to resolve or deserialize type '$type_str': $e. Returning original Dict." - showerror(stderr, e, catch_backtrace()) - println(stderr) - return value # Return original dict on error - end - end - return Dict(Symbol(k) => _deserialize_value(v) for (k, v) in value) - - elseif value isa Vector - # Recursively deserialize array elements - return [_deserialize_value(v) for v in value] - - else - # Basic JSON types (Number, String, Bool, Nothing) pass through - return value - - end -end - -""" -$(TYPEDSIGNATURES) - -Retrieves a value from a dictionary by key, deserializes it, and coerces it to the specified type `T`. Returns a default value if the key is missing or the value is `missing`. - -# Arguments - -- `d`: The dictionary to query. -- `key`: The key to look up (symbol or string). -- `default`: The value to return if the key is not present. -- `T`: The target type for coercion. - -# Returns - -- The value associated with `key` in `d`, deserialized and coerced to type `T`, or `default` if the key is missing or the value is `missing`. - -# Examples - -```julia -result = $(FUNCTIONNAME)(Dict(:a => 1), :a, 0, Int) # Returns 1 -result = $(FUNCTIONNAME)(Dict(), :b, 42, Int) # Returns 42 -``` -""" -get_as(d::AbstractDict, key::Union{Symbol, AbstractString}, default, ::Type{T}) where {T} = - begin - v = get(d, key, default) - v === missing ? missing : coerce_to_T(_deserialize_value(v), T) - end - -""" -$(TYPEDSIGNATURES) - -Deserializes a dictionary (parsed from JSON) into a Julia object of type `T`. -Attempts keyword constructor first, then falls back to positional constructor -if the keyword attempt fails with a specific `MethodError`. - -# Arguments -- `dict`: Dictionary containing the serialized object data. Keys should match field names. -- `T`: The target Julia `Type` to instantiate. - -# Returns -- An instance of type `T`. - -# Throws -- `Error` if construction fails by both methods. -""" -function _deserialize_obj(dict::Dict, ::Type{T}) where {T} - # Prepare a dictionary mapping field symbols to deserialized values - deserialized_fields = Dict{Symbol, Any}() - for (key_str, val) in dict - # Skip metadata keys - if key_str == "__julia_type__" || key_str == "__type__" - continue - end - key_sym = Symbol(key_str) - # Ensure value is deserialized before storing - deserialized_fields[key_sym] = _deserialize_value(val) - - end - - # --- Attempt 1: Keyword Constructor --- - try - # Convert Dict{Symbol, Any} to pairs for keyword constructor T(; pairs...) - # Ensure kwargs only contain keys that are valid fieldnames for T - # This prevents errors if extra keys were present in JSON - valid_keys = fieldnames(T) - kwargs = pairs(filter(p -> p.first in valid_keys, deserialized_fields)) - - # @info "Attempting keyword construction for $T with kwargs: $(collect(kwargs))" # Debug logging - if !isempty(kwargs) || hasmethod(T, Tuple{}, Symbol[]) # Check if kw constructor exists or if kwargs are empty - return T(; kwargs...) - else - # If no kwargs and no zero-arg kw constructor, trigger fallback - error( - "No keyword arguments provided and no zero-argument keyword constructor found for $T.", - ) - end - catch e - # Check if the error is specifically a MethodError for the keyword call - is_kw_meth_error = - e isa MethodError && (e.f === Core.kwcall || (e.f === T && isempty(e.args))) # Check for kwcall or zero-arg method error - - if is_kw_meth_error - # @info "Keyword construction failed for $T (as expected for types without kw constructor). Trying positional." # Debug logging - # Fall through to positional attempt - else - # Different error during keyword construction (e.g., type mismatch inside constructor) - @error "Keyword construction failed for type $T with unexpected error: $e" - println(stderr, "Input dictionary: $dict") - println(stderr, "Deserialized fields (kwargs used): $(deserialized_fields)") - rethrow(e) # Rethrow unexpected errors - end - end - - # --- Attempt 2: Positional Constructor (Fallback) --- - # @info "Attempting positional construction for $T" # Debug logging - fields_in_order = fieldnames(T) - positional_args = [] - - try - # Check if the number of deserialized fields matches the number of struct fields - # This is a basic check for suitability of positional constructor - # It might be too strict if optional fields were omitted in JSON for keyword constructor types - # but for true positional types, all fields should generally be present. - # if length(deserialized_fields) != length(fields_in_order) - # Base.error("Number of fields in JSON ($(length(deserialized_fields))) does not match number of fields in struct $T ($(length(fields_in_order))). Cannot use positional constructor.") - # end - - for field_sym in fields_in_order - if haskey(deserialized_fields, field_sym) - push!(positional_args, deserialized_fields[field_sym]) - else - # If a field is missing, positional construction will fail. - error( - "Cannot attempt positional construction for $T: Missing required field '$field_sym' in input data.", - ) - end - end - - # @info "Positional args for $T: $positional_args" # Debug logging - return T(positional_args...) - catch e - # Catch errors during positional construction (e.g., MethodError, TypeError) - @error "Positional construction failed for type $T with args: $positional_args. Error: $e" - println(stderr, "Input dictionary: $dict") - println( - stderr, - "Deserialized fields used for positional args: $(deserialized_fields)", - ) - # Check argument count mismatch again, although the loop above should ensure it if no error occurred there - if length(positional_args) != length(fields_in_order) - println( - stderr, - "Mismatch between number of args provided ($(length(positional_args))) and fields expected ($(length(fields_in_order))).", - ) - end - # Rethrow the error after providing context. This indicates neither method worked. - rethrow(e) - end - - # This line should ideally not be reached - error( - "Failed to construct object of type $T using both keyword and positional methods.", - ) -end +@inline function _resolve_dotted_in(path::String, root::Module) + cur = root + for p in split(path, '.') + s = Symbol(p) + if isdefined(cur, s) + cur = getfield(cur, s) + else + return nothing + end + end + return cur isa Type ? cur : nothing +end + +function _module_candidates() + pkg = parentmodule(@__MODULE__) # e.g., LineCableModels + cands = Module[@__MODULE__] + pkg !== nothing && push!(cands, pkg) + push!(cands, Main) + if pkg !== nothing + for name in (:DataModel, :Materials, :Engine, :EarthProps, :ImportExport) + if isdefined(pkg, name) + mod = getfield(pkg, name) + mod isa Module && push!(cands, mod) + end + end + end + return cands +end + + +""" +$(TYPEDSIGNATURES) + +Resolves a fully qualified type name string (e.g., \"Module.Type\") +into a Julia `Type` object. + +Resolution order: +1) If fully-qualified (contains '.') and not parametric, walk modules (no eval). +2) If bare name (no '.' and not parametric), search candidate modules. +3) Fallback: parse + eval in `Main` (handles parametric types like `Vector{Float64}`). + +# Arguments + +- `type_str`: The string representation of the type. + +# Returns + +- The corresponding Julia `Type` object. + +# Throws + +- `Error` if the type cannot be resolved. +""" +function _resolve_type(type_str::String) + pkg = parentmodule(@__MODULE__) + try + # 1) Fully-qualified, non-parametric: try walking + if occursin('.', type_str) && !occursin('{', type_str) + if (T = _resolve_dotted_in(type_str, Main)) !== nothing + return T + end + if pkg !== nothing + if (T = _resolve_dotted_in(type_str, pkg)) !== nothing + return T + end + end + end + + # 2) Bare name, non-parametric: search candidate modules + if !occursin('.', type_str) && !occursin('{', type_str) + sym = Symbol(type_str) + for m in _module_candidates() + if isdefined(m, sym) + val = getfield(m, sym) + if val isa Type + return val + end + end + end + end + + # 3) General case: parse + eval in package root (or Main as fallback) + return Base.eval(pkg === nothing ? Main : pkg, Meta.parse(type_str)) + catch e + @error "Could not resolve type '$type_str'" exception = (e, catch_backtrace()) + rethrow(e) + end +end +# function _resolve_type(type_str::String) +# try +# return Core.eval(@__MODULE__, Meta.parse(type_str)) +# catch e +# @error "Could not resolve type '$type_str'. Ensure module structure is correct and type is loaded in Main." +# rethrow(e) +# end +# end + +""" +$(TYPEDSIGNATURES) + +Deserializes a value from its JSON representation back into a Julia value. +Handles special type markers for `Measurements`, `Inf`/`NaN`, and custom structs +identified by `__julia_type__`. Ensures plain dictionaries use Symbol keys. + +# Arguments +- `value`: The JSON-parsed value (Dict, Vector, Number, String, Bool, Nothing). + +# Returns +- The deserialized Julia value. +""" +function _deserialize_value(value) + if value isa Dict + # Check for special type markers first + if haskey(value, "__type__") + type_marker = value["__type__"] + if type_marker == "Measurement" + # Reconstruct Measurement + uncval = get_as(value, "uncertainty", nothing, Measurement) + if isa(uncval, Measurement) + return uncval + else + @warn "Could not reconstruct Measurement from input: value=$(typeof(get_as(value, "value", nothing, BASE_FLOAT))), uncertainty=$(typeof(get_as(value, "uncertainty", nothing, BASE_FLOAT))). Returning original Dict." + return value # Return original dict if parts are invalid + end + + elseif type_marker == "SpecialFloat" + # Reconstruct Inf/NaN + val_str = get(value, "value", "") + if val_str == "Inf" + return Inf + end + if val_str == "-Inf" + return -Inf + end + if val_str == "NaN" + return NaN + end + @warn "Unknown SpecialFloat value: '$val_str'. Returning original Dict." + return value + + elseif type_marker == "Float" + return get_as(value, "value", nothing, BASE_FLOAT) + + elseif type_marker == "Int" + return get_as(value, "value", nothing, Int) + + elseif type_marker == "Complex" + return get_as(value, "value", nothing, Complex) + + else + @warn "Unknown __type__ marker: '$type_marker'. Processing as regular dictionary." + # Fall through to regular dictionary processing + end + end + + # Check for Julia object marker + if haskey(value, "__julia_type__") + type_str = value["__julia_type__"] + try + T = _resolve_type(type_str) + # Delegate object construction to _deserialize_obj + return _deserialize_obj(value, T) + catch e + # Catch errors specifically from _deserialize_obj or _resolve_type + @error "Failed to resolve or deserialize type '$type_str': $e. Returning original Dict." + showerror(stderr, e, catch_backtrace()) + println(stderr) + return value # Return original dict on error + end + end + return Dict(Symbol(k) => _deserialize_value(v) for (k, v) in value) + + elseif value isa Vector + # Recursively deserialize array elements + return [_deserialize_value(v) for v in value] + + else + # Basic JSON types (Number, String, Bool, Nothing) pass through + return value + + end +end + +""" +$(TYPEDSIGNATURES) + +Retrieves a value from a dictionary by key, deserializes it, and coerces it to the specified type `T`. Returns a default value if the key is missing or the value is `missing`. + +# Arguments + +- `d`: The dictionary to query. +- `key`: The key to look up (symbol or string). +- `default`: The value to return if the key is not present. +- `T`: The target type for coercion. + +# Returns + +- The value associated with `key` in `d`, deserialized and coerced to type `T`, or `default` if the key is missing or the value is `missing`. + +# Examples + +```julia +result = $(FUNCTIONNAME)(Dict(:a => 1), :a, 0, Int) # Returns 1 +result = $(FUNCTIONNAME)(Dict(), :b, 42, Int) # Returns 42 +``` +""" +get_as(d::AbstractDict, key::Union{Symbol, AbstractString}, default, ::Type{T}) where {T} = + begin + v = get(d, key, default) + v === missing ? missing : coerce_to_T(_deserialize_value(v), T) + end + +""" +$(TYPEDSIGNATURES) + +Deserializes a dictionary (parsed from JSON) into a Julia object of type `T`. +Attempts keyword constructor first, then falls back to positional constructor +if the keyword attempt fails with a specific `MethodError`. + +# Arguments +- `dict`: Dictionary containing the serialized object data. Keys should match field names. +- `T`: The target Julia `Type` to instantiate. + +# Returns +- An instance of type `T`. + +# Throws +- `Error` if construction fails by both methods. +""" +function _deserialize_obj(dict::Dict, ::Type{T}) where {T} + # Prepare a dictionary mapping field symbols to deserialized values + deserialized_fields = Dict{Symbol, Any}() + for (key_str, val) in dict + # Skip metadata keys + if key_str == "__julia_type__" || key_str == "__type__" + continue + end + key_sym = Symbol(key_str) + # Ensure value is deserialized before storing + deserialized_fields[key_sym] = _deserialize_value(val) + + end + + # --- Attempt 1: Keyword Constructor --- + try + # Convert Dict{Symbol, Any} to pairs for keyword constructor T(; pairs...) + # Ensure kwargs only contain keys that are valid fieldnames for T + # This prevents errors if extra keys were present in JSON + valid_keys = fieldnames(T) + kwargs = pairs(filter(p -> p.first in valid_keys, deserialized_fields)) + + # @info "Attempting keyword construction for $T with kwargs: $(collect(kwargs))" # Debug logging + if !isempty(kwargs) || hasmethod(T, Tuple{}, Symbol[]) # Check if kw constructor exists or if kwargs are empty + return T(; kwargs...) + else + # If no kwargs and no zero-arg kw constructor, trigger fallback + error( + "No keyword arguments provided and no zero-argument keyword constructor found for $T.", + ) + end + catch e + # Check if the error is specifically a MethodError for the keyword call + is_kw_meth_error = + e isa MethodError && (e.f === Core.kwcall || (e.f === T && isempty(e.args))) # Check for kwcall or zero-arg method error + + if is_kw_meth_error + # @info "Keyword construction failed for $T (as expected for types without kw constructor). Trying positional." # Debug logging + # Fall through to positional attempt + else + # Different error during keyword construction (e.g., type mismatch inside constructor) + @error "Keyword construction failed for type $T with unexpected error: $e" + println(stderr, "Input dictionary: $dict") + println(stderr, "Deserialized fields (kwargs used): $(deserialized_fields)") + rethrow(e) # Rethrow unexpected errors + end + end + + # --- Attempt 2: Positional Constructor (Fallback) --- + # @info "Attempting positional construction for $T" # Debug logging + fields_in_order = fieldnames(T) + positional_args = [] + + try + # Check if the number of deserialized fields matches the number of struct fields + # This is a basic check for suitability of positional constructor + # It might be too strict if optional fields were omitted in JSON for keyword constructor types + # but for true positional types, all fields should generally be present. + # if length(deserialized_fields) != length(fields_in_order) + # Base.error("Number of fields in JSON ($(length(deserialized_fields))) does not match number of fields in struct $T ($(length(fields_in_order))). Cannot use positional constructor.") + # end + + for field_sym in fields_in_order + if haskey(deserialized_fields, field_sym) + push!(positional_args, deserialized_fields[field_sym]) + else + # If a field is missing, positional construction will fail. + error( + "Cannot attempt positional construction for $T: Missing required field '$field_sym' in input data.", + ) + end + end + + # @info "Positional args for $T: $positional_args" # Debug logging + return T(positional_args...) + catch e + # Catch errors during positional construction (e.g., MethodError, TypeError) + @error "Positional construction failed for type $T with args: $positional_args. Error: $e" + println(stderr, "Input dictionary: $dict") + println( + stderr, + "Deserialized fields used for positional args: $(deserialized_fields)", + ) + # Check argument count mismatch again, although the loop above should ensure it if no error occurred there + if length(positional_args) != length(fields_in_order) + println( + stderr, + "Mismatch between number of args provided ($(length(positional_args))) and fields expected ($(length(fields_in_order))).", + ) + end + # Rethrow the error after providing context. This indicates neither method worked. + rethrow(e) + end + + # This line should ideally not be reached + error( + "Failed to construct object of type $T using both keyword and positional methods.", + ) +end diff --git a/src/importexport/materialslibrary.jl b/src/importexport/materialslibrary.jl index 64241049..b6f725e6 100644 --- a/src/importexport/materialslibrary.jl +++ b/src/importexport/materialslibrary.jl @@ -1,193 +1,193 @@ -""" -$(TYPEDSIGNATURES) - -Saves a [`MaterialsLibrary`](@ref) to a JSON file. - -# Arguments -- `library`: The [`MaterialsLibrary`](@ref) instance to save. -- `file_name`: The path to the output JSON file (default: "materials_library.json"). - -# Returns -- The absolute path of the saved file, or `nothing` on failure. -""" -function save( - library::MaterialsLibrary; - file_name::String = "materials_library.json", -)::Union{String, Nothing} - # TODO: Add jls serialization to materials library. - # Issue URL: https://github.com/Electa-Git/LineCableModels.jl/issues/3 - file_name = isabspath(file_name) ? file_name : joinpath(@__DIR__, file_name) - - - _, ext = splitext(file_name) - ext = lowercase(ext) - if ext != ".json" - @warn "MaterialsLibrary only supports .json saving. Forcing extension for file '$file_name'." - file_name = first(splitext(file_name)) * ".json" - end - - try - - return _save_materialslibrary_json(library, file_name) - - catch e - @error "Error saving MaterialsLibrary to '$(display_path(file_name))': $e" - showerror(stderr, e, catch_backtrace()) - println(stderr) - return nothing - end -end - -""" -$(TYPEDSIGNATURES) - -Internal function to save the [`MaterialsLibrary`](@ref) to JSON. - -# Arguments -- `library`: The [`MaterialsLibrary`](@ref) instance. -- `file_name`: The output file path. - -# Returns -- The absolute path of the saved file. -""" -function _save_materialslibrary_json(library::MaterialsLibrary, file_name::String)::String - # Check if the library has the data field initialized correctly - if !isdefined(library, :data) || !(library.data isa AbstractDict) - Base.error("MaterialsLibrary does not have a valid 'data' dictionary. Cannot save.") - end - - # Use the generic _serialize_value, which handles the dictionary and its Material contents - serialized_library_data = _serialize_value(library) # Serialize the dict directly - - open(file_name, "w") do io - JSON3.pretty(io, serialized_library_data, allow_inf = true) - end - if isfile(file_name) - @info "Materials library saved to: $(display_path(file_name))" - end - - return abspath(file_name) -end - -""" -$(TYPEDSIGNATURES) - -Loads materials from a JSON file into an existing [`MaterialsLibrary`](@ref) object. -Modifies the library in-place. - -# Arguments -- `library`: The [`MaterialsLibrary`](@ref) instance to populate (modified in-place). -- `file_name`: Path to the JSON file to load (default: \"materials_library.json\"). - -# Returns -- The modified [`MaterialsLibrary`](@ref) instance. - -# See also -- [`MaterialsLibrary`](@ref) -""" -function load!( - library::MaterialsLibrary; - file_name::String = "materials_library.json", -)::MaterialsLibrary - - if !isfile(file_name) - throw( - ErrorException( - "Materials library file not found: '$(display_path(file_name))'", - ), - ) # make caller receive an Exception - - end - - # Only JSON format is supported now - _, ext = splitext(file_name) - ext = lowercase(ext) - if ext != ".json" - @error "MaterialsLibrary loading only supports .json files. Cannot load '$(display_path(file_name))'." - return library - end - - try - _load_materialslibrary_json!(library, file_name) - catch e - @error "Error loading MaterialsLibrary from '$(display_path(file_name))': $e" - showerror(stderr, e, catch_backtrace()) - println(stderr) - # Optionally clear or leave partially loaded - # empty!(library) - end - return library -end - -""" -$(TYPEDSIGNATURES) - -Internal function to load materials from JSON into the library. - -# Arguments -- `library`: The [`MaterialsLibrary`](@ref) instance to modify. -- `file_name`: The path to the JSON file. - -# Returns -- Nothing. Modifies `library` in-place. - -# See also -- [`MaterialsLibrary`](@ref) -- [`Material`](@ref) -- [`add!`](@ref) -- [`_deserialize_value`](@ref) -""" -function _load_materialslibrary_json!(library::MaterialsLibrary, file_name::String) - # Ensure library structure is initialized - if !isdefined(library, :data) || !(library.data isa AbstractDict) - @warn "Library 'data' field was not initialized or not a Dict. Initializing." - library.data = Dict{String, Material}() - else - # Clear existing materials before loading - empty!(library.data) - end - - # Load and parse the JSON data (expecting a Dict of material_name => material_data) - json_data = open(file_name, "r") do io - JSON3.read(io, Dict{String, Any}) - end - - - @info "Loading materials from JSON: '$(display_path(file_name))'..." - num_loaded = 0 - num_failed = 0 - - # Process each material entry - for (name::String, material_data::Any) in json_data - if !(material_data isa AbstractDict) - @warn "Skipping material '$name': Invalid data format (expected Dictionary, got $(typeof(material_data)))." - num_failed += 1 - continue - end - try - # Use the generic _deserialize_value function. - # It will detect __julia_type__ and call _deserialize_obj for Material. - deserialized_material = _deserialize_value(material_data) - - # **Crucial Check:** Verify the deserialized object is actually a Material - if deserialized_material isa Material - add!(library, name, deserialized_material) # Assumes this function exists - num_loaded += 1 - else - # This path is taken if _deserialize_obj failed and returned the original Dict - @warn "Skipping material '$name': Failed to deserialize into Material object. Data received: $material_data" - # The error from _deserialize_obj inside _deserialize_value would have already been logged. - num_failed += 1 - end - catch e - # Catch errors that might occur outside _deserialize_value (e.g., in add!) - num_failed += 1 - @error "Error processing material entry '$name': $e" - showerror(stderr, e, catch_backtrace()) - println(stderr) - end - end - - @info "Finished loading materials from '$(display_path(file_name))'. Successfully loaded $num_loaded materials, failed to load $num_failed." - return nothing -end +""" +$(TYPEDSIGNATURES) + +Saves a [`MaterialsLibrary`](@ref) to a JSON file. + +# Arguments +- `library`: The [`MaterialsLibrary`](@ref) instance to save. +- `file_name`: The path to the output JSON file (default: "materials_library.json"). + +# Returns +- The absolute path of the saved file, or `nothing` on failure. +""" +function save( + library::MaterialsLibrary; + file_name::String = "materials_library.json", +)::Union{String, Nothing} + # TODO: Add jls serialization to materials library. + # Issue URL: https://github.com/Electa-Git/LineCableModels.jl/issues/3 + file_name = isabspath(file_name) ? file_name : joinpath(@__DIR__, file_name) + + + _, ext = splitext(file_name) + ext = lowercase(ext) + if ext != ".json" + @warn "MaterialsLibrary only supports .json saving. Forcing extension for file '$file_name'." + file_name = first(splitext(file_name)) * ".json" + end + + try + + return _save_materialslibrary_json(library, file_name) + + catch e + @error "Error saving MaterialsLibrary to '$(display_path(file_name))': $e" + showerror(stderr, e, catch_backtrace()) + println(stderr) + return nothing + end +end + +""" +$(TYPEDSIGNATURES) + +Internal function to save the [`MaterialsLibrary`](@ref) to JSON. + +# Arguments +- `library`: The [`MaterialsLibrary`](@ref) instance. +- `file_name`: The output file path. + +# Returns +- The absolute path of the saved file. +""" +function _save_materialslibrary_json(library::MaterialsLibrary, file_name::String)::String + # Check if the library has the data field initialized correctly + if !isdefined(library, :data) || !(library.data isa AbstractDict) + Base.error("MaterialsLibrary does not have a valid 'data' dictionary. Cannot save.") + end + + # Use the generic _serialize_value, which handles the dictionary and its Material contents + serialized_library_data = _serialize_value(library) # Serialize the dict directly + + open(file_name, "w") do io + JSON3.pretty(io, serialized_library_data, allow_inf = true) + end + if isfile(file_name) + @info "Materials library saved to: $(display_path(file_name))" + end + + return abspath(file_name) +end + +""" +$(TYPEDSIGNATURES) + +Loads materials from a JSON file into an existing [`MaterialsLibrary`](@ref) object. +Modifies the library in-place. + +# Arguments +- `library`: The [`MaterialsLibrary`](@ref) instance to populate (modified in-place). +- `file_name`: Path to the JSON file to load (default: \"materials_library.json\"). + +# Returns +- The modified [`MaterialsLibrary`](@ref) instance. + +# See also +- [`MaterialsLibrary`](@ref) +""" +function load!( + library::MaterialsLibrary; + file_name::String = "materials_library.json", +)::MaterialsLibrary + + if !isfile(file_name) + throw( + ErrorException( + "Materials library file not found: '$(display_path(file_name))'", + ), + ) # make caller receive an Exception + + end + + # Only JSON format is supported now + _, ext = splitext(file_name) + ext = lowercase(ext) + if ext != ".json" + @error "MaterialsLibrary loading only supports .json files. Cannot load '$(display_path(file_name))'." + return library + end + + try + _load_materialslibrary_json!(library, file_name) + catch e + @error "Error loading MaterialsLibrary from '$(display_path(file_name))': $e" + showerror(stderr, e, catch_backtrace()) + println(stderr) + # Optionally clear or leave partially loaded + # empty!(library) + end + return library +end + +""" +$(TYPEDSIGNATURES) + +Internal function to load materials from JSON into the library. + +# Arguments +- `library`: The [`MaterialsLibrary`](@ref) instance to modify. +- `file_name`: The path to the JSON file. + +# Returns +- Nothing. Modifies `library` in-place. + +# See also +- [`MaterialsLibrary`](@ref) +- [`Material`](@ref) +- [`add!`](@ref) +- [`_deserialize_value`](@ref) +""" +function _load_materialslibrary_json!(library::MaterialsLibrary, file_name::String) + # Ensure library structure is initialized + if !isdefined(library, :data) || !(library.data isa AbstractDict) + @warn "Library 'data' field was not initialized or not a Dict. Initializing." + library.data = Dict{String, Material}() + else + # Clear existing materials before loading + empty!(library.data) + end + + # Load and parse the JSON data (expecting a Dict of material_name => material_data) + json_data = open(file_name, "r") do io + JSON3.read(io, Dict{String, Any}) + end + + + @info "Loading materials from JSON: '$(display_path(file_name))'..." + num_loaded = 0 + num_failed = 0 + + # Process each material entry + for (name::String, material_data::Any) in json_data + if !(material_data isa AbstractDict) + @warn "Skipping material '$name': Invalid data format (expected Dictionary, got $(typeof(material_data)))." + num_failed += 1 + continue + end + try + # Use the generic _deserialize_value function. + # It will detect __julia_type__ and call _deserialize_obj for Material. + deserialized_material = _deserialize_value(material_data) + + # **Crucial Check:** Verify the deserialized object is actually a Material + if deserialized_material isa Material + add!(library, name, deserialized_material) # Assumes this function exists + num_loaded += 1 + else + # This path is taken if _deserialize_obj failed and returned the original Dict + @warn "Skipping material '$name': Failed to deserialize into Material object. Data received: $material_data" + # The error from _deserialize_obj inside _deserialize_value would have already been logged. + num_failed += 1 + end + catch e + # Catch errors that might occur outside _deserialize_value (e.g., in add!) + num_failed += 1 + @error "Error processing material entry '$name': $e" + showerror(stderr, e, catch_backtrace()) + println(stderr) + end + end + + @info "Finished loading materials from '$(display_path(file_name))'. Successfully loaded $num_loaded materials, failed to load $num_failed." + return nothing +end diff --git a/src/importexport/pscad.jl b/src/importexport/pscad.jl index 2f3e44ed..b2c7688c 100644 --- a/src/importexport/pscad.jl +++ b/src/importexport/pscad.jl @@ -1,868 +1,868 @@ -#= -Generates sequential IDs, used for simulation element identification (e.g., PSCAD). -Starts from 100,000,000 and increments. -=# -let current_id = 100000000 - global _next_id = () -> (id = current_id; current_id += 1; string(id)) -end - -""" -$(TYPEDSIGNATURES) - -Exports a [`LineCableSystem`](@ref) to a PSCAD-compatible file format. - -# Arguments - -- `cable_system`: A [`LineCableSystem`](@ref) object representing the cable system to be exported. -- `earth_props`: An [`EarthModel`](@ref) object containing the earth properties. -- `base_freq`: The base frequency \\[Hz\\] used for the PSCAD export. -- `file_name`: The path to the output file (default: "*_export.pscx") - -# Returns - -- The absolute path of the saved file, or `nothing` on failure. - -# Examples - -```julia -cable_system = LineCableSystem(...) -earth_model = EarthModel(...) -$(FUNCTIONNAME)(cable_system, earth_model, base_freq=50) -``` - -# See also - -- [`LineCableSystem`](@ref) -""" -function export_data(::Val{:pscad}, - cable_system::LineCableSystem, - earth_props::EarthModel; - base_freq=f₀, - file_name::Union{String,Nothing}=nothing, -)::Union{String,Nothing} - - if isnothing(file_name) - # caller didn't supply a name -> derive from cable_system if present - file_name = joinpath(@__DIR__, "$(cable_system.system_id)_export.pscx") - else - # caller supplied a path/name -> respect directory, but prepend system_id to basename - requested = isabspath(file_name) ? file_name : joinpath(@__DIR__, file_name) - if isnothing(cable_system) - file_name = requested - else - dir = dirname(requested) - base = basename(requested) - file_name = joinpath(dir, "$(cable_system.system_id)_$base") - end - end - - # Sets attributes on an existing EzXML.Node from a dictionary. - function _set_attributes!(element::EzXML.Node, attrs::Dict{String,String}) - # Loop through the dictionary and set each attribute on the element - for (k, v) in attrs - element[k] = v - end - end - - # Adds child elements to an existing EzXML.Node - # from a vector of ("name", "value") tuples. - function _add_params_to_list!( - list_element::EzXML.Node, - params::Vector{Tuple{String,String}}, - ) - # Ensure the target element is actually a paramlist for clarity, though not strictly necessary for EzXML - # if nodename(list_element) != "paramlist" - # @warn "Attempting to add params to a non-paramlist node: $(nodename(list_element))" - # end - # Loop through the vector and add each parameter as a child element - for (name, value) in params - param = addelement!(list_element, "param") - param["name"] = name - param["value"] = value - end - end - - # --- Initial Setup (Identical to original) --- - # Local Ref for ID generation ensures it's unique to this function call if nested - current_id = Ref(100000000) - _next_id() = string(current_id[] += 1) - - # Formatting function (ensure to_nominal is defined or handle types appropriately) - format_nominal = - (X; sigdigits=4, minval=-1e30, maxval=1e30) -> begin - - local_value = round(to_nominal(X), sigdigits=sigdigits) - - local_value = max(min(local_value, maxval), minval) - if abs(local_value) < eps(Float64) - local_value = 0.0 - end - return string(local_value) - end - - id_map = Dict{String,String}() # Stores IDs needed for linking (Instance IDs in this case) - doc = XMLDocument() - project = ElementNode("project") - setroot!(doc, project) - project_id = cable_system.system_id - - # --- Project Attributes (Identical) --- - project["name"] = project_id - project["version"] = "5.0.2" - project["schema"] = "" - project["Target"] = "EMTDC" - - # --- Settings (Use Helper for Params) --- - settings = addelement!(project, "paramlist") - settings["name"] = "Settings" # Set name attribute directly as in original - timestamp = string(round(Int, datetime2unix(now()))) - settings_params = [ - ("creator", "LineCableModels.jl,$timestamp"), ("time_duration", "0.5"), - ("time_step", "5"), ("sample_step", "250"), ("chatter_threshold", ".001"), - ("branch_threshold", ".0005"), ("StartType", "0"), - ("startup_filename", "\$(Namespace).snp"), ("PlotType", "0"), - ("output_filename", "\$(Namespace).out"), ("SnapType", "0"), - ("SnapTime", "0.3"), ("snapshot_filename", "\$(Namespace).snp"), - ("MrunType", "0"), ("Mruns", "1"), ("Scenario", ""), ("Advanced", "14335"), - ("sparsity_threshold", "200"), ("Options", "16"), ("Build", "18"), - ("Warn", "0"), ("Check", "0"), - ( - "description", - "Created with LineCableModels.jl (https://github.com/Electa-Git/LineCableModels.jl)", - ), - ("Debug", "0"), - ] - _add_params_to_list!(settings, settings_params) # Use helper to add children - - # --- Empty Elements (Identical) --- - addelement!(project, "Layers") - addelement!(project, "List")["classid"] = "Settings" - addelement!(project, "bookmarks") - - # --- GlobalSubstitutions (Identical Structure) --- - global_subs = addelement!(project, "GlobalSubstitutions") - global_subs["name"] = "Default" - addelement!(global_subs, "List")["classid"] = "Sub" - addelement!(global_subs, "List")["classid"] = "ValueSet" - global_pl = addelement!(global_subs, "paramlist") # No name attribute - # Add the single parameter directly as in original - global_param = addelement!(global_pl, "param") - global_param["name"] = "Current" - global_param["value"] = "" - - # --- Definitions Section (Identical Start) --- - definitions = addelement!(project, "definitions") - - # --- StationDefn (Use Helpers for Attrs/Params) --- - station = addelement!(definitions, "Definition") - station_id = _next_id() - id_map["DS_Defn"] = station_id # Map Definition ID - station_attrs = Dict( - "classid" => "StationDefn", "name" => "DS", "id" => station_id, - "group" => "", "url" => "", "version" => "", "build" => "", - "crc" => "-1", "view" => "false", - ) - _set_attributes!(station, station_attrs) # Use helper - - station_pl = addelement!(station, "paramlist") - station_pl["name"] = "" # Keep empty name attribute exactly as original - # Add Description param directly as original - desc_param_st = addelement!(station_pl, "param") - desc_param_st["name"] = "Description" - desc_param_st["value"] = "" - - schematic = addelement!(station, "schematic") - schematic["classid"] = "StationCanvas" - schematic_pl = addelement!(schematic, "paramlist") # No name attribute - schematic_params = [ - ("show_grid", "0"), ("size", "0"), ("orient", "1"), ("show_border", "0"), - ("monitor_bus_voltage", "0"), ("show_signal", "0"), ("show_virtual", "0"), - ("show_sequence", "0"), ("auto_sequence", "1"), ("bus_expand_x", "8"), - ("bus_expand_y", "8"), ("bus_length", "4"), - ] - _add_params_to_list!(schematic_pl, schematic_params) # Use helper - - addelement!(schematic, "grouping") # Identical - - # --- Station Schematic: Wire/User Instance for "Main" (Use Helpers) --- - wire = addelement!(schematic, "Wire") - wire_id = _next_id() - wire_attrs = Dict( - "classid" => "Branch", "id" => wire_id, "name" => "Main", "x" => "180", - "y" => "180", - "w" => "66", "h" => "82", "orient" => "0", "disable" => "false", - "defn" => "Main", - "recv" => "-1", "send" => "-1", "back" => "-1", - ) - _set_attributes!(wire, wire_attrs) # Use helper - - # Keep vertex loop identical - for (x, y) in [(0, 0), (0, 18), (54, 54), (54, 72)] - vertex = addelement!(wire, "vertex") - vertex["x"] = string(x) - vertex["y"] = string(y) - end - - user = addelement!(wire, "User") # User instance nested in Wire - user_id = _next_id() - id_map["Main"] = user_id # Original maps the *instance* ID here for hierarchy link - user_attrs = Dict( - "classid" => "UserCmp", "id" => user_id, "name" => "$project_id:Main", - "x" => "0", "y" => "0", "w" => "0", "h" => "0", "z" => "-1", "orient" => "0", - "defn" => "$project_id:Main", # Links to definition named "Main" (implicitly in same project) - "link" => "-1", "q" => "4", "disable" => "false", - ) - _set_attributes!(user, user_attrs) # Use helper - - user_pl = addelement!(user, "paramlist") - # Original sets attributes directly on paramlist and adds no children - replicate exactly: - user_pl["name"] = "" - user_pl["link"] = "-1" - user_pl["crc"] = "-1" - - # --- UserCmpDefn "Main" (Use Helpers) --- - user_cmp = addelement!(definitions, "Definition") - user_cmp_id = _next_id() # This is the definition ID - id_map["Main_Defn"] = user_cmp_id # Map Definition ID separately - user_cmp_attrs = Dict( - "classid" => "UserCmpDefn", "name" => "Main", "id" => user_cmp_id, - "group" => "", - "url" => "", "version" => "", "build" => "", "crc" => "-1", "view" => "false", - "date" => timestamp, - ) - _set_attributes!(user_cmp, user_cmp_attrs) # Use helper - - user_cmp_pl = addelement!(user_cmp, "paramlist") - user_cmp_pl["name"] = "" # Empty name attribute - # Add Description param directly - desc_param_ucmp = addelement!(user_cmp_pl, "param") - desc_param_ucmp["name"] = "Description" - desc_param_ucmp["value"] = "" - - # Form (Identical) - form = addelement!(user_cmp, "form") - form["name"] = "" - form["w"] = "320" - form["h"] = "400" - form["splitter"] = "60" - - # Graphics (Identical Structure) - graphics = addelement!(user_cmp, "graphics") - graphics["viewBox"] = "-200 -200 200 200" - graphics["size"] = "2" - - # Graphics Rectangle (Use Helpers) - rect = addelement!(graphics, "Gfx") - rect_id = _next_id() - rect_attrs = Dict( - "classid" => "Graphics.Rectangle", "id" => rect_id, "x" => "-36", "y" => "-36", - "w" => "72", "h" => "72", - ) - _set_attributes!(rect, rect_attrs) # Use helper - rect_pl = addelement!(rect, "paramlist") # No name attribute - rect_params = [ - ("color", "Black"), ("dasharray", "0"), ("thickness", "0"), ("port", ""), - ("fill_style", "0"), ("fill_fg", "Black"), ("fill_bg", "Black"), - ("cond", "true"), - ] - _add_params_to_list!(rect_pl, rect_params) # Use helper - - # Graphics Text (Use Helpers) - text = addelement!(graphics, "Gfx") - text_id = _next_id() - text_attrs = Dict("classid" => "Graphics.Text", "id" => text_id, "x" => "0", "y" => "0") - _set_attributes!(text, text_attrs) # Use helper - text_pl = addelement!(text, "paramlist") # No name attribute - text_params = [ - ("text", "%:Name"), ("anchor", "0"), ("full_font", "Tahoma, 13world"), - ("angle", "0"), ("color", "Black"), ("cond", "true"), - ] - _add_params_to_list!(text_pl, text_params) # Use helper - - # --- UserCmpDefn "Main" Schematic (Use Helpers) --- - user_schematic = addelement!(user_cmp, "schematic") - user_schematic["classid"] = "UserCanvas" - user_sch_pl = addelement!(user_schematic, "paramlist") # No name attribute - user_sch_params = [ - ("show_grid", "0"), ("size", "0"), ("orient", "1"), ("show_border", "0"), - ("monitor_bus_voltage", "0"), ("show_signal", "0"), ("show_virtual", "0"), - ("show_sequence", "0"), ("auto_sequence", "1"), ("bus_expand_x", "8"), - ("bus_expand_y", "8"), ("bus_length", "4"), ("show_terminals", "0"), - ("virtual_filter", ""), ("animation_freq", "500"), - ] - _add_params_to_list!(user_sch_pl, user_sch_params) # Use helper - - addelement!(user_schematic, "grouping") # Identical - - # --- UserCmpDefn "Main" Schematic: CableSystem Instance (Use Helpers) --- - cable = addelement!(user_schematic, "Wire") # Wire instance - cable_id = _next_id() - cable_attrs = Dict( - "classid" => "Cable", "id" => cable_id, "name" => "$project_id:CableSystem", - "x" => "72", "y" => "36", "w" => "107", "h" => "128", "orient" => "0", - "disable" => "false", "defn" => "$project_id:CableSystem", # Links to definition named "CableSystem" - "recv" => "-1", "send" => "-1", "back" => "-1", "crc" => "-1", - ) - _set_attributes!(cable, cable_attrs) # Use helper - - # Keep vertex loop identical - for (x, y) in [(0, 0), (0, 18), (54, 54), (54, 72)] - vertex = addelement!(cable, "vertex") - vertex["x"] = string(x) - vertex["y"] = string(y) - end - - cable_user = addelement!(cable, "User") # User instance nested in Wire - cable_user_id = _next_id() - id_map["CableSystem"] = cable_user_id # Original maps this *instance* ID for hierarchy link - cable_user_attrs = Dict( - "classid" => "UserCmp", "id" => cable_user_id, - "name" => "$project_id:CableSystem", - "x" => "0", "y" => "0", "w" => "0", "h" => "0", "z" => "-1", "orient" => "0", - "defn" => "$project_id:CableSystem", # Links to definition named "CableSystem" - "link" => "-1", "q" => "4", "disable" => "false", - ) - _set_attributes!(cable_user, cable_user_attrs) # Use helper - - cable_pl = addelement!(cable_user, "paramlist") - # Original sets attributes on paramlist AND adds params - replicate exactly - cable_pl["name"] = "" - cable_pl["link"] = "-1" - cable_pl["crc"] = "-1" - cable_params = [ # Instance parameters - ("Name", "LineCableSystem_1"), ("R", "#NaN"), ("X", "#NaN"), ("B", "#NaN"), - ("Freq", format_nominal(base_freq)), - ("Length", format_nominal(cable_system.line_length / 1000)), # Assumes field exists - ("Dim", "0"), ("Mode", "0"), ("CoupleEnab", "0"), ("CoupleName", "row"), - ("CoupleOffset", "0.0 [m]"), ("CoupleRef", "0"), ("tname", "tandem_segment"), - ("sfault", "0"), ("linc", "10.0 [km]"), ("steps", "3"), ("gen_cnst", "1"), - ("const_path", "%TEMP%\\my_constants_file.tlo"), ("Date", timestamp), - ] - _add_params_to_list!(cable_pl, cable_params) # Use helper - - # --- RowDefn "CableSystem" (Use Helpers) --- - row = addelement!(definitions, "Definition") - row_id = _next_id() - id_map["CableSystem_Defn"] = row_id # Map definition ID separately - row_attrs = Dict( - "id" => row_id, "classid" => "RowDefn", "name" => "CableSystem", "group" => "", - "url" => "", "version" => "RowDefn", "build" => "RowDefn", "crc" => "-1", - "key" => "", "view" => "false", "date" => timestamp, - ) - _set_attributes!(row, row_attrs) # Use helper - - row_pl = addelement!(row, "paramlist") # No name attribute - row_params = [("Description", ""), ("type", "Cable")] - _add_params_to_list!(row_pl, row_params) # Use helper - - row_schematic = addelement!(row, "schematic") - row_schematic["classid"] = "RowCanvas" - row_sch_pl = addelement!(row_schematic, "paramlist") # No name attribute - row_sch_params = - [("show_grid", "0"), ("size", "0"), ("orient", "1"), ("show_border", "0")] - _add_params_to_list!(row_sch_pl, row_sch_params) # Use helper - - # --- Components in RowDefn "CableSystem" Schematic --- - - # FrePhase Component (Use Helpers) - fre_phase = addelement!(row_schematic, "User") - fre_phase_id = _next_id() - fre_phase_attrs = Dict( - "id" => fre_phase_id, "name" => "master:Line_FrePhase_Options", - "classid" => "UserCmp", - "x" => "576", "y" => "180", "w" => "460", "h" => "236", "z" => "-1", - "orient" => "0", - "defn" => "master:Line_FrePhase_Options", "link" => "-1", "q" => "4", - "disable" => "false", - ) - _set_attributes!(fre_phase, fre_phase_attrs) # Use helper - - fre_pl = addelement!(fre_phase, "paramlist") - # Original sets crc attribute only on paramlist, replicate exactly - fre_pl["crc"] = "-1" - fre_params = [ # Actual params - ("Interp1", "1"), ("Output", "0"), ("Inflen", "0"), ("FS", "0.5"), - ("FE", "1.0E6"), - ("Numf", "100"), ("YMaxP", "20"), ("YMaxE", "0.2"), ("AMaxP", "20"), - ("AMaxE", "0.2"), - ("MaxRPtol", "2.0e6"), ("W1", "1.0"), ("W2", "1000.0"), ("W3", "1.0"), - ("CPASS", "0"), - ("NFP", "1000"), ("FSP", "0.001"), ("FEP", "1000.0"), ("DCenab", "0"), - ("DCCOR", "1"), - ("ECLS", "1"), ("shntcab", "1.0E-9"), ("ET_PE", "1E-10"), ("MER_PE", "2"), - ("MIT_PE", "5"), ("FDIS", "3"), ("enablf", "1"), - ] - _add_params_to_list!(fre_pl, fre_params) # Use helper - - addelement!(row_schematic, "grouping") # Identical - - # --- Coaxial Cables Loop (Use Helpers, keep logic identical) --- - num_cables = cable_system.num_cables - dx = 400 - for i in 1:num_cables - cable_position = cable_system.cables[i] - coax1 = addelement!(row_schematic, "User") - coax1_id = _next_id() - coax1_attrs = Dict( - "classid" => "UserCmp", "name" => "master:Cable_Coax", "id" => coax1_id, - "x" => "$(234+(i-1)*dx)", "y" => "612", "w" => "311", "h" => "493", - "z" => "-1", - "orient" => "0", "defn" => "master:Cable_Coax", "link" => "-1", "q" => "4", - "disable" => "false", - ) - _set_attributes!(coax1, coax1_attrs) # Use helper - - coax1_pl = addelement!(coax1, "paramlist") - # Original sets attributes on paramlist AND adds params - replicate exactly - coax1_pl["link"] = "-1" - coax1_pl["name"] = "" - coax1_pl["crc"] = "-1" - - # --- Parameter Calculation (Identical Logic from Original) --- - component_ids = collect(keys(cable_position.design_data.components)) - num_cable_parts = length(component_ids) - if num_cable_parts > 4 - error( - "Cable $(cable_position.design_data.cable_id) has $num_cable_parts parts, exceeding the limit of 4 (core/sheath/armor/outer).", - ) - end - conn = cable_position.conn - elim1 = length(conn) >= 2 && conn[2] == 0 ? "1" : "0" - elim2 = length(conn) >= 3 && conn[3] == 0 ? "1" : "0" - elim3 = length(conn) >= 4 && conn[4] == 0 ? "1" : "0" - cable_x = cable_position.horz - cable_y = cable_position.vert - - # Build the parameter list exactly as in the original's logic - coax1_params_vector = Vector{Tuple{String,String}}() # Renamed variable - # Base parameters - push!(coax1_params_vector, ("CABNUM", "$i")) - push!(coax1_params_vector, ("Name", "$(cable_position.design_data.cable_id)")) - push!(coax1_params_vector, ("X", format_nominal(cable_x))) - push!(coax1_params_vector, ("OHC", "$(cable_y < 0 ? 0 : 1)")) - push!( - coax1_params_vector, - ("Y", (cable_y < 0 ? format_nominal(abs(cable_y)) : "0.0")), - ) - push!(coax1_params_vector, ("Y2", (cable_y > 0 ? format_nominal(cable_y) : "0.0"))) - push!(coax1_params_vector, ("ShuntA", "1.0e-11 [mho/m]")) - push!(coax1_params_vector, ("FLT", format_nominal(base_freq))) - push!(coax1_params_vector, ("RorT", "0")) - push!(coax1_params_vector, ("LL", "$(2*num_cable_parts-1)")) - push!(coax1_params_vector, ("CROSSBOND", "0")) - push!(coax1_params_vector, ("GROUPNO", "1")) - push!( - coax1_params_vector, - ("CBC1", "1"), - ("CBC2", "0"), - ("CBC3", "0"), - ("CBC4", "0"), - ) - push!(coax1_params_vector, ("SHRad", "1")) - push!(coax1_params_vector, ("LC", "3")) - - # Component parameters (Keep identical logic) - ω = 2 * π * base_freq - for (idx, component_id) in enumerate(component_ids) - component = cable_position.design_data.components[component_id] - C_eq = component.insulator_group.shunt_capacitance - G_eq = component.insulator_group.shunt_conductance - loss_factor = C_eq > 1e-18 ? G_eq / (ω * C_eq) : 0.0 # Avoid NaN/Inf - - sig_digits_props = 6 - max_loss_tangent = 10.0 - - if idx == 1 # Core - push!(coax1_params_vector, ("CONNAM1", uppercasefirst(component.id))) - push!( - coax1_params_vector, - ("R1", format_nominal(component.conductor_group.radius_in)), - ) - push!( - coax1_params_vector, - ("R2", format_nominal(component.conductor_group.radius_ext)), - ) - push!( - coax1_params_vector, - ( - "RHOC", - format_nominal( - component.conductor_props.rho, - sigdigits=sig_digits_props, - ), - ), - ) - push!( - coax1_params_vector, - ( - "PERMC", - format_nominal( - component.conductor_props.mu_r, - sigdigits=sig_digits_props, - ), - ), - ) - push!( - coax1_params_vector, - ("R3", format_nominal(component.insulator_group.radius_ext)), - ) - push!(coax1_params_vector, ("T3", "0.0000")) - push!(coax1_params_vector, ("SemiCL", "0")) - push!(coax1_params_vector, ("SL2", "0.0000")) - push!(coax1_params_vector, ("SL1", "0.0000")) - push!( - coax1_params_vector, - ( - "EPS1", - format_nominal( - component.insulator_props.eps_r, - sigdigits=sig_digits_props, - ), - ), - ) - push!( - coax1_params_vector, - ( - "PERM1", - format_nominal( - component.insulator_props.mu_r, - sigdigits=sig_digits_props, - ), - ), - ) - push!( - coax1_params_vector, - ( - "LT1", - format_nominal( - loss_factor, - sigdigits=sig_digits_props, - maxval=max_loss_tangent, - ), - ), - ) - elseif idx == 2 # Sheath - push!(coax1_params_vector, ("CONNAM2", uppercasefirst(component.id))) - push!( - coax1_params_vector, - ("R4", format_nominal(component.conductor_group.radius_ext)), - ) - push!(coax1_params_vector, ("T4", "0.0000")) - push!( - coax1_params_vector, - ( - "RHOS", - format_nominal( - component.conductor_props.rho, - sigdigits=sig_digits_props, - ), - ), - ) - push!( - coax1_params_vector, - ( - "PERMS", - format_nominal( - component.conductor_props.mu_r, - sigdigits=sig_digits_props, - ), - ), - ) - push!(coax1_params_vector, ("elim1", elim1)) - push!( - coax1_params_vector, - ("R5", format_nominal(component.insulator_group.radius_ext)), - ) - push!(coax1_params_vector, ("T5", "0.0000")) - push!( - coax1_params_vector, - ( - "EPS2", - format_nominal( - component.insulator_props.eps_r, - sigdigits=sig_digits_props, - ), - ), - ) - push!( - coax1_params_vector, - ( - "PERM2", - format_nominal( - component.insulator_props.mu_r, - sigdigits=sig_digits_props, - ), - ), - ) - push!( - coax1_params_vector, - ( - "LT2", - format_nominal( - loss_factor, - sigdigits=sig_digits_props, - maxval=max_loss_tangent, - ), - ), - ) - elseif idx == 3 # Armor - push!(coax1_params_vector, ("CONNAM3", uppercasefirst(component.id))) - push!( - coax1_params_vector, - ("R6", format_nominal(component.conductor_group.radius_ext)), - ) - push!(coax1_params_vector, ("T6", "0.0000")) - push!( - coax1_params_vector, - ( - "RHOA", - format_nominal( - component.conductor_props.rho, - sigdigits=sig_digits_props, - ), - ), - ) - push!( - coax1_params_vector, - ( - "PERMA", - format_nominal( - component.conductor_props.mu_r, - sigdigits=sig_digits_props, - ), - ), - ) - push!(coax1_params_vector, ("elim2", elim2)) - push!( - coax1_params_vector, - ("R7", format_nominal(component.insulator_group.radius_ext)), - ) - push!(coax1_params_vector, ("T7", "0.0000")) - push!( - coax1_params_vector, - ( - "EPS3", - format_nominal( - component.insulator_props.eps_r, - sigdigits=sig_digits_props, - ), - ), - ) - push!( - coax1_params_vector, - ( - "PERM3", - format_nominal( - component.insulator_props.mu_r, - sigdigits=sig_digits_props, - ), - ), - ) - push!( - coax1_params_vector, - ( - "LT3", - format_nominal( - loss_factor, - sigdigits=sig_digits_props, - maxval=max_loss_tangent, - ), - ), - ) - elseif idx == 4 # Outer - push!(coax1_params_vector, ("CONNAM4", uppercasefirst(component.id))) - push!( - coax1_params_vector, - ("R8", format_nominal(component.conductor_group.radius_ext)), - ) - push!(coax1_params_vector, ("T8", "0.0000")) - push!( - coax1_params_vector, - ( - "RHOO", - format_nominal( - component.conductor_props.rho, - sigdigits=sig_digits_props, - ), - ), - ) - push!( - coax1_params_vector, - ( - "PERMO", - format_nominal( - component.conductor_props.mu_r, - sigdigits=sig_digits_props, - ), - ), - ) - push!(coax1_params_vector, ("elim3", elim3)) - push!( - coax1_params_vector, - ("R9", format_nominal(component.insulator_group.radius_ext)), - ) - push!(coax1_params_vector, ("T9", "0.0000")) - push!( - coax1_params_vector, - ( - "EPS4", - format_nominal( - component.insulator_props.eps_r, - sigdigits=sig_digits_props, - ), - ), - ) - push!( - coax1_params_vector, - ( - "PERM4", - format_nominal( - component.insulator_props.mu_r, - sigdigits=sig_digits_props, - ), - ), - ) - push!( - coax1_params_vector, - ( - "LT4", - format_nominal( - loss_factor, - sigdigits=sig_digits_props, - maxval=max_loss_tangent, - ), - ), - ) - end - end - - # Default empty values (Keep identical logic) - if num_cable_parts < 2 - append!( - coax1_params_vector, - [ - ("CONNAM2", "none"), - ("R4", "0.0"), - ("T4", "0.0000"), - ("RHOS", "0.0"), - ("PERMS", "0.0"), - ("elim1", "0"), - ("R5", "0.0"), - ("T5", "0.0000"), - ("EPS2", "0.0"), - ("PERM2", "0.0"), - ("LT2", "0.0000"), - ], - ) - end - if num_cable_parts < 3 - append!( - coax1_params_vector, - [ - ("CONNAM3", "none"), - ("R6", "0.0"), - ("T6", "0.0000"), - ("RHOA", "0.0"), - ("PERMA", "0.0"), - ("elim2", "0"), - ("R7", "0.0"), - ("T7", "0.0000"), - ("EPS3", "0.0"), - ("PERM3", "0.0"), - ("LT3", "0.0000"), - ], - ) - end - if num_cable_parts < 4 - append!( - coax1_params_vector, - [ - ("CONNAM4", "none"), - ("R8", "0.0"), - ("T8", "0.0000"), - ("RHOO", "0.0"), - ("PERMO", "0.0"), - ("elim3", "0"), - ("R9", "0.0"), - ("T9", "0.0000"), - ("EPS4", "0.0"), - ("PERM4", "0.0"), - ("LT4", "0.0000"), - ], - ) - end - - # Add all collected parameters to the paramlist created earlier - _add_params_to_list!(coax1_pl, coax1_params_vector) # Use helper - - end # End Coax cable loop - - # --- Line_Ground Component (Use Helpers) --- - ground = addelement!(row_schematic, "User") - ground_id = _next_id() - ground_attrs = Dict( - "classid" => "UserCmp", "name" => "master:Line_Ground", "id" => ground_id, - "x" => "504", "y" => "288", "w" => "793", "h" => "88", "z" => "-1", - "orient" => "0", - "defn" => "master:Line_Ground", "link" => "-1", "q" => "4", "disable" => "false", - ) - _set_attributes!(ground, ground_attrs) # Use helper - - ground_pl = addelement!(ground, "paramlist") - # Original sets attributes on paramlist AND adds params - replicate exactly - ground_pl["link"] = "-1" - ground_pl["name"] = "" - ground_pl["crc"] = "-1" - - earth_layer = earth_props.layers[end] - ground_params_vector = [ # Renamed variable - ("EarthForm2", "0"), ("EarthForm", "3"), ("EarthForm3", "2"), ("GrRho", "0"), - ("GRRES", format_nominal(earth_layer.base_rho_g)), - ("GPERM", format_nominal(earth_layer.base_mur_g)), - ("K0", "0.001"), ("K1", "0.01"), ("alpha", "0.7"), - ("GRP", format_nominal(earth_layer.base_epsr_g)), - ] - _add_params_to_list!(ground_pl, ground_params_vector) # Use helper - - # --- Resource List and Hierarchy (Identical Nested Structure from Original) --- - addelement!(project, "List")["classid"] = "Resource" - - hierarchy = addelement!(project, "hierarchy") - # Nested calls exactly as in the original, linking to INSTANCE IDs from id_map - call1 = addelement!(hierarchy, "call") - # The link should be to the Station Definition ID, not an instance - call1["link"] = id_map["DS_Defn"] # Corrected link - call1["name"] = "$project_id:DS" - call1["z"] = "-1" - call1["view"] = "false" - call1["instance"] = "0" - - call2 = addelement!(call1, "call") - call2["link"] = id_map["Main"] # Links to Main User INSTANCE ID (as per original id_map usage) - call2["name"] = "$project_id:Main" - call2["z"] = "-1" - call2["view"] = "false" - call2["instance"] = "0" - - call3 = addelement!(call2, "call") - call3["link"] = id_map["CableSystem"] # Links to CableSystem User INSTANCE ID (as per original id_map usage) - call3["name"] = "$project_id:CableSystem" - call3["z"] = "-1" - call3["view"] = "true" - call3["instance"] = "0" - - try - # Use pretty print option for debugging comparisons if needed - # open(filename, "w") do io; prettyprint(io, doc); end - write(file_name, doc) # Standard write - if isfile(file_name) - @info "PSCAD file saved to: $(display_path(file_name))" - end - return file_name - catch e - @error "Failed to write PSCAD file '$(display_path(file_name))': $(e)" - isa(e, SystemError) && println("SystemError details: ", e.extrainfo) - return nothing - rethrow(e) # Rethrow to indicate failure clearly - end +#= +Generates sequential IDs, used for simulation element identification (e.g., PSCAD). +Starts from 100,000,000 and increments. +=# +let current_id = 100000000 + global _next_id = () -> (id = current_id; current_id += 1; string(id)) +end + +""" +$(TYPEDSIGNATURES) + +Exports a [`LineCableSystem`](@ref) to a PSCAD-compatible file format. + +# Arguments + +- `cable_system`: A [`LineCableSystem`](@ref) object representing the cable system to be exported. +- `earth_props`: An [`EarthModel`](@ref) object containing the earth properties. +- `base_freq`: The base frequency \\[Hz\\] used for the PSCAD export. +- `file_name`: The path to the output file (default: "*_export.pscx") + +# Returns + +- The absolute path of the saved file, or `nothing` on failure. + +# Examples + +```julia +cable_system = LineCableSystem(...) +earth_model = EarthModel(...) +$(FUNCTIONNAME)(cable_system, earth_model, base_freq=50) +``` + +# See also + +- [`LineCableSystem`](@ref) +""" +function export_data(::Val{:pscad}, + cable_system::LineCableSystem, + earth_props::EarthModel; + base_freq=f₀, + file_name::Union{String,Nothing}=nothing, +)::Union{String,Nothing} + + if isnothing(file_name) + # caller didn't supply a name -> derive from cable_system if present + file_name = joinpath(@__DIR__, "$(cable_system.system_id)_export.pscx") + else + # caller supplied a path/name -> respect directory, but prepend system_id to basename + requested = isabspath(file_name) ? file_name : joinpath(@__DIR__, file_name) + if isnothing(cable_system) + file_name = requested + else + dir = dirname(requested) + base = basename(requested) + file_name = joinpath(dir, "$(cable_system.system_id)_$base") + end + end + + # Sets attributes on an existing EzXML.Node from a dictionary. + function _set_attributes!(element::EzXML.Node, attrs::Dict{String,String}) + # Loop through the dictionary and set each attribute on the element + for (k, v) in attrs + element[k] = v + end + end + + # Adds child elements to an existing EzXML.Node + # from a vector of ("name", "value") tuples. + function _add_params_to_list!( + list_element::EzXML.Node, + params::Vector{Tuple{String,String}}, + ) + # Ensure the target element is actually a paramlist for clarity, though not strictly necessary for EzXML + # if nodename(list_element) != "paramlist" + # @warn "Attempting to add params to a non-paramlist node: $(nodename(list_element))" + # end + # Loop through the vector and add each parameter as a child element + for (name, value) in params + param = addelement!(list_element, "param") + param["name"] = name + param["value"] = value + end + end + + # --- Initial Setup (Identical to original) --- + # Local Ref for ID generation ensures it's unique to this function call if nested + current_id = Ref(100000000) + _next_id() = string(current_id[] += 1) + + # Formatting function (ensure to_nominal is defined or handle types appropriately) + format_nominal = + (X; sigdigits=4, minval=-1e30, maxval=1e30) -> begin + + local_value = round(to_nominal(X), sigdigits=sigdigits) + + local_value = max(min(local_value, maxval), minval) + if abs(local_value) < eps(Float64) + local_value = 0.0 + end + return string(local_value) + end + + id_map = Dict{String,String}() # Stores IDs needed for linking (Instance IDs in this case) + doc = XMLDocument() + project = ElementNode("project") + setroot!(doc, project) + project_id = cable_system.system_id + + # --- Project Attributes (Identical) --- + project["name"] = project_id + project["version"] = "5.0.2" + project["schema"] = "" + project["Target"] = "EMTDC" + + # --- Settings (Use Helper for Params) --- + settings = addelement!(project, "paramlist") + settings["name"] = "Settings" # Set name attribute directly as in original + timestamp = string(round(Int, datetime2unix(now()))) + settings_params = [ + ("creator", "LineCableModels.jl,$timestamp"), ("time_duration", "0.5"), + ("time_step", "5"), ("sample_step", "250"), ("chatter_threshold", ".001"), + ("branch_threshold", ".0005"), ("StartType", "0"), + ("startup_filename", "\$(Namespace).snp"), ("PlotType", "0"), + ("output_filename", "\$(Namespace).out"), ("SnapType", "0"), + ("SnapTime", "0.3"), ("snapshot_filename", "\$(Namespace).snp"), + ("MrunType", "0"), ("Mruns", "1"), ("Scenario", ""), ("Advanced", "14335"), + ("sparsity_threshold", "200"), ("Options", "16"), ("Build", "18"), + ("Warn", "0"), ("Check", "0"), + ( + "description", + "Created with LineCableModels.jl (https://github.com/Electa-Git/LineCableModels.jl)", + ), + ("Debug", "0"), + ] + _add_params_to_list!(settings, settings_params) # Use helper to add children + + # --- Empty Elements (Identical) --- + addelement!(project, "Layers") + addelement!(project, "List")["classid"] = "Settings" + addelement!(project, "bookmarks") + + # --- GlobalSubstitutions (Identical Structure) --- + global_subs = addelement!(project, "GlobalSubstitutions") + global_subs["name"] = "Default" + addelement!(global_subs, "List")["classid"] = "Sub" + addelement!(global_subs, "List")["classid"] = "ValueSet" + global_pl = addelement!(global_subs, "paramlist") # No name attribute + # Add the single parameter directly as in original + global_param = addelement!(global_pl, "param") + global_param["name"] = "Current" + global_param["value"] = "" + + # --- Definitions Section (Identical Start) --- + definitions = addelement!(project, "definitions") + + # --- StationDefn (Use Helpers for Attrs/Params) --- + station = addelement!(definitions, "Definition") + station_id = _next_id() + id_map["DS_Defn"] = station_id # Map Definition ID + station_attrs = Dict( + "classid" => "StationDefn", "name" => "DS", "id" => station_id, + "group" => "", "url" => "", "version" => "", "build" => "", + "crc" => "-1", "view" => "false", + ) + _set_attributes!(station, station_attrs) # Use helper + + station_pl = addelement!(station, "paramlist") + station_pl["name"] = "" # Keep empty name attribute exactly as original + # Add Description param directly as original + desc_param_st = addelement!(station_pl, "param") + desc_param_st["name"] = "Description" + desc_param_st["value"] = "" + + schematic = addelement!(station, "schematic") + schematic["classid"] = "StationCanvas" + schematic_pl = addelement!(schematic, "paramlist") # No name attribute + schematic_params = [ + ("show_grid", "0"), ("size", "0"), ("orient", "1"), ("show_border", "0"), + ("monitor_bus_voltage", "0"), ("show_signal", "0"), ("show_virtual", "0"), + ("show_sequence", "0"), ("auto_sequence", "1"), ("bus_expand_x", "8"), + ("bus_expand_y", "8"), ("bus_length", "4"), + ] + _add_params_to_list!(schematic_pl, schematic_params) # Use helper + + addelement!(schematic, "grouping") # Identical + + # --- Station Schematic: Wire/User Instance for "Main" (Use Helpers) --- + wire = addelement!(schematic, "Wire") + wire_id = _next_id() + wire_attrs = Dict( + "classid" => "Branch", "id" => wire_id, "name" => "Main", "x" => "180", + "y" => "180", + "w" => "66", "h" => "82", "orient" => "0", "disable" => "false", + "defn" => "Main", + "recv" => "-1", "send" => "-1", "back" => "-1", + ) + _set_attributes!(wire, wire_attrs) # Use helper + + # Keep vertex loop identical + for (x, y) in [(0, 0), (0, 18), (54, 54), (54, 72)] + vertex = addelement!(wire, "vertex") + vertex["x"] = string(x) + vertex["y"] = string(y) + end + + user = addelement!(wire, "User") # User instance nested in Wire + user_id = _next_id() + id_map["Main"] = user_id # Original maps the *instance* ID here for hierarchy link + user_attrs = Dict( + "classid" => "UserCmp", "id" => user_id, "name" => "$project_id:Main", + "x" => "0", "y" => "0", "w" => "0", "h" => "0", "z" => "-1", "orient" => "0", + "defn" => "$project_id:Main", # Links to definition named "Main" (implicitly in same project) + "link" => "-1", "q" => "4", "disable" => "false", + ) + _set_attributes!(user, user_attrs) # Use helper + + user_pl = addelement!(user, "paramlist") + # Original sets attributes directly on paramlist and adds no children - replicate exactly: + user_pl["name"] = "" + user_pl["link"] = "-1" + user_pl["crc"] = "-1" + + # --- UserCmpDefn "Main" (Use Helpers) --- + user_cmp = addelement!(definitions, "Definition") + user_cmp_id = _next_id() # This is the definition ID + id_map["Main_Defn"] = user_cmp_id # Map Definition ID separately + user_cmp_attrs = Dict( + "classid" => "UserCmpDefn", "name" => "Main", "id" => user_cmp_id, + "group" => "", + "url" => "", "version" => "", "build" => "", "crc" => "-1", "view" => "false", + "date" => timestamp, + ) + _set_attributes!(user_cmp, user_cmp_attrs) # Use helper + + user_cmp_pl = addelement!(user_cmp, "paramlist") + user_cmp_pl["name"] = "" # Empty name attribute + # Add Description param directly + desc_param_ucmp = addelement!(user_cmp_pl, "param") + desc_param_ucmp["name"] = "Description" + desc_param_ucmp["value"] = "" + + # Form (Identical) + form = addelement!(user_cmp, "form") + form["name"] = "" + form["w"] = "320" + form["h"] = "400" + form["splitter"] = "60" + + # Graphics (Identical Structure) + graphics = addelement!(user_cmp, "graphics") + graphics["viewBox"] = "-200 -200 200 200" + graphics["size"] = "2" + + # Graphics Rectangle (Use Helpers) + rect = addelement!(graphics, "Gfx") + rect_id = _next_id() + rect_attrs = Dict( + "classid" => "Graphics.Rectangle", "id" => rect_id, "x" => "-36", "y" => "-36", + "w" => "72", "h" => "72", + ) + _set_attributes!(rect, rect_attrs) # Use helper + rect_pl = addelement!(rect, "paramlist") # No name attribute + rect_params = [ + ("color", "Black"), ("dasharray", "0"), ("thickness", "0"), ("port", ""), + ("fill_style", "0"), ("fill_fg", "Black"), ("fill_bg", "Black"), + ("cond", "true"), + ] + _add_params_to_list!(rect_pl, rect_params) # Use helper + + # Graphics Text (Use Helpers) + text = addelement!(graphics, "Gfx") + text_id = _next_id() + text_attrs = Dict("classid" => "Graphics.Text", "id" => text_id, "x" => "0", "y" => "0") + _set_attributes!(text, text_attrs) # Use helper + text_pl = addelement!(text, "paramlist") # No name attribute + text_params = [ + ("text", "%:Name"), ("anchor", "0"), ("full_font", "Tahoma, 13world"), + ("angle", "0"), ("color", "Black"), ("cond", "true"), + ] + _add_params_to_list!(text_pl, text_params) # Use helper + + # --- UserCmpDefn "Main" Schematic (Use Helpers) --- + user_schematic = addelement!(user_cmp, "schematic") + user_schematic["classid"] = "UserCanvas" + user_sch_pl = addelement!(user_schematic, "paramlist") # No name attribute + user_sch_params = [ + ("show_grid", "0"), ("size", "0"), ("orient", "1"), ("show_border", "0"), + ("monitor_bus_voltage", "0"), ("show_signal", "0"), ("show_virtual", "0"), + ("show_sequence", "0"), ("auto_sequence", "1"), ("bus_expand_x", "8"), + ("bus_expand_y", "8"), ("bus_length", "4"), ("show_terminals", "0"), + ("virtual_filter", ""), ("animation_freq", "500"), + ] + _add_params_to_list!(user_sch_pl, user_sch_params) # Use helper + + addelement!(user_schematic, "grouping") # Identical + + # --- UserCmpDefn "Main" Schematic: CableSystem Instance (Use Helpers) --- + cable = addelement!(user_schematic, "Wire") # Wire instance + cable_id = _next_id() + cable_attrs = Dict( + "classid" => "Cable", "id" => cable_id, "name" => "$project_id:CableSystem", + "x" => "72", "y" => "36", "w" => "107", "h" => "128", "orient" => "0", + "disable" => "false", "defn" => "$project_id:CableSystem", # Links to definition named "CableSystem" + "recv" => "-1", "send" => "-1", "back" => "-1", "crc" => "-1", + ) + _set_attributes!(cable, cable_attrs) # Use helper + + # Keep vertex loop identical + for (x, y) in [(0, 0), (0, 18), (54, 54), (54, 72)] + vertex = addelement!(cable, "vertex") + vertex["x"] = string(x) + vertex["y"] = string(y) + end + + cable_user = addelement!(cable, "User") # User instance nested in Wire + cable_user_id = _next_id() + id_map["CableSystem"] = cable_user_id # Original maps this *instance* ID for hierarchy link + cable_user_attrs = Dict( + "classid" => "UserCmp", "id" => cable_user_id, + "name" => "$project_id:CableSystem", + "x" => "0", "y" => "0", "w" => "0", "h" => "0", "z" => "-1", "orient" => "0", + "defn" => "$project_id:CableSystem", # Links to definition named "CableSystem" + "link" => "-1", "q" => "4", "disable" => "false", + ) + _set_attributes!(cable_user, cable_user_attrs) # Use helper + + cable_pl = addelement!(cable_user, "paramlist") + # Original sets attributes on paramlist AND adds params - replicate exactly + cable_pl["name"] = "" + cable_pl["link"] = "-1" + cable_pl["crc"] = "-1" + cable_params = [ # Instance parameters + ("Name", "LineCableSystem_1"), ("R", "#NaN"), ("X", "#NaN"), ("B", "#NaN"), + ("Freq", format_nominal(base_freq)), + ("Length", format_nominal(cable_system.line_length / 1000)), # Assumes field exists + ("Dim", "0"), ("Mode", "0"), ("CoupleEnab", "0"), ("CoupleName", "row"), + ("CoupleOffset", "0.0 [m]"), ("CoupleRef", "0"), ("tname", "tandem_segment"), + ("sfault", "0"), ("linc", "10.0 [km]"), ("steps", "3"), ("gen_cnst", "1"), + ("const_path", "%TEMP%\\my_constants_file.tlo"), ("Date", timestamp), + ] + _add_params_to_list!(cable_pl, cable_params) # Use helper + + # --- RowDefn "CableSystem" (Use Helpers) --- + row = addelement!(definitions, "Definition") + row_id = _next_id() + id_map["CableSystem_Defn"] = row_id # Map definition ID separately + row_attrs = Dict( + "id" => row_id, "classid" => "RowDefn", "name" => "CableSystem", "group" => "", + "url" => "", "version" => "RowDefn", "build" => "RowDefn", "crc" => "-1", + "key" => "", "view" => "false", "date" => timestamp, + ) + _set_attributes!(row, row_attrs) # Use helper + + row_pl = addelement!(row, "paramlist") # No name attribute + row_params = [("Description", ""), ("type", "Cable")] + _add_params_to_list!(row_pl, row_params) # Use helper + + row_schematic = addelement!(row, "schematic") + row_schematic["classid"] = "RowCanvas" + row_sch_pl = addelement!(row_schematic, "paramlist") # No name attribute + row_sch_params = + [("show_grid", "0"), ("size", "0"), ("orient", "1"), ("show_border", "0")] + _add_params_to_list!(row_sch_pl, row_sch_params) # Use helper + + # --- Components in RowDefn "CableSystem" Schematic --- + + # FrePhase Component (Use Helpers) + fre_phase = addelement!(row_schematic, "User") + fre_phase_id = _next_id() + fre_phase_attrs = Dict( + "id" => fre_phase_id, "name" => "master:Line_FrePhase_Options", + "classid" => "UserCmp", + "x" => "576", "y" => "180", "w" => "460", "h" => "236", "z" => "-1", + "orient" => "0", + "defn" => "master:Line_FrePhase_Options", "link" => "-1", "q" => "4", + "disable" => "false", + ) + _set_attributes!(fre_phase, fre_phase_attrs) # Use helper + + fre_pl = addelement!(fre_phase, "paramlist") + # Original sets crc attribute only on paramlist, replicate exactly + fre_pl["crc"] = "-1" + fre_params = [ # Actual params + ("Interp1", "1"), ("Output", "0"), ("Inflen", "0"), ("FS", "0.5"), + ("FE", "1.0E6"), + ("Numf", "100"), ("YMaxP", "20"), ("YMaxE", "0.2"), ("AMaxP", "20"), + ("AMaxE", "0.2"), + ("MaxRPtol", "2.0e6"), ("W1", "1.0"), ("W2", "1000.0"), ("W3", "1.0"), + ("CPASS", "0"), + ("NFP", "1000"), ("FSP", "0.001"), ("FEP", "1000.0"), ("DCenab", "0"), + ("DCCOR", "1"), + ("ECLS", "1"), ("shntcab", "1.0E-9"), ("ET_PE", "1E-10"), ("MER_PE", "2"), + ("MIT_PE", "5"), ("FDIS", "3"), ("enablf", "1"), + ] + _add_params_to_list!(fre_pl, fre_params) # Use helper + + addelement!(row_schematic, "grouping") # Identical + + # --- Coaxial Cables Loop (Use Helpers, keep logic identical) --- + num_cables = cable_system.num_cables + dx = 400 + for i in 1:num_cables + cable_position = cable_system.cables[i] + coax1 = addelement!(row_schematic, "User") + coax1_id = _next_id() + coax1_attrs = Dict( + "classid" => "UserCmp", "name" => "master:Cable_Coax", "id" => coax1_id, + "x" => "$(234+(i-1)*dx)", "y" => "612", "w" => "311", "h" => "493", + "z" => "-1", + "orient" => "0", "defn" => "master:Cable_Coax", "link" => "-1", "q" => "4", + "disable" => "false", + ) + _set_attributes!(coax1, coax1_attrs) # Use helper + + coax1_pl = addelement!(coax1, "paramlist") + # Original sets attributes on paramlist AND adds params - replicate exactly + coax1_pl["link"] = "-1" + coax1_pl["name"] = "" + coax1_pl["crc"] = "-1" + + # --- Parameter Calculation (Identical Logic from Original) --- + component_ids = collect(keys(cable_position.design_data.components)) + num_cable_parts = length(component_ids) + if num_cable_parts > 4 + error( + "Cable $(cable_position.design_data.cable_id) has $num_cable_parts parts, exceeding the limit of 4 (core/sheath/armor/outer).", + ) + end + conn = cable_position.conn + elim1 = length(conn) >= 2 && conn[2] == 0 ? "1" : "0" + elim2 = length(conn) >= 3 && conn[3] == 0 ? "1" : "0" + elim3 = length(conn) >= 4 && conn[4] == 0 ? "1" : "0" + cable_x = cable_position.horz + cable_y = cable_position.vert + + # Build the parameter list exactly as in the original's logic + coax1_params_vector = Vector{Tuple{String,String}}() # Renamed variable + # Base parameters + push!(coax1_params_vector, ("CABNUM", "$i")) + push!(coax1_params_vector, ("Name", "$(cable_position.design_data.cable_id)")) + push!(coax1_params_vector, ("X", format_nominal(cable_x))) + push!(coax1_params_vector, ("OHC", "$(cable_y < 0 ? 0 : 1)")) + push!( + coax1_params_vector, + ("Y", (cable_y < 0 ? format_nominal(abs(cable_y)) : "0.0")), + ) + push!(coax1_params_vector, ("Y2", (cable_y > 0 ? format_nominal(cable_y) : "0.0"))) + push!(coax1_params_vector, ("ShuntA", "1.0e-11 [mho/m]")) + push!(coax1_params_vector, ("FLT", format_nominal(base_freq))) + push!(coax1_params_vector, ("RorT", "0")) + push!(coax1_params_vector, ("LL", "$(2*num_cable_parts-1)")) + push!(coax1_params_vector, ("CROSSBOND", "0")) + push!(coax1_params_vector, ("GROUPNO", "1")) + push!( + coax1_params_vector, + ("CBC1", "1"), + ("CBC2", "0"), + ("CBC3", "0"), + ("CBC4", "0"), + ) + push!(coax1_params_vector, ("SHRad", "1")) + push!(coax1_params_vector, ("LC", "3")) + + # Component parameters (Keep identical logic) + ω = 2 * π * base_freq + for (idx, component_id) in enumerate(component_ids) + component = cable_position.design_data.components[component_id] + C_eq = component.insulator_group.shunt_capacitance + G_eq = component.insulator_group.shunt_conductance + loss_factor = C_eq > 1e-18 ? G_eq / (ω * C_eq) : 0.0 # Avoid NaN/Inf + + sig_digits_props = 6 + max_loss_tangent = 10.0 + + if idx == 1 # Core + push!(coax1_params_vector, ("CONNAM1", uppercasefirst(component.id))) + push!( + coax1_params_vector, + ("R1", format_nominal(component.conductor_group.radius_in)), + ) + push!( + coax1_params_vector, + ("R2", format_nominal(component.conductor_group.radius_ext)), + ) + push!( + coax1_params_vector, + ( + "RHOC", + format_nominal( + component.conductor_props.rho, + sigdigits=sig_digits_props, + ), + ), + ) + push!( + coax1_params_vector, + ( + "PERMC", + format_nominal( + component.conductor_props.mu_r, + sigdigits=sig_digits_props, + ), + ), + ) + push!( + coax1_params_vector, + ("R3", format_nominal(component.insulator_group.radius_ext)), + ) + push!(coax1_params_vector, ("T3", "0.0000")) + push!(coax1_params_vector, ("SemiCL", "0")) + push!(coax1_params_vector, ("SL2", "0.0000")) + push!(coax1_params_vector, ("SL1", "0.0000")) + push!( + coax1_params_vector, + ( + "EPS1", + format_nominal( + component.insulator_props.eps_r, + sigdigits=sig_digits_props, + ), + ), + ) + push!( + coax1_params_vector, + ( + "PERM1", + format_nominal( + component.insulator_props.mu_r, + sigdigits=sig_digits_props, + ), + ), + ) + push!( + coax1_params_vector, + ( + "LT1", + format_nominal( + loss_factor, + sigdigits=sig_digits_props, + maxval=max_loss_tangent, + ), + ), + ) + elseif idx == 2 # Sheath + push!(coax1_params_vector, ("CONNAM2", uppercasefirst(component.id))) + push!( + coax1_params_vector, + ("R4", format_nominal(component.conductor_group.radius_ext)), + ) + push!(coax1_params_vector, ("T4", "0.0000")) + push!( + coax1_params_vector, + ( + "RHOS", + format_nominal( + component.conductor_props.rho, + sigdigits=sig_digits_props, + ), + ), + ) + push!( + coax1_params_vector, + ( + "PERMS", + format_nominal( + component.conductor_props.mu_r, + sigdigits=sig_digits_props, + ), + ), + ) + push!(coax1_params_vector, ("elim1", elim1)) + push!( + coax1_params_vector, + ("R5", format_nominal(component.insulator_group.radius_ext)), + ) + push!(coax1_params_vector, ("T5", "0.0000")) + push!( + coax1_params_vector, + ( + "EPS2", + format_nominal( + component.insulator_props.eps_r, + sigdigits=sig_digits_props, + ), + ), + ) + push!( + coax1_params_vector, + ( + "PERM2", + format_nominal( + component.insulator_props.mu_r, + sigdigits=sig_digits_props, + ), + ), + ) + push!( + coax1_params_vector, + ( + "LT2", + format_nominal( + loss_factor, + sigdigits=sig_digits_props, + maxval=max_loss_tangent, + ), + ), + ) + elseif idx == 3 # Armor + push!(coax1_params_vector, ("CONNAM3", uppercasefirst(component.id))) + push!( + coax1_params_vector, + ("R6", format_nominal(component.conductor_group.radius_ext)), + ) + push!(coax1_params_vector, ("T6", "0.0000")) + push!( + coax1_params_vector, + ( + "RHOA", + format_nominal( + component.conductor_props.rho, + sigdigits=sig_digits_props, + ), + ), + ) + push!( + coax1_params_vector, + ( + "PERMA", + format_nominal( + component.conductor_props.mu_r, + sigdigits=sig_digits_props, + ), + ), + ) + push!(coax1_params_vector, ("elim2", elim2)) + push!( + coax1_params_vector, + ("R7", format_nominal(component.insulator_group.radius_ext)), + ) + push!(coax1_params_vector, ("T7", "0.0000")) + push!( + coax1_params_vector, + ( + "EPS3", + format_nominal( + component.insulator_props.eps_r, + sigdigits=sig_digits_props, + ), + ), + ) + push!( + coax1_params_vector, + ( + "PERM3", + format_nominal( + component.insulator_props.mu_r, + sigdigits=sig_digits_props, + ), + ), + ) + push!( + coax1_params_vector, + ( + "LT3", + format_nominal( + loss_factor, + sigdigits=sig_digits_props, + maxval=max_loss_tangent, + ), + ), + ) + elseif idx == 4 # Outer + push!(coax1_params_vector, ("CONNAM4", uppercasefirst(component.id))) + push!( + coax1_params_vector, + ("R8", format_nominal(component.conductor_group.radius_ext)), + ) + push!(coax1_params_vector, ("T8", "0.0000")) + push!( + coax1_params_vector, + ( + "RHOO", + format_nominal( + component.conductor_props.rho, + sigdigits=sig_digits_props, + ), + ), + ) + push!( + coax1_params_vector, + ( + "PERMO", + format_nominal( + component.conductor_props.mu_r, + sigdigits=sig_digits_props, + ), + ), + ) + push!(coax1_params_vector, ("elim3", elim3)) + push!( + coax1_params_vector, + ("R9", format_nominal(component.insulator_group.radius_ext)), + ) + push!(coax1_params_vector, ("T9", "0.0000")) + push!( + coax1_params_vector, + ( + "EPS4", + format_nominal( + component.insulator_props.eps_r, + sigdigits=sig_digits_props, + ), + ), + ) + push!( + coax1_params_vector, + ( + "PERM4", + format_nominal( + component.insulator_props.mu_r, + sigdigits=sig_digits_props, + ), + ), + ) + push!( + coax1_params_vector, + ( + "LT4", + format_nominal( + loss_factor, + sigdigits=sig_digits_props, + maxval=max_loss_tangent, + ), + ), + ) + end + end + + # Default empty values (Keep identical logic) + if num_cable_parts < 2 + append!( + coax1_params_vector, + [ + ("CONNAM2", "none"), + ("R4", "0.0"), + ("T4", "0.0000"), + ("RHOS", "0.0"), + ("PERMS", "0.0"), + ("elim1", "0"), + ("R5", "0.0"), + ("T5", "0.0000"), + ("EPS2", "0.0"), + ("PERM2", "0.0"), + ("LT2", "0.0000"), + ], + ) + end + if num_cable_parts < 3 + append!( + coax1_params_vector, + [ + ("CONNAM3", "none"), + ("R6", "0.0"), + ("T6", "0.0000"), + ("RHOA", "0.0"), + ("PERMA", "0.0"), + ("elim2", "0"), + ("R7", "0.0"), + ("T7", "0.0000"), + ("EPS3", "0.0"), + ("PERM3", "0.0"), + ("LT3", "0.0000"), + ], + ) + end + if num_cable_parts < 4 + append!( + coax1_params_vector, + [ + ("CONNAM4", "none"), + ("R8", "0.0"), + ("T8", "0.0000"), + ("RHOO", "0.0"), + ("PERMO", "0.0"), + ("elim3", "0"), + ("R9", "0.0"), + ("T9", "0.0000"), + ("EPS4", "0.0"), + ("PERM4", "0.0"), + ("LT4", "0.0000"), + ], + ) + end + + # Add all collected parameters to the paramlist created earlier + _add_params_to_list!(coax1_pl, coax1_params_vector) # Use helper + + end # End Coax cable loop + + # --- Line_Ground Component (Use Helpers) --- + ground = addelement!(row_schematic, "User") + ground_id = _next_id() + ground_attrs = Dict( + "classid" => "UserCmp", "name" => "master:Line_Ground", "id" => ground_id, + "x" => "504", "y" => "288", "w" => "793", "h" => "88", "z" => "-1", + "orient" => "0", + "defn" => "master:Line_Ground", "link" => "-1", "q" => "4", "disable" => "false", + ) + _set_attributes!(ground, ground_attrs) # Use helper + + ground_pl = addelement!(ground, "paramlist") + # Original sets attributes on paramlist AND adds params - replicate exactly + ground_pl["link"] = "-1" + ground_pl["name"] = "" + ground_pl["crc"] = "-1" + + earth_layer = earth_props.layers[end] + ground_params_vector = [ # Renamed variable + ("EarthForm2", "0"), ("EarthForm", "3"), ("EarthForm3", "2"), ("GrRho", "0"), + ("GRRES", format_nominal(earth_layer.base_rho_g)), + ("GPERM", format_nominal(earth_layer.base_mur_g)), + ("K0", "0.001"), ("K1", "0.01"), ("alpha", "0.7"), + ("GRP", format_nominal(earth_layer.base_epsr_g)), + ] + _add_params_to_list!(ground_pl, ground_params_vector) # Use helper + + # --- Resource List and Hierarchy (Identical Nested Structure from Original) --- + addelement!(project, "List")["classid"] = "Resource" + + hierarchy = addelement!(project, "hierarchy") + # Nested calls exactly as in the original, linking to INSTANCE IDs from id_map + call1 = addelement!(hierarchy, "call") + # The link should be to the Station Definition ID, not an instance + call1["link"] = id_map["DS_Defn"] # Corrected link + call1["name"] = "$project_id:DS" + call1["z"] = "-1" + call1["view"] = "false" + call1["instance"] = "0" + + call2 = addelement!(call1, "call") + call2["link"] = id_map["Main"] # Links to Main User INSTANCE ID (as per original id_map usage) + call2["name"] = "$project_id:Main" + call2["z"] = "-1" + call2["view"] = "false" + call2["instance"] = "0" + + call3 = addelement!(call2, "call") + call3["link"] = id_map["CableSystem"] # Links to CableSystem User INSTANCE ID (as per original id_map usage) + call3["name"] = "$project_id:CableSystem" + call3["z"] = "-1" + call3["view"] = "true" + call3["instance"] = "0" + + try + # Use pretty print option for debugging comparisons if needed + # open(filename, "w") do io; prettyprint(io, doc); end + write(file_name, doc) # Standard write + if isfile(file_name) + @info "PSCAD file saved to: $(display_path(file_name))" + end + return file_name + catch e + @error "Failed to write PSCAD file '$(display_path(file_name))': $(e)" + isa(e, SystemError) && println("SystemError details: ", e.extrainfo) + return nothing + rethrow(e) # Rethrow to indicate failure clearly + end end \ No newline at end of file diff --git a/src/importexport/serialize.jl b/src/importexport/serialize.jl index 4126b829..ae989b07 100644 --- a/src/importexport/serialize.jl +++ b/src/importexport/serialize.jl @@ -1,214 +1,214 @@ -""" -$(TYPEDSIGNATURES) - -Defines which fields of a given object should be serialized to JSON. -This function acts as a trait. Specific types should overload this method -to customize which fields are needed for reconstruction. - -# Arguments - -- `obj`: The object whose serializable fields are to be determined. - -# Returns - -- A tuple of symbols representing the fields of `obj` that should be serialized. - -# Methods - -$(METHODLIST) -""" -function _serializable_fields end - -# Default fallback: Serialize all fields. This might include computed fields -# that are not needed for reconstruction. Overload for specific types. -_serializable_fields(obj::T) where {T} = fieldnames(T) - -# Define exactly which fields are needed to reconstruct each object. -# These typically match the constructor arguments or the minimal set -# required by the reconstruction logic (e.g., for groups). - -# Core Data Types -_serializable_fields(::Material) = (:rho, :eps_r, :mu_r, :T0, :alpha) -_serializable_fields(::NominalData) = ( - :designation_code, - :U0, - :U, - :conductor_cross_section, - :screen_cross_section, - :armor_cross_section, - :resistance, - :capacitance, - :inductance, -) - -# Layer Types (Conductor Parts) -_serializable_fields(::WireArray) = ( - :radius_in, # Needed for first layer reconstruction - :radius_wire, - :num_wires, - :lay_ratio, - :material_props, - :temperature, - :lay_direction, -) -_serializable_fields(::Tubular) = ( - :radius_in, # Needed for first layer reconstruction - :radius_ext, - :material_props, - :temperature, -) -_serializable_fields(::Strip) = ( - :radius_in, # Needed for first layer reconstruction - :radius_ext, - :width, - :lay_ratio, - :material_props, - :temperature, - :lay_direction, -) - -# Layer Types (Insulator Parts) -_serializable_fields(::Insulator) = ( - :radius_in, # Needed for first layer reconstruction - :radius_ext, - :material_props, - :temperature, -) -_serializable_fields(::Semicon) = ( - :radius_in, # Needed for first layer reconstruction - :radius_ext, - :material_props, - :temperature, -) - -# Group Types - Only serialize the layers needed for reconstruction. -_serializable_fields(::ConductorGroup) = (:layers,) -_serializable_fields(::InsulatorGroup) = (:layers,) - -# Component & Design Types -_serializable_fields(::CableComponent) = (:id, :conductor_group, :insulator_group) -# For CableDesign, we need components and nominal data. ID is handled as the key. -_serializable_fields(::CableDesign) = (:cable_id, :nominal_data, :components) - -# Library Types -_serializable_fields(::CablesLibrary) = (:data,) -_serializable_fields(::MaterialsLibrary) = (:data,) - - -#= -Serializes a Julia value into a JSON-compatible representation. -Handles special types like Measurements, Inf/NaN, Symbols, and custom structs -using the `_serializable_fields` trait. - -# Arguments -- `value`: The Julia value to serialize. - -# Returns -- A JSON-compatible representation (Dict, Vector, Number, String, Bool, Nothing). -=# -# Helper: only used in serialization, never leaks to core math. -function _serialize_value(value) - - if isnothing(value) - return nothing - - elseif value isa Measurements.Measurement - v = Measurements.value(value) - u = Measurements.uncertainty(value) - return Dict( - "__type__" => "Measurement", - "value" => _serialize_value(v), - "uncertainty" => _serialize_value(u), - ) - - elseif value isa Number && !isfinite(value) - # Inf / -Inf / NaN stay tagged - local val_str - if isinf(value) - val_str = value > 0 ? "Inf" : "-Inf" - else - # NaN - val_str = "NaN" - end - return Dict("__type__" => "SpecialFloat", "value" => val_str) - - elseif value isa AbstractFloat - return Dict("__type__" => "Float", "value" => value) - - elseif value isa Integer - return Dict("__type__" => "Int", "value" => value) - - elseif value isa Complex - return Dict("__type__" => "Complex", - "re" => _serialize_value(real(value)), - "im" => _serialize_value(imag(value)), - ) - - elseif value isa Number || value isa String || value isa Bool - return value - - elseif value isa Symbol - return string(value) - - elseif value isa AbstractDict - return Dict(string(k) => _serialize_value(v) for (k, v) in value) - - elseif value isa Union{AbstractVector, Tuple} - return [_serialize_value(v) for v in value] - else - !isprimitivetype(typeof(value)) && fieldcount(typeof(value)) > 0 - # Custom structs - return _serialize_obj(value) - end -end - - -""" -$(TYPEDSIGNATURES) - -Serializes a Julia value into a JSON-compatible representation. -Handles special types like Measurements, Inf/NaN, Symbols, and custom structs -using the [`_serializable_fields`](@ref) trait. - -# Arguments -- `value`: The Julia value to serialize. - -# Returns -- A JSON-compatible representation (Dict, Vector, Number, String, Bool, Nothing). -""" -function _serialize_obj(obj) - T = typeof(obj) - # Get fully qualified type name (e.g., Main.MyModule.MyType) - try - mod = parentmodule(T) - typeName = nameof(T) - type_str = string(mod, ".", typeName) - - result = Dict{String, Any}() - result["__julia_type__"] = type_str - - # Get the fields to serialize using the trait function - fields_to_include = _serializable_fields(obj) - - # Iterate only through the fields specified by the trait - for field in fields_to_include - if hasproperty(obj, field) - value = getfield(obj, field) - result[string(field)] = _serialize_value(value) # Recursively serialize - else - # This indicates an issue with the _serializable_fields definition for T - @warn "Field :$field specified by _serializable_fields(::$T) not found in object. Skipping." - end - end - return result - catch e - Base.error( - "Error determining module or type name for object of type $T: $e. Cannot serialize.", - ) - # Return a representation indicating the error - return Dict( - "__error__" => "Serialization failed for type $T", - "__details__" => string(e), - ) - end -end +""" +$(TYPEDSIGNATURES) + +Defines which fields of a given object should be serialized to JSON. +This function acts as a trait. Specific types should overload this method +to customize which fields are needed for reconstruction. + +# Arguments + +- `obj`: The object whose serializable fields are to be determined. + +# Returns + +- A tuple of symbols representing the fields of `obj` that should be serialized. + +# Methods + +$(METHODLIST) +""" +function _serializable_fields end + +# Default fallback: Serialize all fields. This might include computed fields +# that are not needed for reconstruction. Overload for specific types. +_serializable_fields(obj::T) where {T} = fieldnames(T) + +# Define exactly which fields are needed to reconstruct each object. +# These typically match the constructor arguments or the minimal set +# required by the reconstruction logic (e.g., for groups). + +# Core Data Types +_serializable_fields(::Material) = (:rho, :eps_r, :mu_r, :T0, :alpha, :kappa) +_serializable_fields(::NominalData) = ( + :designation_code, + :U0, + :U, + :conductor_cross_section, + :screen_cross_section, + :armor_cross_section, + :resistance, + :capacitance, + :inductance, +) + +# Layer Types (Conductor Parts) +_serializable_fields(::WireArray) = ( + :radius_in, # Needed for first layer reconstruction + :radius_wire, + :num_wires, + :lay_ratio, + :material_props, + :temperature, + :lay_direction, +) +_serializable_fields(::Tubular) = ( + :radius_in, # Needed for first layer reconstruction + :radius_ext, + :material_props, + :temperature, +) +_serializable_fields(::Strip) = ( + :radius_in, # Needed for first layer reconstruction + :radius_ext, + :width, + :lay_ratio, + :material_props, + :temperature, + :lay_direction, +) + +# Layer Types (Insulator Parts) +_serializable_fields(::Insulator) = ( + :radius_in, # Needed for first layer reconstruction + :radius_ext, + :material_props, + :temperature, +) +_serializable_fields(::Semicon) = ( + :radius_in, # Needed for first layer reconstruction + :radius_ext, + :material_props, + :temperature, +) + +# Group Types - Only serialize the layers needed for reconstruction. +_serializable_fields(::ConductorGroup) = (:layers,) +_serializable_fields(::InsulatorGroup) = (:layers,) + +# Component & Design Types +_serializable_fields(::CableComponent) = (:id, :conductor_group, :insulator_group) +# For CableDesign, we need components and nominal data. ID is handled as the key. +_serializable_fields(::CableDesign) = (:cable_id, :nominal_data, :components) + +# Library Types +_serializable_fields(::CablesLibrary) = (:data,) +_serializable_fields(::MaterialsLibrary) = (:data,) + + +#= +Serializes a Julia value into a JSON-compatible representation. +Handles special types like Measurements, Inf/NaN, Symbols, and custom structs +using the `_serializable_fields` trait. + +# Arguments +- `value`: The Julia value to serialize. + +# Returns +- A JSON-compatible representation (Dict, Vector, Number, String, Bool, Nothing). +=# +# Helper: only used in serialization, never leaks to core math. +function _serialize_value(value) + + if isnothing(value) + return nothing + + elseif value isa Measurements.Measurement + v = Measurements.value(value) + u = Measurements.uncertainty(value) + return Dict( + "__type__" => "Measurement", + "value" => _serialize_value(v), + "uncertainty" => _serialize_value(u), + ) + + elseif value isa Number && !isfinite(value) + # Inf / -Inf / NaN stay tagged + local val_str + if isinf(value) + val_str = value > 0 ? "Inf" : "-Inf" + else + # NaN + val_str = "NaN" + end + return Dict("__type__" => "SpecialFloat", "value" => val_str) + + elseif value isa AbstractFloat + return Dict("__type__" => "Float", "value" => value) + + elseif value isa Integer + return Dict("__type__" => "Int", "value" => value) + + elseif value isa Complex + return Dict("__type__" => "Complex", + "re" => _serialize_value(real(value)), + "im" => _serialize_value(imag(value)), + ) + + elseif value isa Number || value isa String || value isa Bool + return value + + elseif value isa Symbol + return string(value) + + elseif value isa AbstractDict + return Dict(string(k) => _serialize_value(v) for (k, v) in value) + + elseif value isa Union{AbstractVector, Tuple} + return [_serialize_value(v) for v in value] + else + !isprimitivetype(typeof(value)) && fieldcount(typeof(value)) > 0 + # Custom structs + return _serialize_obj(value) + end +end + + +""" +$(TYPEDSIGNATURES) + +Serializes a Julia value into a JSON-compatible representation. +Handles special types like Measurements, Inf/NaN, Symbols, and custom structs +using the [`_serializable_fields`](@ref) trait. + +# Arguments +- `value`: The Julia value to serialize. + +# Returns +- A JSON-compatible representation (Dict, Vector, Number, String, Bool, Nothing). +""" +function _serialize_obj(obj) + T = typeof(obj) + # Get fully qualified type name (e.g., Main.MyModule.MyType) + try + mod = parentmodule(T) + typeName = nameof(T) + type_str = string(mod, ".", typeName) + + result = Dict{String, Any}() + result["__julia_type__"] = type_str + + # Get the fields to serialize using the trait function + fields_to_include = _serializable_fields(obj) + + # Iterate only through the fields specified by the trait + for field in fields_to_include + if hasproperty(obj, field) + value = getfield(obj, field) + result[string(field)] = _serialize_value(value) # Recursively serialize + else + # This indicates an issue with the _serializable_fields definition for T + @warn "Field :$field specified by _serializable_fields(::$T) not found in object. Skipping." + end + end + return result + catch e + Base.error( + "Error determining module or type name for object of type $T: $e. Cannot serialize.", + ) + # Return a representation indicating the error + return Dict( + "__error__" => "Serialization failed for type $T", + "__details__" => string(e), + ) + end +end diff --git a/src/importexport/tralin.jl b/src/importexport/tralin.jl index e5c09d40..2684384f 100644 --- a/src/importexport/tralin.jl +++ b/src/importexport/tralin.jl @@ -1,471 +1,471 @@ -const _TRALIN_COMP = ("CORE", "SHEATH", "ARMOUR") - - -function export_data(::Val{:tralin}, - cable_system::LineCableSystem, - earth_props::EarthModel; - freq = f₀, - file_name::Union{String, Nothing} = nothing, -)::Union{String, Nothing} - - # -- helpers --------------------------------------------------------------- - _freqs(x) = x isa AbstractVector ? collect(x) : [x] - _fmt(x) = string(round(Float64(to_nominal(x)); digits = 6)) - _maybe(x) = (x === nothing) ? "" : _fmt(x) - - # Resolve output file name (prefix "tr_"; mirror XML semantics) - if isnothing(file_name) - file_name = joinpath(@__DIR__, "tr_$(cable_system.system_id).f05") - else - dir = dirname(file_name) - fname = basename(file_name) - # Ensure filename has "tr_" prefix, but preserve user's name - prefixed_fname = startswith(fname, "tr_") ? fname : "tr_" * fname - # Rejoin with original path, handling relative vs absolute - file_name = - isabspath(file_name) ? joinpath(dir, prefixed_fname) : - joinpath(@__DIR__, dir, prefixed_fname) - end - - num_phases = length(cable_system.cables) - freqs = map(f -> to_nominal(f), _freqs(freq)) - - # -- build TRALIN lines ---------------------------------------------------- - lines = String[] - - push!(lines, "TRALIN") - push!(lines, "TEXT,MODULE,LineCableModels run") - push!(lines, "OPTIONS") - push!(lines, "UNITS,METRIC") - push!(lines, "RUN-IDENTIFICATION,$(cable_system.system_id)") - push!(lines, "SEQUENCE,ON") - push!(lines, "MULTILAYER,ON") - push!(lines, "CONDUCTANCE,ON") - push!(lines, "!KEEP_CIRCUIT_MODE") - - push!(lines, "PARAMETERS") - push!(lines, "BASE-VALUES") - push!(lines, "ACCURACY,1e-7") - push!(lines, "BESSEL") - push!(lines, "TERMS,300") - for f in freqs - push!(lines, "FREQUENCY,$(_fmt(f))") - end - push!(lines, "INTEGRATION,AUTO-ADJUST,9") - push!(lines, "STEP,1e-6") - push!(lines, "UPPER-LIMIT,5.") - push!(lines, "SERIES-TERMS,300") - - nlayers = length(earth_props.layers) - - if nlayers == 2 - # [AIR, SOIL] => uniform semi-infinite earth - soil = earth_props.layers[end] - rho = _fmt(getfield(soil, :base_rho_g)) - mu_r = hasfield(typeof(soil), :mu_r) ? _fmt(getfield(soil, :mu_r)) : "1" - eps_r = hasfield(typeof(soil), :eps_r) ? _fmt(getfield(soil, :eps_r)) : "1" - push!(lines, "SOIL-TYPE") - push!(lines, "UNIFORM,$rho,$mu_r,$eps_r") - else - # [AIR, TOP, (CENTRAL...), BOTTOM] => HORIZONTAL - push!(lines, "SOIL-TYPE") - push!(lines, "HORIZONTAL") - - # AIR: no thickness -> explicit empty field `,,` - push!(lines, " LAYER,AIR,1e+18,,1,1") - - n_earth = nlayers - 1 - names = - n_earth == 1 ? ["TOP"] : - n_earth == 2 ? ["TOP", "BOTTOM"] : - vcat("TOP", fill("CENTRAL", n_earth - 2), "BOTTOM") - - for (eidx, (lname, layer)) in enumerate(zip(names, earth_props.layers[2:end])) - rho = _fmt(getfield(layer, :base_rho_g)) - mu_r = hasfield(typeof(layer), :base_mur_g) ? _fmt(getfield(layer, :base_mur_g)) : "1" - eps_r = hasfield(typeof(layer), :base_epsr_g) ? _fmt(getfield(layer, :base_epsr_g)) : "1" - - if eidx == n_earth - # BOTTOM: no thickness -> explicit empty field `,,` - push!(lines, " LAYER,$lname,$rho,,$mu_r,$eps_r") - else - # TOP/CENTRAL: include thickness if available; otherwise leave it empty to keep the slot - thk = - ( - hasfield(typeof(layer), :t) && - getfield(layer, :t) !== nothing - ) ? - _fmt(getfield(layer, :t)) : "" - push!(lines, " LAYER,$lname,$rho,$thk,$mu_r,$eps_r") - end - end - end - - push!(lines, "SYSTEM") - - for (pidx, cable) in enumerate(cable_system.cables) - # Phase group position - push!(lines, "GROUP,PH-$(pidx),$(_fmt(cable.horz)),$(_fmt(cable.vert))") - - comps_vec = cable.design_data.components # assumed Vector in your corrected model - ncomp = length(comps_vec) - if ncomp > 3 - throw( - ArgumentError( - "TRALIN supports at most 3 concentric components (CORE/SHEATH/ARMOR); got $ncomp for cable index $pidx.", - ), - ) - end - # Outer radius for CABLE line - outer_R = to_nominal(comps_vec[end].insulator_group.radius_ext) - push!(lines, "CABLE,CA-$(pidx),$(_fmt(outer_R))") - - # Strict connection vector - conn = getfield(cable, :conn) - if !(conn isa AbstractVector) - throw( - ArgumentError( - "cable.conn must be a Vector of Int mappings (0 or 1..$num_phases) for cable index $pidx.", - ), - ) - end - if length(conn) < ncomp - throw( - ArgumentError( - "cable.conn length $(length(conn)) < number of components $ncomp for cable index $pidx.", - ), - ) - end - - # Emit COMPONENT lines (same syntax for CORE/SHEATH/ARMOR) - for i in 1:ncomp - label = _TRALIN_COMP[i] - comp = comps_vec[i] - comp_id = String(getfield(comp, :id)) # <-- component name from your datamodel - - conn_val = Int(conn[i]) # 0 or 1..N phases - - cond_group = comp.conductor_group - ins_group = comp.insulator_group - cond_props = comp.conductor_props - ins_props = comp.insulator_props - - rin = _fmt(cond_group.radius_in) - rex = _fmt(cond_group.radius_ext) - rho = _fmt(cond_props.rho/ρ₀) # values in TRALIN are normalized to match the annealed copper - muC = _fmt(cond_props.mu_r) - epsI = _fmt(ins_props.eps_r) # coating εr - - # COMPONENT,,,,,,,0, - push!(lines, "$label,$comp_id,$conn_val,$rex,$rin,$rho,$muC,0,$epsI") - end - end - - push!(lines, "ENDPROGRAM") - - try - open(file_name, "w") do fid - for ln in lines - write(fid, ln); - write(fid, '\n') - end - end - @info "TRALIN file saved to: $(display_path(file_name))" - return file_name - catch e - @error "Failed to write TRALIN file '$(display_path(file_name))'" exception = - (e, catch_backtrace()) - return nothing - end -end - - -# --- internal utility: slice a block between an anchor and the next page header --- -# Finds the first line that contains `anchor` and returns the lines up to (but not including) -# the next "TRALIN package - PAGE" header. Throws if not found. -function _block_after_anchor(fileLines::Vector{String}, anchor::AbstractString) - start_idx = findfirst(l -> occursin(anchor, l), fileLines) - start_idx === nothing && throw(ArgumentError("Anchor not found: $anchor")) - - # page header appears after each page break; we stop before it - page_hdr = "TRALIN package - PAGE" - stop_idx = findnext(l -> occursin(page_hdr, l), fileLines, start_idx + 1) - stop_idx === nothing && (stop_idx = length(fileLines) + 1) - - # drop the anchor line itself and the terminating page header (if any) - return fileLines[(start_idx+1):(stop_idx-1)] -end - -function _infer_tralin_order(file_or_lines)::Int - fileLines = - file_or_lines isa AbstractString ? readlines(String(file_or_lines)) : file_or_lines - - block = _block_after_anchor( - fileLines, - "CHARACTERISTICS OF ALL CONDUCTORS", - ) - - # Table rows look like: - # 1 1 1 1 1 core 0.00000 0.01885 ... - # Columns (first 5 numbers): CONDUCTOR, GROUP, CABLE, COAX, PHASE - # We capture the 5th integer (PHASE) and keep nonzero uniques. - phase_set = Set{Int}() - row_re = r"^\s*\d+\s+\d+\s+\d+\s+\d+\s+(\d+)\s+\S+" - - for ln in block - m = match(row_re, ln) - if m !== nothing - ph = parse(Int, m.captures[1]) - if ph != 0 - push!(phase_set, ph) - end - end - end - - isempty(phase_set) && throw( - ArgumentError( - "Could not infer phase count from the 'CHARACTERISTICS OF ALL CONDUCTORS' table.", - ), - ) - return length(phase_set) -end - -# --- public: extract the frequency vector from the "FREQUENCY OF HARMONIC CURRENT" section --- -""" - extract_tralin_frequencies(file_or_lines) -> Vector{Float64} - -Parses the list of operating frequencies from the `FREQUENCY OF HARMONIC CURRENT:` section -up to the next page header. Returns a `Vector{Float64}` in \\[Hz\\]. - -Accepts either a filename (`AbstractString`) or a preloaded `Vector{String}` with file lines. -""" -function _extract_tralin_frequencies(file_or_lines)::Vector{Float64} - fileLines = - file_or_lines isa AbstractString ? readlines(String(file_or_lines)) : file_or_lines - - block = _block_after_anchor( - fileLines, - "FREQUENCY OF HARMONIC CURRENT:", - ) - - # Data lines look like: - # 1 1.00 - # 6 0.215E+04 - # We capture the second column as a float (supports E-notation). - freqs = Float64[] - row_re = r"^\s*\d+\s+([+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?)\s*$" - - for ln in block - m = match(row_re, ln) - if m !== nothing - push!(freqs, parse(Float64, m.captures[1])) - end - end - - isempty(freqs) && throw( - ArgumentError("No frequency lines found under 'FREQUENCY OF HARMONIC CURRENT:'."), - ) - - return freqs -end - -""" - parse_tralin_file(filename) - -Parse a TRALIN file and extract impedance, admittance, and potential coefficient matrices -for multiple frequency samples. -""" -function parse_tralin_file(filename) - fileLines = readlines(filename) - - ord = _infer_tralin_order(fileLines) - freqs = _extract_tralin_frequencies(fileLines) - - # Get all occurrences of "GROUND WIRES ELIMINATED" - limited_str = "GROUND WIRES ELIMINATED" - all_idx = findall(row -> occursin(limited_str, row), fileLines) - - # Initialize arrays to store matrices for all frequency samples - Z_matrices = Vector{Matrix{ComplexF64}}(undef, length(all_idx)) - Y_matrices = Vector{Matrix{ComplexF64}}(undef, length(all_idx)) - P_matrices = Vector{Matrix{ComplexF64}}(undef, length(all_idx)) - - # Loop through each frequency block - for (k, start_idx) in enumerate(all_idx) - - # Slice the file from the current "GROUND WIRES ELIMINATED" position to end - block_lines = fileLines[start_idx:end] - - # Extract matrices for this frequency sample, ensuring output is ComplexF64 - Z_matrices[k] = Complex{Float64}.( - extract_tralin_variable( - block_lines, - ord, - "SERIES IMPEDANCES - (ohms/kilometer)", - "SHUNT ADMITTANCES (microsiemens/kilometer)", - ), - ) - Y_matrices[k] = Complex{Float64}.( - extract_tralin_variable( - block_lines, - ord, - "SHUNT ADMITTANCES (microsiemens/kilometer)", - "SERIES ADMITTANCES (siemens.kilometer)", - ), - ) - P_matrices[k] = Complex{Float64}.( - extract_tralin_variable( - block_lines, - ord, - "POTENTIAL COEFFICIENTS (meghoms.kilometer)", - "SERIES IMPEDANCES - (ohms/kilometer)", - ), - ) - end - - # Convert lists of matrices into 3D arrays for each matrix type - Z_stack = reshape(hcat(Z_matrices...), ord, ord, length(Z_matrices)) - Y_stack = reshape(hcat(Y_matrices...), ord, ord, length(Y_matrices)) - P_stack = reshape(hcat(P_matrices...), ord, ord, length(P_matrices)) - - Z_stack = Z_stack ./ 1000 - Y_stack = Y_stack .* 1e-6 ./ 1000 - P_stack = P_stack .* 1e6 .* 1000 - - return freqs, Z_stack, Y_stack, P_stack -end - -""" - extract_tralin_variable(fileLines, order, str_init, str_final) - -Extracts matrix data between specified headers in `fileLines`, handling complex formatting. -""" -function extract_tralin_variable(fileLines, order, str_init, str_final) - # Locate header and footer lines - variable_init = findfirst(line -> occursin(str_init, line), fileLines) - variable_final = findfirst(line -> occursin(str_final, line), fileLines) - - if isnothing(variable_init) || isnothing(variable_final) - println("Could not locate start or end of the block.") - return zeros(ComplexF64, order, order) - end - - # Parse the relevant lines into a list of complex numbers - variable_list_number = [] - for line in fileLines[(variable_init+15):(variable_final-1)] - numbers = take_complex_list(line) - if !isempty(numbers) - push!(variable_list_number, numbers) - end - end - - # Process, clean, and arrange data into matrix form - variable_list_number = clean_variable_list(variable_list_number, order) - - # Initialize matrix and fill, with padding if necessary - matrix = zeros(ComplexF64, order, order) - for (i, row) in enumerate(variable_list_number) - matrix[i, 1:length(row)] = row - end - - # Make symmetric by filling lower triangle - matrix += tril(matrix, -1)' - - return matrix -end - - -""" - take_complex_list(s) - -Parses a string to identify real and complex numbers, with conditional scaling for scientific notation. -""" -function take_complex_list(s) - numbers = [] - - # Match the first real number (decimal, integer, or scientific notation) - first_real_pattern = r"([-+]?\d*\.?\d+(?:[Ee][-+]?\d+)?|\d+)" - first_real_match = match(first_real_pattern, s) - if !isnothing(first_real_match) - real_part_str = strip(first_real_match.match) - real_value = - occursin(r"[Ee]", real_part_str) ? parse(Float64, real_part_str) : - parse(Float64, real_part_str) * 1 - push!(numbers, real_value) - end - - # Match complex numbers (handles scientific notation or regular float, allowing extra whitespace before 'j') - complex_pattern = - r"([-+]?\d*\.?\d+(?:[Ee][-+]?\d+)?|\d+)\s*\+\s*j\s*([-+]?\d*\.?\d+(?:[Ee][-+]?\d+)?|\d+)" - for m in eachmatch(complex_pattern, s) - real_part_str, imag_part_str = m.captures - real_value = - occursin(r"[Ee]", real_part_str) ? parse(Float64, real_part_str) : - parse(Float64, real_part_str) * 1 - imag_value = - occursin(r"[Ee]", imag_part_str) ? parse(Float64, imag_part_str) : - parse(Float64, imag_part_str) * 1 - push!(numbers, Complex(real_value, imag_value)) - end - - return numbers -end - - -""" - clean_variable_list(variable_list_number, order) - -Cleans and arranges extracted list into a proper matrix format. -""" -function clean_variable_list(data, order) - # Remove entries that lack values, filter short lists - filter!(lst -> length(lst) > 1, data) - - # Trim row label elements and only keep the actual data - data = [lst[2:end] for lst in data] - - # Apply padding to each row as needed to align with specified order - data_padded = [vcat(lst, fill(0.0 + 0.0im, order - length(lst))) for lst in data] - - # Ensure `data_padded` has `order` rows; add extra rows of zeros if required - if length(data_padded) < order - for _ in 1:(order-length(data_padded)) - push!(data_padded, fill(0.0 + 0.0im, order)) - end - end - - return data_padded -end - -# -- Direct TRALIN constructor -function LineParameters(::Val{:tralin}, file_name::AbstractString) - f, Z_tralin, Y_tralin, _ = parse_tralin_file(file_name) - - # Normalize types (ComplexF64 / Float64 by default; tweak if you need Measurements etc.) - Z = ComplexF64.(Z_tralin) - Y = ComplexF64.(Y_tralin) - fv = Float64.(f) - - return LineParameters(SeriesImpedance(Z), ShuntAdmittance(Y), fv) -end - -# -- Format-auto convenience (add branches as you implement other parsers) -function LineParameters(file_name::AbstractString; format::Symbol = :auto) - fmt = - format === :auto ? (endswith(lowercase(file_name), ".f09") ? :tralin : :unknown) : - format - if fmt === :tralin - return LineParameters(Val(:tralin), file_name) - else - throw( - ArgumentError("Unknown/unsupported format for '$file_name' (format=$format)."), - ) - end -end - -# helpful fallback for unknown symbols (better than a MethodError) -LineParameters(::Val{fmt}, args...; kwargs...) where {fmt} = - throw(ArgumentError("Unsupported format: $(fmt)")) - -@inline LineParameters(fmt::Symbol, args...; kwargs...) = - LineParameters(Val(fmt), args...; kwargs...) +const _TRALIN_COMP = ("CORE", "SHEATH", "ARMOUR") + + +function export_data(::Val{:tralin}, + cable_system::LineCableSystem, + earth_props::EarthModel; + freq = f₀, + file_name::Union{String, Nothing} = nothing, +)::Union{String, Nothing} + + # -- helpers --------------------------------------------------------------- + _freqs(x) = x isa AbstractVector ? collect(x) : [x] + _fmt(x) = string(round(Float64(to_nominal(x)); digits = 6)) + _maybe(x) = (x === nothing) ? "" : _fmt(x) + + # Resolve output file name (prefix "tr_"; mirror XML semantics) + if isnothing(file_name) + file_name = joinpath(@__DIR__, "tr_$(cable_system.system_id).f05") + else + dir = dirname(file_name) + fname = basename(file_name) + # Ensure filename has "tr_" prefix, but preserve user's name + prefixed_fname = startswith(fname, "tr_") ? fname : "tr_" * fname + # Rejoin with original path, handling relative vs absolute + file_name = + isabspath(file_name) ? joinpath(dir, prefixed_fname) : + joinpath(@__DIR__, dir, prefixed_fname) + end + + num_phases = length(cable_system.cables) + freqs = map(f -> to_nominal(f), _freqs(freq)) + + # -- build TRALIN lines ---------------------------------------------------- + lines = String[] + + push!(lines, "TRALIN") + push!(lines, "TEXT,MODULE,LineCableModels run") + push!(lines, "OPTIONS") + push!(lines, "UNITS,METRIC") + push!(lines, "RUN-IDENTIFICATION,$(cable_system.system_id)") + push!(lines, "SEQUENCE,ON") + push!(lines, "MULTILAYER,ON") + push!(lines, "CONDUCTANCE,ON") + push!(lines, "!KEEP_CIRCUIT_MODE") + + push!(lines, "PARAMETERS") + push!(lines, "BASE-VALUES") + push!(lines, "ACCURACY,1e-7") + push!(lines, "BESSEL") + push!(lines, "TERMS,300") + for f in freqs + push!(lines, "FREQUENCY,$(_fmt(f))") + end + push!(lines, "INTEGRATION,AUTO-ADJUST,9") + push!(lines, "STEP,1e-6") + push!(lines, "UPPER-LIMIT,5.") + push!(lines, "SERIES-TERMS,300") + + nlayers = length(earth_props.layers) + + if nlayers == 2 + # [AIR, SOIL] => uniform semi-infinite earth + soil = earth_props.layers[end] + rho = _fmt(getfield(soil, :base_rho_g)) + mu_r = hasfield(typeof(soil), :mu_r) ? _fmt(getfield(soil, :mu_r)) : "1" + eps_r = hasfield(typeof(soil), :eps_r) ? _fmt(getfield(soil, :eps_r)) : "1" + push!(lines, "SOIL-TYPE") + push!(lines, "UNIFORM,$rho,$mu_r,$eps_r") + else + # [AIR, TOP, (CENTRAL...), BOTTOM] => HORIZONTAL + push!(lines, "SOIL-TYPE") + push!(lines, "HORIZONTAL") + + # AIR: no thickness -> explicit empty field `,,` + push!(lines, " LAYER,AIR,1e+18,,1,1") + + n_earth = nlayers - 1 + names = + n_earth == 1 ? ["TOP"] : + n_earth == 2 ? ["TOP", "BOTTOM"] : + vcat("TOP", fill("CENTRAL", n_earth - 2), "BOTTOM") + + for (eidx, (lname, layer)) in enumerate(zip(names, earth_props.layers[2:end])) + rho = _fmt(getfield(layer, :base_rho_g)) + mu_r = hasfield(typeof(layer), :base_mur_g) ? _fmt(getfield(layer, :base_mur_g)) : "1" + eps_r = hasfield(typeof(layer), :base_epsr_g) ? _fmt(getfield(layer, :base_epsr_g)) : "1" + + if eidx == n_earth + # BOTTOM: no thickness -> explicit empty field `,,` + push!(lines, " LAYER,$lname,$rho,,$mu_r,$eps_r") + else + # TOP/CENTRAL: include thickness if available; otherwise leave it empty to keep the slot + thk = + ( + hasfield(typeof(layer), :t) && + getfield(layer, :t) !== nothing + ) ? + _fmt(getfield(layer, :t)) : "" + push!(lines, " LAYER,$lname,$rho,$thk,$mu_r,$eps_r") + end + end + end + + push!(lines, "SYSTEM") + + for (pidx, cable) in enumerate(cable_system.cables) + # Phase group position + push!(lines, "GROUP,PH-$(pidx),$(_fmt(cable.horz)),$(_fmt(cable.vert))") + + comps_vec = cable.design_data.components # assumed Vector in your corrected model + ncomp = length(comps_vec) + if ncomp > 3 + throw( + ArgumentError( + "TRALIN supports at most 3 concentric components (CORE/SHEATH/ARMOR); got $ncomp for cable index $pidx.", + ), + ) + end + # Outer radius for CABLE line + outer_R = to_nominal(comps_vec[end].insulator_group.radius_ext) + push!(lines, "CABLE,CA-$(pidx),$(_fmt(outer_R))") + + # Strict connection vector + conn = getfield(cable, :conn) + if !(conn isa AbstractVector) + throw( + ArgumentError( + "cable.conn must be a Vector of Int mappings (0 or 1..$num_phases) for cable index $pidx.", + ), + ) + end + if length(conn) < ncomp + throw( + ArgumentError( + "cable.conn length $(length(conn)) < number of components $ncomp for cable index $pidx.", + ), + ) + end + + # Emit COMPONENT lines (same syntax for CORE/SHEATH/ARMOR) + for i in 1:ncomp + label = _TRALIN_COMP[i] + comp = comps_vec[i] + comp_id = String(getfield(comp, :id)) # <-- component name from your datamodel + + conn_val = Int(conn[i]) # 0 or 1..N phases + + cond_group = comp.conductor_group + ins_group = comp.insulator_group + cond_props = comp.conductor_props + ins_props = comp.insulator_props + + rin = _fmt(cond_group.radius_in) + rex = _fmt(cond_group.radius_ext) + rho = _fmt(cond_props.rho/ρ₀) # values in TRALIN are normalized to match the annealed copper + muC = _fmt(cond_props.mu_r) + epsI = _fmt(ins_props.eps_r) # coating εr + + # COMPONENT,,,,,,,0, + push!(lines, "$label,$comp_id,$conn_val,$rex,$rin,$rho,$muC,0,$epsI") + end + end + + push!(lines, "ENDPROGRAM") + + try + open(file_name, "w") do fid + for ln in lines + write(fid, ln); + write(fid, '\n') + end + end + @info "TRALIN file saved to: $(display_path(file_name))" + return file_name + catch e + @error "Failed to write TRALIN file '$(display_path(file_name))'" exception = + (e, catch_backtrace()) + return nothing + end +end + + +# --- internal utility: slice a block between an anchor and the next page header --- +# Finds the first line that contains `anchor` and returns the lines up to (but not including) +# the next "TRALIN package - PAGE" header. Throws if not found. +function _block_after_anchor(fileLines::Vector{String}, anchor::AbstractString) + start_idx = findfirst(l -> occursin(anchor, l), fileLines) + start_idx === nothing && throw(ArgumentError("Anchor not found: $anchor")) + + # page header appears after each page break; we stop before it + page_hdr = "TRALIN package - PAGE" + stop_idx = findnext(l -> occursin(page_hdr, l), fileLines, start_idx + 1) + stop_idx === nothing && (stop_idx = length(fileLines) + 1) + + # drop the anchor line itself and the terminating page header (if any) + return fileLines[(start_idx+1):(stop_idx-1)] +end + +function _infer_tralin_order(file_or_lines)::Int + fileLines = + file_or_lines isa AbstractString ? readlines(String(file_or_lines)) : file_or_lines + + block = _block_after_anchor( + fileLines, + "CHARACTERISTICS OF ALL CONDUCTORS", + ) + + # Table rows look like: + # 1 1 1 1 1 core 0.00000 0.01885 ... + # Columns (first 5 numbers): CONDUCTOR, GROUP, CABLE, COAX, PHASE + # We capture the 5th integer (PHASE) and keep nonzero uniques. + phase_set = Set{Int}() + row_re = r"^\s*\d+\s+\d+\s+\d+\s+\d+\s+(\d+)\s+\S+" + + for ln in block + m = match(row_re, ln) + if m !== nothing + ph = parse(Int, m.captures[1]) + if ph != 0 + push!(phase_set, ph) + end + end + end + + isempty(phase_set) && throw( + ArgumentError( + "Could not infer phase count from the 'CHARACTERISTICS OF ALL CONDUCTORS' table.", + ), + ) + return length(phase_set) +end + +# --- public: extract the frequency vector from the "FREQUENCY OF HARMONIC CURRENT" section --- +""" + extract_tralin_frequencies(file_or_lines) -> Vector{Float64} + +Parses the list of operating frequencies from the `FREQUENCY OF HARMONIC CURRENT:` section +up to the next page header. Returns a `Vector{Float64}` in \\[Hz\\]. + +Accepts either a filename (`AbstractString`) or a preloaded `Vector{String}` with file lines. +""" +function _extract_tralin_frequencies(file_or_lines)::Vector{Float64} + fileLines = + file_or_lines isa AbstractString ? readlines(String(file_or_lines)) : file_or_lines + + block = _block_after_anchor( + fileLines, + "FREQUENCY OF HARMONIC CURRENT:", + ) + + # Data lines look like: + # 1 1.00 + # 6 0.215E+04 + # We capture the second column as a float (supports E-notation). + freqs = Float64[] + row_re = r"^\s*\d+\s+([+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?)\s*$" + + for ln in block + m = match(row_re, ln) + if m !== nothing + push!(freqs, parse(Float64, m.captures[1])) + end + end + + isempty(freqs) && throw( + ArgumentError("No frequency lines found under 'FREQUENCY OF HARMONIC CURRENT:'."), + ) + + return freqs +end + +""" + parse_tralin_file(filename) + +Parse a TRALIN file and extract impedance, admittance, and potential coefficient matrices +for multiple frequency samples. +""" +function parse_tralin_file(filename) + fileLines = readlines(filename) + + ord = _infer_tralin_order(fileLines) + freqs = _extract_tralin_frequencies(fileLines) + + # Get all occurrences of "GROUND WIRES ELIMINATED" + limited_str = "GROUND WIRES ELIMINATED" + all_idx = findall(row -> occursin(limited_str, row), fileLines) + + # Initialize arrays to store matrices for all frequency samples + Z_matrices = Vector{Matrix{ComplexF64}}(undef, length(all_idx)) + Y_matrices = Vector{Matrix{ComplexF64}}(undef, length(all_idx)) + P_matrices = Vector{Matrix{ComplexF64}}(undef, length(all_idx)) + + # Loop through each frequency block + for (k, start_idx) in enumerate(all_idx) + + # Slice the file from the current "GROUND WIRES ELIMINATED" position to end + block_lines = fileLines[start_idx:end] + + # Extract matrices for this frequency sample, ensuring output is ComplexF64 + Z_matrices[k] = Complex{Float64}.( + extract_tralin_variable( + block_lines, + ord, + "SERIES IMPEDANCES - (ohms/kilometer)", + "SHUNT ADMITTANCES (microsiemens/kilometer)", + ), + ) + Y_matrices[k] = Complex{Float64}.( + extract_tralin_variable( + block_lines, + ord, + "SHUNT ADMITTANCES (microsiemens/kilometer)", + "SERIES ADMITTANCES (siemens.kilometer)", + ), + ) + P_matrices[k] = Complex{Float64}.( + extract_tralin_variable( + block_lines, + ord, + "POTENTIAL COEFFICIENTS (meghoms.kilometer)", + "SERIES IMPEDANCES - (ohms/kilometer)", + ), + ) + end + + # Convert lists of matrices into 3D arrays for each matrix type + Z_stack = reshape(hcat(Z_matrices...), ord, ord, length(Z_matrices)) + Y_stack = reshape(hcat(Y_matrices...), ord, ord, length(Y_matrices)) + P_stack = reshape(hcat(P_matrices...), ord, ord, length(P_matrices)) + + Z_stack = Z_stack ./ 1000 + Y_stack = Y_stack .* 1e-6 ./ 1000 + P_stack = P_stack .* 1e6 .* 1000 + + return freqs, Z_stack, Y_stack, P_stack +end + +""" + extract_tralin_variable(fileLines, order, str_init, str_final) + +Extracts matrix data between specified headers in `fileLines`, handling complex formatting. +""" +function extract_tralin_variable(fileLines, order, str_init, str_final) + # Locate header and footer lines + variable_init = findfirst(line -> occursin(str_init, line), fileLines) + variable_final = findfirst(line -> occursin(str_final, line), fileLines) + + if isnothing(variable_init) || isnothing(variable_final) + println("Could not locate start or end of the block.") + return zeros(ComplexF64, order, order) + end + + # Parse the relevant lines into a list of complex numbers + variable_list_number = [] + for line in fileLines[(variable_init+15):(variable_final-1)] + numbers = take_complex_list(line) + if !isempty(numbers) + push!(variable_list_number, numbers) + end + end + + # Process, clean, and arrange data into matrix form + variable_list_number = clean_variable_list(variable_list_number, order) + + # Initialize matrix and fill, with padding if necessary + matrix = zeros(ComplexF64, order, order) + for (i, row) in enumerate(variable_list_number) + matrix[i, 1:length(row)] = row + end + + # Make symmetric by filling lower triangle + matrix += tril(matrix, -1)' + + return matrix +end + + +""" + take_complex_list(s) + +Parses a string to identify real and complex numbers, with conditional scaling for scientific notation. +""" +function take_complex_list(s) + numbers = [] + + # Match the first real number (decimal, integer, or scientific notation) + first_real_pattern = r"([-+]?\d*\.?\d+(?:[Ee][-+]?\d+)?|\d+)" + first_real_match = match(first_real_pattern, s) + if !isnothing(first_real_match) + real_part_str = strip(first_real_match.match) + real_value = + occursin(r"[Ee]", real_part_str) ? parse(Float64, real_part_str) : + parse(Float64, real_part_str) * 1 + push!(numbers, real_value) + end + + # Match complex numbers (handles scientific notation or regular float, allowing extra whitespace before 'j') + complex_pattern = + r"([-+]?\d*\.?\d+(?:[Ee][-+]?\d+)?|\d+)\s*\+\s*j\s*([-+]?\d*\.?\d+(?:[Ee][-+]?\d+)?|\d+)" + for m in eachmatch(complex_pattern, s) + real_part_str, imag_part_str = m.captures + real_value = + occursin(r"[Ee]", real_part_str) ? parse(Float64, real_part_str) : + parse(Float64, real_part_str) * 1 + imag_value = + occursin(r"[Ee]", imag_part_str) ? parse(Float64, imag_part_str) : + parse(Float64, imag_part_str) * 1 + push!(numbers, Complex(real_value, imag_value)) + end + + return numbers +end + + +""" + clean_variable_list(variable_list_number, order) + +Cleans and arranges extracted list into a proper matrix format. +""" +function clean_variable_list(data, order) + # Remove entries that lack values, filter short lists + filter!(lst -> length(lst) > 1, data) + + # Trim row label elements and only keep the actual data + data = [lst[2:end] for lst in data] + + # Apply padding to each row as needed to align with specified order + data_padded = [vcat(lst, fill(0.0 + 0.0im, order - length(lst))) for lst in data] + + # Ensure `data_padded` has `order` rows; add extra rows of zeros if required + if length(data_padded) < order + for _ in 1:(order-length(data_padded)) + push!(data_padded, fill(0.0 + 0.0im, order)) + end + end + + return data_padded +end + +# -- Direct TRALIN constructor +function LineParameters(::Val{:tralin}, file_name::AbstractString) + f, Z_tralin, Y_tralin, _ = parse_tralin_file(file_name) + + # Normalize types (ComplexF64 / Float64 by default; tweak if you need Measurements etc.) + Z = ComplexF64.(Z_tralin) + Y = ComplexF64.(Y_tralin) + fv = Float64.(f) + + return LineParameters(SeriesImpedance(Z), ShuntAdmittance(Y), fv) +end + +# -- Format-auto convenience (add branches as you implement other parsers) +function LineParameters(file_name::AbstractString; format::Symbol = :auto) + fmt = + format === :auto ? (endswith(lowercase(file_name), ".f09") ? :tralin : :unknown) : + format + if fmt === :tralin + return LineParameters(Val(:tralin), file_name) + else + throw( + ArgumentError("Unknown/unsupported format for '$file_name' (format=$format)."), + ) + end +end + +# helpful fallback for unknown symbols (better than a MethodError) +LineParameters(::Val{fmt}, args...; kwargs...) where {fmt} = + throw(ArgumentError("Unsupported format: $(fmt)")) + +@inline LineParameters(fmt::Symbol, args...; kwargs...) = + LineParameters(Val(fmt), args...; kwargs...) diff --git a/src/materials/Materials.jl b/src/materials/Materials.jl index 3bf59963..ab878030 100644 --- a/src/materials/Materials.jl +++ b/src/materials/Materials.jl @@ -1,97 +1,102 @@ -""" - LineCableModels.Materials - -The [`Materials`](@ref) module provides functionality for managing and utilizing material properties within the [`LineCableModels.jl`](index.md) package. This module includes definitions for material properties, a library for storing and retrieving materials, and functions for manipulating material data. - -# Overview - -- Defines the [`Material`](@ref) struct representing fundamental physical properties of materials. -- Provides the [`MaterialsLibrary`](@ref) mutable struct for storing a collection of materials. -- Includes functions for adding, removing, and retrieving materials from the library. -- Supports loading and saving material data from/to JSON files. -- Contains utility functions for displaying material data. - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module Materials - -# Export public API -export Material, MaterialsLibrary - -# Module-specific dependencies -using ..Commons -using ..Utils: resolve_T -using Measurements -import ..Commons: add! -import ..Utils: coerce_to_T - -""" -$(TYPEDEF) - -Defines electromagnetic and thermal properties of a material used in cable modeling: - -$(TYPEDFIELDS) -""" -struct Material{T <: REALSCALAR} - "Electrical resistivity of the material \\[Ω·m\\]." - rho::T - "Relative permittivity \\[dimensionless\\]." - eps_r::T - "Relative permeability \\[dimensionless\\]." - mu_r::T - "Reference temperature for property evaluations \\[°C\\]." - T0::T - "Temperature coefficient of resistivity \\[1/°C\\]." - alpha::T - - @inline function Material{T}( - rho::T, - eps_r::T, - mu_r::T, - T0::T, - alpha::T, - ) where {T <: REALSCALAR} - return new{T}(rho, eps_r, mu_r, T0, alpha) - end - -end - -""" -$(TYPEDSIGNATURES) - -Weakly-typed constructor that infers the target scalar type `T` from the arguments, -coerces values to `T`, and calls the strict numeric kernel. - -# Arguments -- `rho`: Resistivity \\[Ω·m\\]. -- `eps_r`: Relative permittivity \\[1\\]. -- `mu_r`: Relative permeability \\[1\\]. -- `T0`: Reference temperature \\[°C\\]. -- `alpha`: Temperature coefficient of resistivity \\[1/°C\\]. - -# Returns -- `Material{T}` where `T = resolve_T(rho, eps_r, mu_r, T0, alpha)`. -""" -@inline function Material(rho, eps_r, mu_r, T0, alpha) - T = resolve_T(rho, eps_r, mu_r, T0, alpha) - return Material{T}( - coerce_to_T(rho, T), - coerce_to_T(eps_r, T), - coerce_to_T(mu_r, T), - coerce_to_T(T0, T), - coerce_to_T(alpha, T), - ) -end - -include("materialslibrary.jl") -include("dataframe.jl") -include("base.jl") -include("typecoercion.jl") - -end # module Materials +""" + LineCableModels.Materials + +The [`Materials`](@ref) module provides functionality for managing and utilizing material properties within the [`LineCableModels.jl`](index.md) package. This module includes definitions for material properties, a library for storing and retrieving materials, and functions for manipulating material data. + +# Overview + +- Defines the [`Material`](@ref) struct representing fundamental physical properties of materials. +- Provides the [`MaterialsLibrary`](@ref) mutable struct for storing a collection of materials. +- Includes functions for adding, removing, and retrieving materials from the library. +- Supports loading and saving material data from/to JSON files. +- Contains utility functions for displaying material data. + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module Materials + +# Export public API +export Material, MaterialsLibrary + +# Module-specific dependencies +using ..Commons +using ..Utils: resolve_T +using Measurements +import ..Commons: add! +import ..Utils: coerce_to_T + +""" +$(TYPEDEF) + +Defines electromagnetic and thermal properties of a material used in cable modeling: + +$(TYPEDFIELDS) +""" +struct Material{T <: REALSCALAR} + "Electrical resistivity of the material \\[Ω·m\\]." + rho::T + "Relative permittivity \\[dimensionless\\]." + eps_r::T + "Relative permeability \\[dimensionless\\]." + mu_r::T + "Reference temperature for property evaluations \\[°C\\]." + T0::T + "Temperature coefficient of resistivity \\[1/°C\\]." + alpha::T + "Thermal conductivity of the material \\[W/(m·K)\\]." + kappa::T + + @inline function Material{T}( + rho::T, + eps_r::T, + mu_r::T, + T0::T, + alpha::T, + kappa::T, + ) where {T <: REALSCALAR} + return new{T}(rho, eps_r, mu_r, T0, alpha, kappa) + end + +end + +""" +$(TYPEDSIGNATURES) + +Weakly-typed constructor that infers the target scalar type `T` from the arguments, +coerces values to `T`, and calls the strict numeric kernel. + +# Arguments +- `rho`: Resistivity \\[Ω·m\\]. +- `eps_r`: Relative permittivity \\[1\\]. +- `mu_r`: Relative permeability \\[1\\]. +- `T0`: Reference temperature \\[°C\\]. +- `alpha`: Temperature coefficient of resistivity \\[1/°C\\]. +- `kappa`: Thermal conductivity \\[W/(m·K)\\]. + +# Returns +- `Material{T}` where `T = resolve_T(rho, eps_r, mu_r, T0, alpha, kappa)`. +""" +@inline function Material(rho, eps_r, mu_r, T0, alpha, kappa) + T = resolve_T(rho, eps_r, mu_r, T0, alpha, kappa) + return Material{T}( + coerce_to_T(rho, T), + coerce_to_T(eps_r, T), + coerce_to_T(mu_r, T), + coerce_to_T(T0, T), + coerce_to_T(alpha, T), + coerce_to_T(kappa, T), + ) +end + +include("materialslibrary.jl") +include("dataframe.jl") +include("base.jl") +include("typecoercion.jl") + +end # module Materials diff --git a/src/materials/base.jl b/src/materials/base.jl index d1000cb8..6149ba5b 100644 --- a/src/materials/base.jl +++ b/src/materials/base.jl @@ -1,197 +1,197 @@ -Base.eltype(::Material{T}) where {T} = T -Base.eltype(::Type{Material{T}}) where {T} = T - -# Implement the AbstractDict interface -Base.length(lib::MaterialsLibrary) = length(lib.data) -Base.setindex!(lib::MaterialsLibrary, value::Material, key::String) = (lib.data[key] = value) -Base.iterate(lib::MaterialsLibrary, state...) = iterate(lib.data, state...) -Base.keys(lib::MaterialsLibrary) = keys(lib.data) -Base.values(lib::MaterialsLibrary) = values(lib.data) -Base.haskey(lib::MaterialsLibrary, key::String) = haskey(lib.data, key) -Base.getindex(lib::MaterialsLibrary, key::String) = getindex(lib.data, key) - - -""" -$(TYPEDSIGNATURES) - -Removes a material from a [`MaterialsLibrary`](@ref). - -# Arguments - -- `library`: Instance of [`MaterialsLibrary`](@ref) from which the material will be removed. -- `name`: Name of the material to be removed. - -# Returns - -- The modified instance of [`MaterialsLibrary`](@ref) without the specified material. - -# Errors - -Throws an error if the material does not exist in the library. - -# Examples - -```julia -library = MaterialsLibrary() -$(FUNCTIONNAME)(library, "copper") -``` - -# See also - -- [`add!`](@ref) -""" -function Base.delete!(library::MaterialsLibrary, name::String) - if !haskey(library, name) - @error "Material '$name' not found in the library; cannot delete." - throw(KeyError(name)) - - end - delete!(library.data, name) - @info "Material '$name' removed from the library." -end - - - -""" -$(TYPEDSIGNATURES) - -Retrieves a material from a [`MaterialsLibrary`](@ref) by name. - -# Arguments - -- `library`: Instance of [`MaterialsLibrary`](@ref) containing the materials. -- `name`: Name of the material to retrieve. - -# Returns - -- The requested [`Material`](@ref) if found, otherwise `nothing`. - -# Examples - -```julia -library = MaterialsLibrary() -material = $(FUNCTIONNAME)(library, "copper") -``` - -# See also - -- [`add!`](@ref) -- [`delete!`](@ref) -""" -function Base.get(library::MaterialsLibrary, name::String, default=nothing) - material = get(library.data, name, default) - if material === nothing - @warn "Material '$name' not found in the library; returning default." - end - return material -end - -""" -$(TYPEDSIGNATURES) - -Defines the display representation of a [`Material`](@ref) object for REPL or text output. - -# Arguments - -- `io`: Output stream. -- `::MIME"text/plain"`: MIME type for plain text output. -- `material`: The [`Material`](@ref) instance to be displayed. - -# Returns - -- Nothing. Modifies `io` by writing text representation of the material. -""" -function Base.show(io::IO, ::MIME"text/plain", material::Material) - print(io, "Material with properties: [") - - # Define fields to display - fields = [:rho, :eps_r, :mu_r, :T0, :alpha] - - # Print each field with proper formatting - for (i, field) in enumerate(fields) - value = getfield(material, field) - # Add comma only between items, not after the last one - delimiter = i < length(fields) ? ", " : "" - print(io, "$field=$(round(value, sigdigits=4))$delimiter") - end - - print(io, "]") -end - -""" -$(TYPEDSIGNATURES) - -Defines the display representation of a [`MaterialsLibrary`](@ref) object for REPL or text output. - -# Arguments - -- `io`: Output stream. -- `::MIME"text/plain"`: MIME type for plain text output. -- `library`: The [`MaterialsLibrary`](@ref) instance to be displayed. - -# Returns - -- Nothing. Modifies `io` by writing text representation of the library. -""" -function Base.show(io::IO, ::MIME"text/plain", library::MaterialsLibrary) - num_materials = length(library) - material_word = num_materials == 1 ? "material" : "materials" - print(io, "MaterialsLibrary with $num_materials $material_word") - - if num_materials > 0 - print(io, ":") - # Optional: list the first few materials - shown_materials = min(5, num_materials) - material_names = collect(keys(library))[1:shown_materials] - - for (i, name) in enumerate(material_names) - print(io, "\n$(i == shown_materials ? "└─" : "├─") $name") - end - - # If there are more materials than we're showing - if num_materials > shown_materials - print(io, "\n└─ ... and $(num_materials - shown_materials) more") - end - end -end - -""" -$(TYPEDSIGNATURES) - -Defines the display representation of a [`MaterialsLibrary`](@ref) object for REPL or text output. - -# Arguments - -- `io`: Output stream. -- `::MIME"text/plain"`: MIME type for plain text output. -- `dict`: The [`MaterialsLibrary`](@ref) contents to be displayed. - -# Returns - -- Nothing. Modifies `io` by writing text representation of the library. -""" -function Base.show(io::IO, ::MIME"text/plain", dict::Dict{String,Material}) - num_materials = length(dict) - material_word = num_materials == 1 ? "material" : "materials" - print(io, "Dict{String, Material} with $num_materials $material_word") - - if num_materials > 0 - print(io, ":") - # List the first few materials - shown_materials = min(5, num_materials) - material_names = collect(keys(dict))[1:shown_materials] - - for (i, name) in enumerate(material_names) - print(io, "\n$(i == shown_materials ? "└─" : "├─") $name") - end - - # If there are more materials than we're showing - if num_materials > shown_materials - print(io, "\n└─ ... and $(num_materials - shown_materials) more") - end - end -end - -Base.convert(::Type{Material{T}}, m::Material) where {T<:REALSCALAR} = - Material{T}(convert(T, m.rho), convert(T, m.eps_r), convert(T, m.mu_r), +Base.eltype(::Material{T}) where {T} = T +Base.eltype(::Type{Material{T}}) where {T} = T + +# Implement the AbstractDict interface +Base.length(lib::MaterialsLibrary) = length(lib.data) +Base.setindex!(lib::MaterialsLibrary, value::Material, key::String) = (lib.data[key] = value) +Base.iterate(lib::MaterialsLibrary, state...) = iterate(lib.data, state...) +Base.keys(lib::MaterialsLibrary) = keys(lib.data) +Base.values(lib::MaterialsLibrary) = values(lib.data) +Base.haskey(lib::MaterialsLibrary, key::String) = haskey(lib.data, key) +Base.getindex(lib::MaterialsLibrary, key::String) = getindex(lib.data, key) + + +""" +$(TYPEDSIGNATURES) + +Removes a material from a [`MaterialsLibrary`](@ref). + +# Arguments + +- `library`: Instance of [`MaterialsLibrary`](@ref) from which the material will be removed. +- `name`: Name of the material to be removed. + +# Returns + +- The modified instance of [`MaterialsLibrary`](@ref) without the specified material. + +# Errors + +Throws an error if the material does not exist in the library. + +# Examples + +```julia +library = MaterialsLibrary() +$(FUNCTIONNAME)(library, "copper") +``` + +# See also + +- [`add!`](@ref) +""" +function Base.delete!(library::MaterialsLibrary, name::String) + if !haskey(library, name) + @error "Material '$name' not found in the library; cannot delete." + throw(KeyError(name)) + + end + delete!(library.data, name) + @info "Material '$name' removed from the library." +end + + + +""" +$(TYPEDSIGNATURES) + +Retrieves a material from a [`MaterialsLibrary`](@ref) by name. + +# Arguments + +- `library`: Instance of [`MaterialsLibrary`](@ref) containing the materials. +- `name`: Name of the material to retrieve. + +# Returns + +- The requested [`Material`](@ref) if found, otherwise `nothing`. + +# Examples + +```julia +library = MaterialsLibrary() +material = $(FUNCTIONNAME)(library, "copper") +``` + +# See also + +- [`add!`](@ref) +- [`delete!`](@ref) +""" +function Base.get(library::MaterialsLibrary, name::String, default=nothing) + material = get(library.data, name, default) + if material === nothing + @warn "Material '$name' not found in the library; returning default." + end + return material +end + +""" +$(TYPEDSIGNATURES) + +Defines the display representation of a [`Material`](@ref) object for REPL or text output. + +# Arguments + +- `io`: Output stream. +- `::MIME"text/plain"`: MIME type for plain text output. +- `material`: The [`Material`](@ref) instance to be displayed. + +# Returns + +- Nothing. Modifies `io` by writing text representation of the material. +""" +function Base.show(io::IO, ::MIME"text/plain", material::Material) + print(io, "Material with properties: [") + + # Define fields to display + fields = [:rho, :eps_r, :mu_r, :T0, :alpha, :kappa] + + # Print each field with proper formatting + for (i, field) in enumerate(fields) + value = getfield(material, field) + # Add comma only between items, not after the last one + delimiter = i < length(fields) ? ", " : "" + print(io, "$field=$(round(value, sigdigits=4))$delimiter") + end + + print(io, "]") +end + +""" +$(TYPEDSIGNATURES) + +Defines the display representation of a [`MaterialsLibrary`](@ref) object for REPL or text output. + +# Arguments + +- `io`: Output stream. +- `::MIME"text/plain"`: MIME type for plain text output. +- `library`: The [`MaterialsLibrary`](@ref) instance to be displayed. + +# Returns + +- Nothing. Modifies `io` by writing text representation of the library. +""" +function Base.show(io::IO, ::MIME"text/plain", library::MaterialsLibrary) + num_materials = length(library) + material_word = num_materials == 1 ? "material" : "materials" + print(io, "MaterialsLibrary with $num_materials $material_word") + + if num_materials > 0 + print(io, ":") + # Optional: list the first few materials + shown_materials = min(5, num_materials) + material_names = collect(keys(library))[1:shown_materials] + + for (i, name) in enumerate(material_names) + print(io, "\n$(i == shown_materials ? "└─" : "├─") $name") + end + + # If there are more materials than we're showing + if num_materials > shown_materials + print(io, "\n└─ ... and $(num_materials - shown_materials) more") + end + end +end + +""" +$(TYPEDSIGNATURES) + +Defines the display representation of a [`MaterialsLibrary`](@ref) object for REPL or text output. + +# Arguments + +- `io`: Output stream. +- `::MIME"text/plain"`: MIME type for plain text output. +- `dict`: The [`MaterialsLibrary`](@ref) contents to be displayed. + +# Returns + +- Nothing. Modifies `io` by writing text representation of the library. +""" +function Base.show(io::IO, ::MIME"text/plain", dict::Dict{String,Material}) + num_materials = length(dict) + material_word = num_materials == 1 ? "material" : "materials" + print(io, "Dict{String, Material} with $num_materials $material_word") + + if num_materials > 0 + print(io, ":") + # List the first few materials + shown_materials = min(5, num_materials) + material_names = collect(keys(dict))[1:shown_materials] + + for (i, name) in enumerate(material_names) + print(io, "\n$(i == shown_materials ? "└─" : "├─") $name") + end + + # If there are more materials than we're showing + if num_materials > shown_materials + print(io, "\n└─ ... and $(num_materials - shown_materials) more") + end + end +end + +Base.convert(::Type{Material{T}}, m::Material) where {T<:REALSCALAR} = + Material{T}(convert(T, m.rho), convert(T, m.eps_r), convert(T, m.mu_r), convert(T, m.T0), convert(T, m.alpha)) \ No newline at end of file diff --git a/src/materials/dataframe.jl b/src/materials/dataframe.jl index b3fbf645..d3f0996d 100644 --- a/src/materials/dataframe.jl +++ b/src/materials/dataframe.jl @@ -1,41 +1,42 @@ -import DataFrames: DataFrame - -""" -$(TYPEDSIGNATURES) - -Lists the contents of a [`MaterialsLibrary`](@ref) as a `DataFrame`. - -# Arguments - -- `library`: Instance of [`MaterialsLibrary`](@ref) to be displayed. - -# Returns - -- A `DataFrame` containing the material properties. - -# Examples - -```julia -library = MaterialsLibrary() -df = $(FUNCTIONNAME)(library) -``` - -# See also - -- [`LineCableModels.ImportExport.save`](@ref) -""" -function DataFrame(library::MaterialsLibrary)::DataFrame - rows = [ - ( - name=name, - rho=m.rho, - eps_r=m.eps_r, - mu_r=m.mu_r, - T0=m.T0, - alpha=m.alpha, - ) - for (name, m) in library - ] - data = DataFrame(rows) - return data +import DataFrames: DataFrame + +""" +$(TYPEDSIGNATURES) + +Lists the contents of a [`MaterialsLibrary`](@ref) as a `DataFrame`. + +# Arguments + +- `library`: Instance of [`MaterialsLibrary`](@ref) to be displayed. + +# Returns + +- A `DataFrame` containing the material properties. + +# Examples + +```julia +library = MaterialsLibrary() +df = $(FUNCTIONNAME)(library) +``` + +# See also + +- [`LineCableModels.ImportExport.save`](@ref) +""" +function DataFrame(library::MaterialsLibrary)::DataFrame + rows = [ + ( + name=name, + rho=m.rho, + eps_r=m.eps_r, + mu_r=m.mu_r, + T0=m.T0, + alpha=m.alpha, + kappa=m.kappa, + ) + for (name, m) in library + ] + data = DataFrame(rows) + return data end \ No newline at end of file diff --git a/src/materials/materialslibrary.jl b/src/materials/materialslibrary.jl index 601211bd..6a82d575 100644 --- a/src/materials/materialslibrary.jl +++ b/src/materials/materialslibrary.jl @@ -1,144 +1,144 @@ -""" -$(TYPEDEF) - -Stores a collection of predefined materials for cable modeling, indexed by material name: - -$(TYPEDFIELDS) -""" -mutable struct MaterialsLibrary <: AbstractDict{String, Material} - "Dictionary mapping material names to [`Material`](@ref) objects." - data::Dict{String, Material} # Key: Material name, Value: Material object -end - -""" -$(TYPEDSIGNATURES) - -Constructs an empty [`MaterialsLibrary`](@ref) instance and initializes with default materials. - -# Arguments - -- None. - -# Returns - -- A [`MaterialsLibrary`](@ref) object populated with default materials. - -# Examples - -```julia -# Create a new, empty library -library = $(FUNCTIONNAME)() -``` - -# See also - -- [`Material`](@ref) -- [`_add_default_materials!`](@ref) -""" -function MaterialsLibrary(; add_defaults::Bool = true)::MaterialsLibrary - library = MaterialsLibrary(Dict{String, Material}()) - - if add_defaults - @info "Initializing default materials database..." - _add_default_materials!(library) - end - - return library -end - -""" -$(TYPEDSIGNATURES) - -Populates a [`MaterialsLibrary`](@ref) with commonly used materials, assigning predefined electrical and thermal properties. - -# Arguments - -- `library`: Instance of [`MaterialsLibrary`](@ref) to be populated. - -# Returns - -- The modified instance of [`MaterialsLibrary`](@ref) containing the predefined materials. - -# Examples - -```julia -library = MaterialsLibrary() -$(FUNCTIONNAME)(library) -``` - -# See also - -- [`add!`](@ref) -""" -function _add_default_materials!(library::MaterialsLibrary) - add!(library, "air", Material(Inf, 1.0, 1.0, 20.0, 0.0)) - add!(library, "pec", Material(eps(), 1.0, 1.0, 20.0, 0.0)) - add!( - library, - "copper", - Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393), - ) - add!( - library, - "aluminum", - Material(2.8264e-8, 1.0, 1.000022, 20.0, 0.00429), - ) - add!(library, "xlpe", Material(1.97e14, 2.5, 1.0, 20.0, 0.0)) - add!(library, "pe", Material(1.97e14, 2.3, 1.0, 20.0, 0.0)) - add!( - library, - "semicon1", - Material(1000.0, 1000.0, 1.0, 20.0, 0.0), - ) - add!( - library, - "semicon2", - Material(500.0, 1000.0, 1.0, 20.0, 0.0), - ) - add!( - library, - "polyacrylate", - Material(5.3e3, 32.3, 1.0, 20.0, 0.0), - ) -end - - -""" -$(TYPEDSIGNATURES) - -Adds a new material to a [`MaterialsLibrary`](@ref). - -# Arguments - -- `library`: Instance of [`MaterialsLibrary`](@ref) where the material will be added. -- `name`: Name of the material. -- `material`: Instance of [`Material`](@ref) containing its properties. - -# Returns - -- The modified instance of [`MaterialsLibrary`](@ref) with the new material added. - -# Errors - -Throws an error if a material with the same name already exists in the library. - -# Examples - -```julia -library = MaterialsLibrary() -material = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) -$(FUNCTIONNAME)(library, "copper", material) -``` -""" -function add!( - library::MaterialsLibrary, - name::AbstractString, - material::Material, -) - if haskey(library, name) - Base.error("Material $name already exists in the library.") - end - library[String(name)] = material - library -end - +""" +$(TYPEDEF) + +Stores a collection of predefined materials for cable modeling, indexed by material name: + +$(TYPEDFIELDS) +""" +mutable struct MaterialsLibrary <: AbstractDict{String, Material} + "Dictionary mapping material names to [`Material`](@ref) objects." + data::Dict{String, Material} # Key: Material name, Value: Material object +end + +""" +$(TYPEDSIGNATURES) + +Constructs an empty [`MaterialsLibrary`](@ref) instance and initializes with default materials. + +# Arguments + +- None. + +# Returns + +- A [`MaterialsLibrary`](@ref) object populated with default materials. + +# Examples + +```julia +# Create a new, empty library +library = $(FUNCTIONNAME)() +``` + +# See also + +- [`Material`](@ref) +- [`_add_default_materials!`](@ref) +""" +function MaterialsLibrary(; add_defaults::Bool = true)::MaterialsLibrary + library = MaterialsLibrary(Dict{String, Material}()) + + if add_defaults + @info "Initializing default materials database..." + _add_default_materials!(library) + end + + return library +end + +""" +$(TYPEDSIGNATURES) + +Populates a [`MaterialsLibrary`](@ref) with commonly used materials, assigning predefined electrical and thermal properties. + +# Arguments + +- `library`: Instance of [`MaterialsLibrary`](@ref) to be populated. + +# Returns + +- The modified instance of [`MaterialsLibrary`](@ref) containing the predefined materials. + +# Examples + +```julia +library = MaterialsLibrary() +$(FUNCTIONNAME)(library) +``` + +# See also + +- [`add!`](@ref) +""" +function _add_default_materials!(library::MaterialsLibrary) + add!(library, "air", Material(Inf, 1.0, 1.0, 20.0, 0.0, 0.02587)) + add!(library, "pec", Material(eps(), 1.0, 1.0, 20.0, 0.0, 0.19)) + add!( + library, + "copper", + Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0), + ) + add!( + library, + "aluminum", + Material(2.8264e-8, 1.0, 1.000022, 20.0, 0.00429, 237.0), + ) + add!(library, "xlpe", Material(1.97e14, 2.5, 1.0, 20.0, 0.0, 0.3)) + add!(library, "pe", Material(1.97e14, 2.3, 1.0, 20.0, 0.0, 0.28)) + add!( + library, + "semicon1", + Material(1000.0, 1000.0, 1.0, 20.0, 0.0, 148.0), + ) + add!( + library, + "semicon2", + Material(500.0, 1000.0, 1.0, 20.0, 0.0, 148.0), + ) + add!( + library, + "polyacrylate", + Material(5.3e3, 32.3, 1.0, 20.0, 0.0, 0.1), + ) +end + + +""" +$(TYPEDSIGNATURES) + +Adds a new material to a [`MaterialsLibrary`](@ref). + +# Arguments + +- `library`: Instance of [`MaterialsLibrary`](@ref) where the material will be added. +- `name`: Name of the material. +- `material`: Instance of [`Material`](@ref) containing its properties. + +# Returns + +- The modified instance of [`MaterialsLibrary`](@ref) with the new material added. + +# Errors + +Throws an error if a material with the same name already exists in the library. + +# Examples + +```julia +library = MaterialsLibrary() +material = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) +$(FUNCTIONNAME)(library, "copper", material) +``` +""" +function add!( + library::MaterialsLibrary, + name::AbstractString, + material::Material, +) + if haskey(library, name) + Base.error("Material $name already exists in the library.") + end + library[String(name)] = material + library +end + diff --git a/src/materials/typecoercion.jl b/src/materials/typecoercion.jl index 9cd1b075..ebb739b7 100644 --- a/src/materials/typecoercion.jl +++ b/src/materials/typecoercion.jl @@ -1,11 +1,12 @@ -# Identity: no allocation if already at T -@inline coerce_to_T(m::Material{T}, ::Type{T}) where {T} = m - -# Cross-T rebuild: use the TYPED constructor to avoid surprise promotion -@inline coerce_to_T(m::Material{S}, ::Type{T}) where {S,T} = Material{T}( - coerce_to_T(m.rho, T), - coerce_to_T(m.eps_r, T), - coerce_to_T(m.mu_r, T), - coerce_to_T(m.T0, T), - coerce_to_T(m.alpha, T), +# Identity: no allocation if already at T +@inline coerce_to_T(m::Material{T}, ::Type{T}) where {T} = m + +# Cross-T rebuild: use the TYPED constructor to avoid surprise promotion +@inline coerce_to_T(m::Material{S}, ::Type{T}) where {S,T} = Material{T}( + coerce_to_T(m.rho, T), + coerce_to_T(m.eps_r, T), + coerce_to_T(m.mu_r, T), + coerce_to_T(m.T0, T), + coerce_to_T(m.alpha, T), + coerce_to_T(m.kappa, T), ) \ No newline at end of file diff --git a/src/parametricbuilder/ParametricBuilder.jl b/src/parametricbuilder/ParametricBuilder.jl index 909cd5ba..1f98cf13 100644 --- a/src/parametricbuilder/ParametricBuilder.jl +++ b/src/parametricbuilder/ParametricBuilder.jl @@ -1,61 +1,61 @@ -module ParametricBuilder - -# Export public API -export make_stranded, make_screened -export Conductor, Insulator, Material, CableBuilder -export at, Earth, SystemBuilder - -# Module-specific dependencies -using ..Commons -import ..Commons: add! -using ..Materials: Materials -using ..DataModel: DataModel -using ..EarthProps: EarthModel -using ..Engine: LineParametersProblem -using Measurements -using Base.Iterators: product - -# normalize input to (spec, pct) -_spec(x) = (x isa Tuple && length(x)==2) ? x : (x, nothing) - - -_values(x::Number) = (x,) -_values(v::AbstractVector) = collect(v) -_values(t::Tuple{<:Number, <:Number, <:Integer}) = range(t[1], t[2]; length = t[3]) - -_pcts(::Nothing) = (0.0,) -_pcts(p::Number) = (float(p),) -_pcts(v::AbstractVector) = map(float, collect(v)) -_pcts(t::Tuple{<:Number, <:Number, <:Integer}) = - range(float(t[1]), float(t[2]); length = t[3]) - -function _make_range(spec; pct = nothing) - vs, ps = collect(_values(spec)), collect(_pcts(pct)) - if all(p->p==0.0, ps) - ; - return vs; - end - out = Any[] - for v in vs, p in ps - push!(out, measurement(v, abs(v)*(p/100))) - end - out -end - -# expand positional args tuple → iterator of resolved tuples -function _expand_args(args::Tuple) - spaces = - map(a -> (a isa Tuple && length(a)==2 ? _make_range(a[1]; pct = a[2]) : (a,)), args) - return (tuple(vals...) for vals in Iterators.product(spaces...)) -end - -include("materialspec.jl") -include("cablebuilderspec.jl") -include("systembuilderspec.jl") -include("base.jl") - -# Submodule `WirePatterns` -include("wirepatterns/WirePatterns.jl") -using .WirePatterns - -end # module ParametricBuilder +module ParametricBuilder + +# Export public API +export make_stranded, make_screened +export Conductor, Insulator, Material, CableBuilder +export at, Earth, SystemBuilder + +# Module-specific dependencies +using ..Commons +import ..Commons: add! +using ..Materials: Materials +using ..DataModel: DataModel +using ..EarthProps: EarthModel +using ..Engine: LineParametersProblem +using Measurements +using Base.Iterators: product + +# normalize input to (spec, pct) +_spec(x) = (x isa Tuple && length(x)==2) ? x : (x, nothing) + + +_values(x::Number) = (x,) +_values(v::AbstractVector) = collect(v) +_values(t::Tuple{<:Number, <:Number, <:Integer}) = range(t[1], t[2]; length = t[3]) + +_pcts(::Nothing) = (0.0,) +_pcts(p::Number) = (float(p),) +_pcts(v::AbstractVector) = map(float, collect(v)) +_pcts(t::Tuple{<:Number, <:Number, <:Integer}) = + range(float(t[1]), float(t[2]); length = t[3]) + +function _make_range(spec; pct = nothing) + vs, ps = collect(_values(spec)), collect(_pcts(pct)) + if all(p->p==0.0, ps) + ; + return vs; + end + out = Any[] + for v in vs, p in ps + push!(out, measurement(v, abs(v)*(p/100))) + end + out +end + +# expand positional args tuple → iterator of resolved tuples +function _expand_args(args::Tuple) + spaces = + map(a -> (a isa Tuple && length(a)==2 ? _make_range(a[1]; pct = a[2]) : (a,)), args) + return (tuple(vals...) for vals in Iterators.product(spaces...)) +end + +include("materialspec.jl") +include("cablebuilderspec.jl") +include("systembuilderspec.jl") +include("base.jl") + +# Submodule `WirePatterns` +include("wirepatterns/WirePatterns.jl") +using .WirePatterns + +end # module ParametricBuilder diff --git a/src/parametricbuilder/base.jl b/src/parametricbuilder/base.jl index ef2a0bc7..a077c20e 100644 --- a/src/parametricbuilder/base.jl +++ b/src/parametricbuilder/base.jl @@ -1,289 +1,290 @@ -Base.IteratorEltype(::Type{CableBuilderSpec}) = Base.HasEltype() -Base.eltype(::Type{CableBuilderSpec}) = DataModel.CableDesign -Base.IteratorSize(::Type{CableBuilderSpec}) = Base.SizeUnknown() - -function Base.iterate(cbs::CableBuilderSpec) - ch = iterate_designs(cbs) - try - d = take!(ch) - return (d, ch) - catch - return nothing - end -end - -function Base.iterate(::CableBuilderSpec, ch::Channel) - try - d = take!(ch) - return (d, ch) - catch - return nothing - end -end - - -# how many choices are in a "range-like" thing -_choice_count(x) = - x === nothing ? 1 : - (x isa Tuple && length(x) == 2) ? _choice_count(x[1]) * _choice_count(x[2]) : - (x isa AbstractVector) ? length(x) : - (x isa Tuple && length(x) == 3) ? last(x) : 1 - -# count choices for a MaterialSpec (rho/eps/mu/T/α product) -_choice_count(ms::MaterialSpec) = length(_make_range(ms)) - -# args: each entry can be scalar | vector | (lo,hi,n) | (value_spec, pct_spec) -_arg_choice_count(a) = - (a isa Tuple && length(a) == 2) ? (_choice_count(a[1]) * _choice_count(a[2])) : - _choice_count(a) - -_args_choice_count(args::Tuple) = - isempty(args) ? 1 : prod(_arg_choice_count(a) for a in args) - -function cardinality(cbs::CableBuilderSpec) - comp_names = unique(p.component for p in cbs.parts) - by_comp = Dict{Symbol, Vector{PartSpec}}() - for p in cbs.parts - get!(by_comp, p.component, PartSpec[]) |> v -> push!(v, p) - end - - total = 1 - for cname in comp_names - ps = by_comp[cname] - cond = [p for p in ps if p.part_type <: DataModel.AbstractConductorPart] - insu = [p for p in ps if p.part_type <: DataModel.AbstractInsulatorPart] - isempty(cond) && error("component '$cname' has no conductors") - isempty(insu) && error("component '$cname' has no insulators") - - # first conductor axes - p1c = cond[1] - c_dim = _choice_count(p1c.dim[1]) * _choice_count(p1c.dim[2]) - c_args = _args_choice_count(p1c.args) - c_mat = _choice_count(p1c.material) - - # uncoupled extras from later conductors (couple when tuples compare equal) - for pc in cond[2:end] - pc_dim_same = (pc.dim == p1c.dim) - pc_args_same = (pc.args == p1c.args) - pc_mat_same = (pc.material == p1c.material) - - c_dim *= pc_dim_same ? 1 : (_choice_count(pc.dim[1]) * _choice_count(pc.dim[2])) - c_args *= pc_args_same ? 1 : _args_choice_count(pc.args) - c_mat *= pc_mat_same ? 1 : _choice_count(pc.material) - end - cond_factor = c_dim * c_args * c_mat - - # first insulator axes - p1i = insu[1] - i_dim = _choice_count(p1i.dim[1]) * _choice_count(p1i.dim[2]) - i_args = _args_choice_count(p1i.args) - i_mat = _choice_count(p1i.material) - - for pi in insu[2:end] - pi_dim_same = (pi.dim == p1i.dim) - pi_args_same = (pi.args == p1i.args) - pi_mat_same = (pi.material == p1i.material) - - i_dim *= pi_dim_same ? 1 : (_choice_count(pi.dim[1]) * _choice_count(pi.dim[2])) - i_args *= pi_args_same ? 1 : _args_choice_count(pi.args) - i_mat *= pi_mat_same ? 1 : _choice_count(pi.material) - end - insu_factor = i_dim * i_args * i_mat - - total *= cond_factor * insu_factor - end - return total -end - -Base.length(cbs::CableBuilderSpec) = cardinality(cbs) - -function Base.show(io::IO, ::MIME"text/plain", cbs::CableBuilderSpec) - comp_names = unique(p.component for p in cbs.parts) - by_comp = Dict{Symbol, Vector{PartSpec}}() - for p in cbs.parts - get!(by_comp, p.component, PartSpec[]) |> v -> push!(v, p) - end - - println(io, "CableBuilderSpec(\"", cbs.cable_id, "\")") - println(io, " components: ", join(string.(comp_names), ", ")) - - total = 1 - for cname in comp_names - ps = by_comp[cname] - cond = [p for p in ps if p.part_type <: DataModel.AbstractConductorPart] - insu = [p for p in ps if p.part_type <: DataModel.AbstractInsulatorPart] - isempty(cond) && error("component '$cname' has no conductors") - isempty(insu) && error("component '$cname' has no insulators") - - # conductors: couple to first when tuples compare equal - p1c = cond[1] - c_dim = _choice_count(p1c.dim[1]) * _choice_count(p1c.dim[2]) - c_args = _args_choice_count(p1c.args) - c_mat = _choice_count(p1c.material) - for pc in cond[2:end] - c_dim *= (pc.dim == p1c.dim) ? 1 : (_choice_count(pc.dim[1]) * _choice_count(pc.dim[2])) - c_args *= (pc.args == p1c.args) ? 1 : _args_choice_count(pc.args) - c_mat *= (pc.material == p1c.material) ? 1 : _choice_count(pc.material) - end - cond_factor = c_dim * c_args * c_mat - - # insulators: same coupling rule vs first insulator - p1i = insu[1] - i_dim = _choice_count(p1i.dim[1]) * _choice_count(p1i.dim[2]) - i_args = _args_choice_count(p1i.args) - i_mat = _choice_count(p1i.material) - for pi in insu[2:end] - i_dim *= (pi.dim == p1i.dim) ? 1 : (_choice_count(pi.dim[1]) * _choice_count(pi.dim[2])) - i_args *= (pi.args == p1i.args) ? 1 : _args_choice_count(pi.args) - i_mat *= (pi.material == p1i.material) ? 1 : _choice_count(pi.material) - end - insu_factor = i_dim * i_args * i_mat - - fac = cond_factor * insu_factor - total *= fac - print(io, " • ", cname, ": ") - print(io, "cond(dim=", c_dim, ", args=", c_args, ", mat=", c_mat, "); ") - println(io, "insu(dim=", i_dim, ", args=", i_args, ", mat=", i_mat, ") ⇒ ×", fac) - end - - println(io, " cardinality: ", total) - if cbs.nominal !== nothing - println(io, " nominal: ", typeof(cbs.nominal)) - end -end - - -function show_trace(tr::DesignTrace) - println("Design: ", tr.cable_id) - for comp in tr.components - println(" Component: ", comp.name) - for c in comp.choices - mat = c.mat - println(" [", c.role, "] ", c.T, - " layers=", c.layers, - " dim=", c.dim, - " args=", c.args, - " ρ=", mat.rho, " εr=", mat.eps_r, " μr=", mat.mu_r) - end - end -end - -Base.show(io::IO, ::MIME"text/plain", tr::DesignTrace) = show_trace(tr) - -# == Choice-count helpers (reusing ParametricBuilder counting) == -_position_choice_count(p::_Pos) = _choice_count(p.dx) * _choice_count(p.dy) - -_earth_choice_count(e::EarthSpec) = - _choice_count(e.rho) * _choice_count(e.eps_r) * _choice_count(e.mu_r) * - _choice_count(e.t) - -# == Public cardinality API == -function cardinality(spec::SystemBuilderSpec) - pos_prod = - isempty(spec.positions) ? 0 : - prod(_position_choice_count(p) for p in spec.positions) - pos_prod == 0 && return 0 - return cardinality(spec.builder) * - _choice_count(spec.length) * - pos_prod * - _choice_count(spec.temperature) * - _earth_choice_count(spec.earth) -end - -Base.length(spec::SystemBuilderSpec) = cardinality(spec) - -# == Iterator over fully formed LineParametersProblem (skips overlaps silently) == -function Base.iterate(spec::SystemBuilderSpec) - ch = iterate_problems(spec) - try - x = take!(ch); - return (x, ch) - catch - @error "SystemBuilderSpec iteration failed before first yield" exception=( - e, - catch_backtrace(), - ) - - rethrow() - end -end - -function Base.iterate(::SystemBuilderSpec, ch::Channel{LineParametersProblem}) - try - x = take!(ch); - return (x, ch) - catch - return nothing - end -end - -Base.IteratorEltype(::Type{SystemBuilderSpec}) = Base.HasEltype() -Base.eltype(::Type{SystemBuilderSpec}) = LineParametersProblem -Base.IteratorSize(::Type{SystemBuilderSpec}) = Base.SizeUnknown() - -# == Terse pretty printer (because why the hell not?) == -# show at most `limit` values: "v1, v2, ..., vN (N=total)" -_fmt_vals(vals; limit = 8) = begin - v = collect(vals) - n = length(v) - if n == 0 - "∅" - elseif n <= limit - string(join(v, ", ")) - else - string(join(v[1:limit], ", "), ", … (N=", n, ")") - end -end - -# expand one knob (your (valuespec,pct) grammar) into concrete values -_vals_pair(p) = collect(_expand_pair(p)) # :contentReference[oaicite:2]{index=2} -# axis around anchor (handles (nothing, pct) → uncertain anchor) -_vals_axis(anchor, dspec) = collect(_axis(anchor, dspec)) # :contentReference[oaicite:3]{index=3} - -# deterministic freq summary: list if tiny, else min..max (N) -_fmt_freqs(f::AbstractVector) = - length(f) ≤ 8 ? join(f, ", ") : - string(first(f), " … ", last(f), " (N=", length(f), ")") - -# stable, human order for phases: core,sheath,jacket first if present, then alphabetical -function _fmt_map(conn::Dict{String, Int}) - prio = Dict("core"=>1, "sheath"=>2, "jacket"=>3) - ks = collect(keys(conn)) - sort!(ks, by = k -> (get(prio, k, 1000), k)) - # FIX: use getindex, not get - vs = getindex.(Ref(conn), ks) # or: map(k -> conn[k], ks) - return join(string.(ks, "=>", vs), ", ") -end - -function Base.show(io::IO, ::MIME"text/plain", spec::SystemBuilderSpec) - println(io, "SystemBuilder(\"", spec.system_id, "\")") - println(io, " designs × = ", cardinality(spec.builder)) # we don’t explode details here; PB has its own show. :contentReference[oaicite:4]{index=4} - - # positions block - println(io, " positions = ", length(spec.positions)) - for (i, p) in enumerate(spec.positions) - dxvals = _vals_axis(p.x0, p.dx) - dyvals = _vals_axis(p.y0, p.dy) - println(io, " • p", i, " x: ", _fmt_vals(dxvals), ", y: ", _fmt_vals(dyvals), - ", phases: {", _fmt_map(p.conn), "}") - end - - # system scalars - println(io, " length = ", _fmt_vals(_vals_pair(spec.length))) - println(io, " temp = ", _fmt_vals(_vals_pair(spec.temperature))) - - # earth knobs (each axis separately) - println(io, " earth:") - println(io, " ρ = ", _fmt_vals(_vals_pair(spec.earth.rho))) - println(io, " εr = ", _fmt_vals(_vals_pair(spec.earth.eps_r))) - println(io, " μr = ", _fmt_vals(_vals_pair(spec.earth.mu_r))) - println(io, " t = ", _fmt_vals(_vals_pair(spec.earth.t))) - - # frequencies (deterministic vector coming from the user/spec) - if hasfield(SystemBuilderSpec, :frequencies) && !isempty(getfield(spec, :frequencies)) - f = getfield(spec, :frequencies) - println(io, " f = ", _fmt_freqs(f)) - end - - println(io, " cardinality (upper bound): ", cardinality(spec)) -end +Base.IteratorEltype(::Type{CableBuilderSpec}) = Base.HasEltype() +Base.eltype(::Type{CableBuilderSpec}) = DataModel.CableDesign +Base.IteratorSize(::Type{CableBuilderSpec}) = Base.SizeUnknown() + +function Base.iterate(cbs::CableBuilderSpec) + ch = iterate_designs(cbs) + try + d = take!(ch) + return (d, ch) + catch + return nothing + end +end + +function Base.iterate(::CableBuilderSpec, ch::Channel) + try + d = take!(ch) + return (d, ch) + catch + return nothing + end +end + + +# how many choices are in a "range-like" thing +_choice_count(x) = + x === nothing ? 1 : + (x isa Tuple && length(x) == 2) ? _choice_count(x[1]) * _choice_count(x[2]) : + (x isa AbstractVector) ? length(x) : + (x isa Tuple && length(x) == 3) ? last(x) : 1 + +# count choices for a MaterialSpec (rho/eps/mu/T/α product) +_choice_count(ms::MaterialSpec) = length(_make_range(ms)) + +# args: each entry can be scalar | vector | (lo,hi,n) | (value_spec, pct_spec) +_arg_choice_count(a) = + (a isa Tuple && length(a) == 2) ? (_choice_count(a[1]) * _choice_count(a[2])) : + _choice_count(a) + +_args_choice_count(args::Tuple) = + isempty(args) ? 1 : prod(_arg_choice_count(a) for a in args) + +function cardinality(cbs::CableBuilderSpec) + comp_names = unique(p.component for p in cbs.parts) + by_comp = Dict{Symbol, Vector{PartSpec}}() + for p in cbs.parts + get!(by_comp, p.component, PartSpec[]) |> v -> push!(v, p) + end + + total = 1 + for cname in comp_names + ps = by_comp[cname] + cond = [p for p in ps if p.part_type <: DataModel.AbstractConductorPart] + insu = [p for p in ps if p.part_type <: DataModel.AbstractInsulatorPart] + isempty(cond) && error("component '$cname' has no conductors") + isempty(insu) && error("component '$cname' has no insulators") + + # first conductor axes + p1c = cond[1] + c_dim = _choice_count(p1c.dim[1]) * _choice_count(p1c.dim[2]) + c_args = _args_choice_count(p1c.args) + c_mat = _choice_count(p1c.material) + + # uncoupled extras from later conductors (couple when tuples compare equal) + for pc in cond[2:end] + pc_dim_same = (pc.dim == p1c.dim) + pc_args_same = (pc.args == p1c.args) + pc_mat_same = (pc.material == p1c.material) + + c_dim *= pc_dim_same ? 1 : (_choice_count(pc.dim[1]) * _choice_count(pc.dim[2])) + c_args *= pc_args_same ? 1 : _args_choice_count(pc.args) + c_mat *= pc_mat_same ? 1 : _choice_count(pc.material) + end + cond_factor = c_dim * c_args * c_mat + + # first insulator axes + p1i = insu[1] + i_dim = _choice_count(p1i.dim[1]) * _choice_count(p1i.dim[2]) + i_args = _args_choice_count(p1i.args) + i_mat = _choice_count(p1i.material) + + for pi in insu[2:end] + pi_dim_same = (pi.dim == p1i.dim) + pi_args_same = (pi.args == p1i.args) + pi_mat_same = (pi.material == p1i.material) + + i_dim *= pi_dim_same ? 1 : (_choice_count(pi.dim[1]) * _choice_count(pi.dim[2])) + i_args *= pi_args_same ? 1 : _args_choice_count(pi.args) + i_mat *= pi_mat_same ? 1 : _choice_count(pi.material) + end + insu_factor = i_dim * i_args * i_mat + + total *= cond_factor * insu_factor + end + return total +end + +Base.length(cbs::CableBuilderSpec) = cardinality(cbs) + +function Base.show(io::IO, ::MIME"text/plain", cbs::CableBuilderSpec) + comp_names = unique(p.component for p in cbs.parts) + by_comp = Dict{Symbol, Vector{PartSpec}}() + for p in cbs.parts + get!(by_comp, p.component, PartSpec[]) |> v -> push!(v, p) + end + + println(io, "CableBuilderSpec(\"", cbs.cable_id, "\")") + println(io, " components: ", join(string.(comp_names), ", ")) + + total = 1 + for cname in comp_names + ps = by_comp[cname] + cond = [p for p in ps if p.part_type <: DataModel.AbstractConductorPart] + insu = [p for p in ps if p.part_type <: DataModel.AbstractInsulatorPart] + isempty(cond) && error("component '$cname' has no conductors") + isempty(insu) && error("component '$cname' has no insulators") + + # conductors: couple to first when tuples compare equal + p1c = cond[1] + c_dim = _choice_count(p1c.dim[1]) * _choice_count(p1c.dim[2]) + c_args = _args_choice_count(p1c.args) + c_mat = _choice_count(p1c.material) + for pc in cond[2:end] + c_dim *= (pc.dim == p1c.dim) ? 1 : (_choice_count(pc.dim[1]) * _choice_count(pc.dim[2])) + c_args *= (pc.args == p1c.args) ? 1 : _args_choice_count(pc.args) + c_mat *= (pc.material == p1c.material) ? 1 : _choice_count(pc.material) + end + cond_factor = c_dim * c_args * c_mat + + # insulators: same coupling rule vs first insulator + p1i = insu[1] + i_dim = _choice_count(p1i.dim[1]) * _choice_count(p1i.dim[2]) + i_args = _args_choice_count(p1i.args) + i_mat = _choice_count(p1i.material) + for pi in insu[2:end] + i_dim *= (pi.dim == p1i.dim) ? 1 : (_choice_count(pi.dim[1]) * _choice_count(pi.dim[2])) + i_args *= (pi.args == p1i.args) ? 1 : _args_choice_count(pi.args) + i_mat *= (pi.material == p1i.material) ? 1 : _choice_count(pi.material) + end + insu_factor = i_dim * i_args * i_mat + + fac = cond_factor * insu_factor + total *= fac + print(io, " • ", cname, ": ") + print(io, "cond(dim=", c_dim, ", args=", c_args, ", mat=", c_mat, "); ") + println(io, "insu(dim=", i_dim, ", args=", i_args, ", mat=", i_mat, ") ⇒ ×", fac) + end + + println(io, " cardinality: ", total) + if cbs.nominal !== nothing + println(io, " nominal: ", typeof(cbs.nominal)) + end +end + + +function show_trace(tr::DesignTrace) + println("Design: ", tr.cable_id) + for comp in tr.components + println(" Component: ", comp.name) + for c in comp.choices + mat = c.mat + println(" [", c.role, "] ", c.T, + " layers=", c.layers, + " dim=", c.dim, + " args=", c.args, + " ρ=", mat.rho, " εr=", mat.eps_r, " μr=", mat.mu_r) + end + end +end + +Base.show(io::IO, ::MIME"text/plain", tr::DesignTrace) = show_trace(tr) + +# == Choice-count helpers (reusing ParametricBuilder counting) == +_position_choice_count(p::_Pos) = _choice_count(p.dx) * _choice_count(p.dy) + +_earth_choice_count(e::EarthSpec) = + _choice_count(e.rho) * _choice_count(e.eps_r) * _choice_count(e.mu_r) * + _choice_count(e.t) + +# == Public cardinality API == +function cardinality(spec::SystemBuilderSpec) + pos_prod = + isempty(spec.positions) ? 0 : + prod(_position_choice_count(p) for p in spec.positions) + pos_prod == 0 && return 0 + return cardinality(spec.builder) * + _choice_count(spec.length) * + pos_prod * + _choice_count(spec.temperature) * + _earth_choice_count(spec.earth) +end + +Base.length(spec::SystemBuilderSpec) = cardinality(spec) + +# == Iterator over fully formed LineParametersProblem (skips overlaps silently) == +function Base.iterate(spec::SystemBuilderSpec) + ch = iterate_problems(spec) + try + x = take!(ch); + return (x, ch) + catch + @error "SystemBuilderSpec iteration failed before first yield" exception=( + e, + catch_backtrace(), + ) + + rethrow() + end +end + +function Base.iterate(::SystemBuilderSpec, ch::Channel{LineParametersProblem}) + try + x = take!(ch); + return (x, ch) + catch + return nothing + end +end + +Base.IteratorEltype(::Type{SystemBuilderSpec}) = Base.HasEltype() +Base.eltype(::Type{SystemBuilderSpec}) = LineParametersProblem +Base.IteratorSize(::Type{SystemBuilderSpec}) = Base.SizeUnknown() + +# == Terse pretty printer (because why the hell not?) == +# show at most `limit` values: "v1, v2, ..., vN (N=total)" +_fmt_vals(vals; limit = 8) = begin + v = collect(vals) + n = length(v) + if n == 0 + "∅" + elseif n <= limit + string(join(v, ", ")) + else + string(join(v[1:limit], ", "), ", … (N=", n, ")") + end +end + +# expand one knob (your (valuespec,pct) grammar) into concrete values +_vals_pair(p) = collect(_expand_pair(p)) # :contentReference[oaicite:2]{index=2} +# axis around anchor (handles (nothing, pct) → uncertain anchor) +_vals_axis(anchor, dspec) = collect(_axis(anchor, dspec)) # :contentReference[oaicite:3]{index=3} + +# deterministic freq summary: list if tiny, else min..max (N) +_fmt_freqs(f::AbstractVector) = + length(f) ≤ 8 ? join(f, ", ") : + string(first(f), " … ", last(f), " (N=", length(f), ")") + +# stable, human order for phases: core,sheath,jacket first if present, then alphabetical +function _fmt_map(conn::Dict{String, Int}) + prio = Dict("core"=>1, "sheath"=>2, "jacket"=>3) + ks = collect(keys(conn)) + sort!(ks, by = k -> (get(prio, k, 1000), k)) + # FIX: use getindex, not get + vs = getindex.(Ref(conn), ks) # or: map(k -> conn[k], ks) + return join(string.(ks, "=>", vs), ", ") +end + +function Base.show(io::IO, ::MIME"text/plain", spec::SystemBuilderSpec) + println(io, "SystemBuilder(\"", spec.system_id, "\")") + println(io, " designs × = ", cardinality(spec.builder)) # we don’t explode details here; PB has its own show. :contentReference[oaicite:4]{index=4} + + # positions block + println(io, " positions = ", length(spec.positions)) + for (i, p) in enumerate(spec.positions) + dxvals = _vals_axis(p.x0, p.dx) + dyvals = _vals_axis(p.y0, p.dy) + println(io, " • p", i, " x: ", _fmt_vals(dxvals), ", y: ", _fmt_vals(dyvals), + ", phases: {", _fmt_map(p.conn), "}") + end + + # system scalars + println(io, " length = ", _fmt_vals(_vals_pair(spec.length))) + println(io, " temp = ", _fmt_vals(_vals_pair(spec.temperature))) + + # earth knobs (each axis separately) + println(io, " earth:") + println(io, " ρ = ", _fmt_vals(_vals_pair(spec.earth.rho))) + println(io, " εr = ", _fmt_vals(_vals_pair(spec.earth.eps_r))) + println(io, " μr = ", _fmt_vals(_vals_pair(spec.earth.mu_r))) + println(io, " κr = ", _fmt_vals(_vals_pair(spec.earth.kappa_r))) + println(io, " t = ", _fmt_vals(_vals_pair(spec.earth.t))) + + # frequencies (deterministic vector coming from the user/spec) + if hasfield(SystemBuilderSpec, :frequencies) && !isempty(getfield(spec, :frequencies)) + f = getfield(spec, :frequencies) + println(io, " f = ", _fmt_freqs(f)) + end + + println(io, " cardinality (upper bound): ", cardinality(spec)) +end diff --git a/src/parametricbuilder/cablebuilderspec.jl b/src/parametricbuilder/cablebuilderspec.jl index 6677664c..45cfaaf5 100644 --- a/src/parametricbuilder/cablebuilderspec.jl +++ b/src/parametricbuilder/cablebuilderspec.jl @@ -1,519 +1,519 @@ -# spec: (value_spec, pct_spec) — pct_spec can be `nothing | number | vector | (lo,hi,n)` -""" -PartSpec: -- component::Symbol # e.g. :core, :sheath, :jacket -- part_type::Type # WireArray, Tubular, Strip, Insulator, Semicon, … -- n_layers::Int # how many stacked layers of this part_type -- dim::Tuple # diameter OR thickness OR radius (spec, pct) -- args::Tuple # specialized ctor positional args -- material::MaterialSpec -""" -struct PartSpec - component::Symbol - part_type::Type - n_layers::Int - dim::Tuple # (spec, pct) - args::Tuple # positional args; each entry is either a number or (spec, pct) - material::MaterialSpec -end - -PartSpec(component::Symbol, part_type::Type, n_layers::Int; - dim, args = (), material::MaterialSpec) = - PartSpec(component, part_type, n_layers, dim, args, material) - -""" -CableBuilderSpec: -- cable_id::String -- parts::Vector{PartSpec} # may interleave conductor/insulator arbitrarily -- nominal::Union{Nothing,DataModel.NominalData} -""" -struct CableBuilderSpec - cable_id::String - parts::Vector{PartSpec} - nominal::Union{Nothing, DataModel.NominalData} -end -CableBuilder(id::AbstractString, parts::Vector{PartSpec}; nominal = nothing) = - CableBuilderSpec(String(id), parts, nominal) - -# --- minimal flattening helpers (accept PartSpec or collections of them) ----- -function _collect_parts!(acc::Vector{PartSpec}, x) - if x isa PartSpec - push!(acc, x) - elseif x isa AbstractVector - @inbounds for y in x - _collect_parts!(acc, y) - end - else - error("Expected PartSpec or a collection of PartSpec; got $(typeof(x))") - end - return acc -end - -# ctor that accepts a vector with possible nested vectors (no splat needed) -function CableBuilder(id::AbstractString, parts_any::AbstractVector; nominal = nothing) - acc = PartSpec[] - _collect_parts!(acc, parts_any) - return CableBuilderSpec(String(id), acc, nominal) # calls your primary ctor -end - -# ctor that accepts varargs (mixed PartSpec and vectors), plus nominal kw -function CableBuilder(id::AbstractString, parts...; nominal = nothing) - acc = PartSpec[] - @inbounds for p in parts - _collect_parts!(acc, p) - end - return CableBuilderSpec(String(id), acc, nominal) -end - -struct PartChoice - idx::Int # index in ps vector (1-based) - role::Symbol # :conductor or :insulator - T::Type - dim::Any # chosen scalar (Diameter/Thickness proxy input) - args::Tuple # chosen positional args (scalars) - mat::Materials.Material # concrete material used - layers::Int # n_layers replicated with that choice -end - -struct ComponentTrace - name::String - choices::Vector{PartChoice} -end - -struct DesignTrace - cable_id::String - components::Vector{ComponentTrace} -end - - - -# ----- anchor: last physical layer, not the container ----- -@inline _anchor(x::Real) = x -@inline _anchor(x::DataModel.AbstractConductorPart) = x -@inline _anchor(x::DataModel.AbstractInsulatorPart) = x - -@inline function _anchor(g::DataModel.ConductorGroup) - L = getfield(g, :layers) - @assert !isempty(L) "ConductorGroup has no layers to anchor on." - return L[end] -end -@inline function _anchor(g::DataModel.InsulatorGroup) - L = getfield(g, :layers) - @assert !isempty(L) "InsulatorGroup has no layers to anchor on." - return L[end] -end - -# ----- proxy for radius_ext by CONTRACT ----- -@inline function _resolve_dim(T::Type, is_abs_first::Bool) - return T <: DataModel.AbstractWireArray ? :diameter : - (is_abs_first && T === DataModel.Tubular ? :diameter : :thickness) -end - -@inline _make_dim(::Val{:diameter}, d) = DataModel.Diameter(d) -@inline _make_dim(::Val{:thickness}, d) = DataModel.Thickness(d) -@inline _make_dim(::Val{:radius}, r) = r # if direct radius -@inline _make_dim(sym::Symbol, d) = _make_dim(Val(sym), d) - - -function _init_cg(T::Type, base, dim_val, args_pos::Tuple, mat; abs_first::Bool) - - r_in = _anchor(base) - sym = _resolve_dim(T, abs_first) - _ = _make_dim(sym, dim_val) # keeps intent (WireArray ignores this) - - if T <: DataModel.AbstractWireArray - @assert length(args_pos) ≥ 1 "WireArray needs (n, [lay])." - n = args_pos[1] - lay = length(args_pos) ≥ 2 ? args_pos[2] : 0.0 - return DataModel.ConductorGroup( - DataModel.WireArray(r_in, DataModel.Diameter(dim_val), n, lay, mat), - ) - else - return DataModel.ConductorGroup(T(r_in, _make_dim(sym, dim_val), args_pos..., mat)) - end -end - - -function _add_conductor!( - cg::DataModel.ConductorGroup, - T::Type, - dim_val, - args_pos::Tuple, - mat; - layer::Int, -) - if T <: DataModel.AbstractWireArray - @assert length(args_pos) ≥ 1 "WireArray needs (n, [lay])." - n = args_pos[1] - lay = length(args_pos) ≥ 2 ? args_pos[2] : 0.0 - add!(cg, DataModel.WireArray, DataModel.Diameter(dim_val), layer*n, lay, mat) - else - # thickness by contract for all non-wire additions - add!(cg, T, _make_dim(:thickness, dim_val), args_pos..., mat) - end -end - - -function _init_ig(T::Type, cg::DataModel.ConductorGroup, dim_val, args_pos::Tuple, mat) - c_last = _anchor(cg) - obj = T(c_last, DataModel.Thickness(dim_val), args_pos..., mat) # insulators use THICKNESS - return DataModel.InsulatorGroup(obj) -end - - -function _add_insulator!( - ig::DataModel.InsulatorGroup, - T::Type, - dim_val, - args_pos::Tuple, - mat, -) - add!(ig, T, DataModel.Thickness(dim_val), args_pos..., mat) -end - -# Build all variants of ONE component, anchored at `base` (0.0 for the very first) -function _make_variants(ps::Vector{PartSpec}, base) - cond = [p for p in ps if p.part_type <: DataModel.AbstractConductorPart] - insu = [p for p in ps if p.part_type <: DataModel.AbstractInsulatorPart] - isempty(cond) && error("component has no conductors") - isempty(insu) && error("component has no insulators") - - variants = Tuple{DataModel.CableComponent, DataModel.InsulatorGroup, ComponentTrace}[] - - # ---------------- first conductor choice spaces ---------------- - p1c = cond[1] - mats1 = _make_range(p1c.material) - dims1 = _make_range(p1c.dim[1]; pct = p1c.dim[2]) - args1s = collect(_expand_args(p1c.args)) # Vector{<:Tuple} - - # remaining conductors — spaces, with COUPLING flags to p1c - # Tuple layout: (pc, mcs_or_nothing, dcs_or_nothing, acs_or_nothing) - rest_cond_spaces = Tuple{PartSpec, Any, Union{Nothing, Any}, Union{Nothing, Any}}[] - for pc in cond[2:end] - same_mat = (pc.material == p1c.material) - same_dim = (pc.dim == p1c.dim) - same_args = (pc.args == p1c.args) - - mcs = same_mat ? nothing : _make_range(pc.material) - dcs = same_dim ? nothing : _make_range(pc.dim[1]; pct = pc.dim[2]) - acs = same_args ? nothing : collect(_expand_args(pc.args)) - - push!(rest_cond_spaces, (pc, mcs, dcs, acs)) - end - - # ---------------- first insulator choice spaces ---------------- - p1i = insu[1] - matsi = _make_range(p1i.material) - dimsi = _make_range(p1i.dim[1]; pct = p1i.dim[2]) - args1i = collect(_expand_args(p1i.args)) - - # remaining insulators — spaces, with COUPLING flags to p1i - rest_ins_spaces = Tuple{PartSpec, Any, Union{Nothing, Any}, Union{Nothing, Any}}[] - for pi in insu[2:end] - same_mat = (pi.material == p1i.material) - same_dim = (pi.dim == p1i.dim) - same_args = (pi.args == p1i.args) - - m2 = same_mat ? nothing : _make_range(pi.material) - d2 = same_dim ? nothing : _make_range(pi.dim[1]; pct = pi.dim[2]) - a2 = same_args ? nothing : collect(_expand_args(pi.args)) - - push!(rest_ins_spaces, (pi, m2, d2, a2)) - end - - # ---------------- selection stacks (resolved tuples) ------------- - # chosen_c stores (pc, mc, dc, ac) - # chosen_i stores (pi, m2i, d2i, a2i) - chosen_c = Vector{NTuple{4, Any}}() - chosen_i = Vector{NTuple{4, Any}}() - - # ---------------- build with current resolved choices ------------ - function build_with_current_selection(mat1, d1, a1, mi, di, ai) - # 1) conductors - cg = _init_cg(p1c.part_type, base, d1, a1, mat1; abs_first = base == 0.0) - for k in 2:p1c.n_layers - _add_conductor!(cg, p1c.part_type, d1, a1, mat1; layer = k) - end - for (pc, mc, dc, ac) in chosen_c - for k in 1:pc.n_layers - _add_conductor!(cg, pc.part_type, dc, ac, mc; layer = k) - end - end - - # 2) insulators - ig = _init_ig(p1i.part_type, cg, di, ai, mi) - for (pi, m2i, d2i, a2i) in chosen_i - for k in 1:pi.n_layers - _add_insulator!(ig, pi.part_type, d2i, a2i, m2i) - end - end - - # assemble trace - choices = PartChoice[] - # first conductor spec - push!(choices, PartChoice(1, :conductor, p1c.part_type, d1, a1, mat1, p1c.n_layers)) - # remaining conductors - for (j, (pc, mc, dc, ac)) in enumerate(chosen_c) - push!( - choices, - PartChoice(1 + j, :conductor, pc.part_type, dc, ac, mc, pc.n_layers), - ) - end - # first insulator spec - push!( - choices, - PartChoice( - length(choices)+1, - :insulator, - p1i.part_type, - di, - ai, - mi, - p1i.n_layers, - ), - ) - # remaining insulators - for (pi, m2i, d2i, a2i) in chosen_i - push!( - choices, - PartChoice( - length(choices)+1, - :insulator, - pi.part_type, - d2i, - a2i, - m2i, - pi.n_layers, - ), - ) - end - ctrace = ComponentTrace(String(ps[1].component), choices) - - push!( - variants, - (DataModel.CableComponent(String(ps[1].component), cg, ig), ig, ctrace), - ) - - # push!(variants, (DataModel.CableComponent(String(ps[1].component), cg, ig), ig)) - end - - # ---------------- enumerate insulators with coupling ------------- - function choose_ins(idx::Int, mi, di, ai, mat1, d1, a1) - if idx > length(rest_ins_spaces) - build_with_current_selection(mat1, d1, a1, mi, di, ai) - return - end - pi, m2, d2, a2 = rest_ins_spaces[idx] - - Ms = (m2 === nothing) ? (mi,) : m2 - Ds = (d2 === nothing) ? (di,) : d2 - As = (a2 === nothing) ? (ai,) : a2 - - for m2i in Ms, d2i in Ds, a2i in As - push!(chosen_i, (pi, m2i, d2i, a2i)) - choose_ins(idx + 1, mi, di, ai, mat1, d1, a1) - pop!(chosen_i) - end - end - - # ---------------- enumerate conductors with coupling ------------- - function choose_cond(idx::Int, mat1, d1, a1, mi, di, ai) - if idx > length(rest_cond_spaces) - empty!(chosen_i) - choose_ins(1, mi, di, ai, mat1, d1, a1) - return - end - pc, mcs, dcs, acs = rest_cond_spaces[idx] - - Ms = (mcs === nothing) ? (mat1,) : mcs - Ds = (dcs === nothing) ? (d1,) : dcs - As = (acs === nothing) ? (a1,) : acs - - for mc in Ms, dc in Ds, ac in As - push!(chosen_c, (pc, mc, dc, ac)) - choose_cond(idx + 1, mat1, d1, a1, mi, di, ai) - pop!(chosen_c) - end - end - - # ---------------- top-level selection loops ---------------------- - for mat1 in mats1, d1 in dims1, a1 in args1s - for mi in matsi, di in dimsi, ai in args1i - empty!(chosen_c) - empty!(chosen_i) - choose_cond(1, mat1, d1, a1, mi, di, ai) - end - end - - return variants -end - - -function build(cbs::CableBuilderSpec; trace::Bool = false) - comp_names = unique(p.component for p in cbs.parts) - by_comp = Dict{Symbol, Vector{PartSpec}}() - for p in cbs.parts - get!(by_comp, p.component, PartSpec[]) |> v -> push!(v, p) - end - - # partials: (built_components, last_ig_or_nothing) - partials = Tuple{ - Vector{DataModel.CableComponent}, - Union{Nothing, DataModel.InsulatorGroup}, - Vector{ComponentTrace}, - }[(DataModel.CableComponent[], nothing, ComponentTrace[])] - - for cname in comp_names - ps = by_comp[cname] - new_partials = Tuple{ - Vector{DataModel.CableComponent}, - Union{Nothing, DataModel.InsulatorGroup}, - Vector{ComponentTrace}, - }[] - for (built, last_ig, tr) in partials - base = last_ig === nothing ? 0.0 : last_ig - for (comp, ig, ctrace) in _make_variants(ps, base) - push!(new_partials, (vcat(built, comp), ig, [tr...; ctrace])) - end - end - partials = new_partials - end - - - - if !trace - designs = DataModel.CableDesign[] - for (comps, _) in ((x[1], x[2]) for x in partials) - des = DataModel.CableDesign(cbs.cable_id, comps[1]; nominal_data = cbs.nominal) - for k in Iterators.drop(eachindex(comps), 1) - add!(des, comps[k]) - end - push!(designs, des) - end - return designs - else - designs = DataModel.CableDesign[] - traces = DesignTrace[] - for (comps, _, ctraces) in partials - des = DataModel.CableDesign(cbs.cable_id, comps[1]; nominal_data = cbs.nominal) - for k in 2:length(comps) - ; - add!(des, comps[k]); - end - push!(designs, des) - push!(traces, DesignTrace(cbs.cable_id, ctraces)) - end - return designs, traces - end -end - -""" - iterate_designs(cbs) -> Channel{DataModel.CableDesign} - -Lazy stream of `CableDesign`s built from `CableBuilderSpec` without allocating all of them. -Works with `for d in iterate_designs(cbs)`. -""" -function iterate_designs(cbs::CableBuilderSpec) - # group by component - comp_names = unique(p.component for p in cbs.parts) - by_comp = Dict{Symbol, Vector{PartSpec}}() - for p in cbs.parts - get!(by_comp, p.component, PartSpec[]) |> v -> push!(v, p) - end - - return Channel{DataModel.CableDesign}(32) do ch - built = DataModel.CableComponent[] - lastig = Ref{Union{Nothing, DataModel.InsulatorGroup}}(nothing) - - function dfs(i::Int) - if i > length(comp_names) - des = DataModel.CableDesign( - cbs.cable_id, - built[1]; - nominal_data = cbs.nominal, - ) - for k in 2:length(built) - ; - add!(des, built[k]); - end - put!(ch, des) - return - end - cname = comp_names[i] - ps = by_comp[cname] - base = (lastig[] === nothing) ? 0.0 : lastig[] - - for (comp, ig) in _make_variants(ps, base) - push!(built, comp) - prev = lastig[]; - lastig[] = ig - dfs(i + 1) - lastig[] = prev - pop!(built) - end - end - - dfs(1) - end -end - -module Conductor - -using ..ParametricBuilder: PartSpec, _spec -using ...DataModel: DataModel - -# wire: args are (n, lay) -Wires(component::Symbol; layers::Int, d, n::Int, lay = 11.0, mat) = - PartSpec(component, DataModel.WireArray, layers; - dim = _spec(d), args = (n, _spec(lay)), material = mat) - -# tube: no extra args -Tubular(component::Symbol; layers::Int, t, mat) = - PartSpec(component, DataModel.Tubular, layers; - dim = _spec(t), args = (), material = mat) - -# strip: args are (width, lay) -Strip(component::Symbol; layers::Int, t, w, lay = 0.0, mat) = - PartSpec(component, DataModel.Strip, layers; - dim = _spec(t), args = (_spec(w), _spec(lay)), material = mat) - -# central + hex rings sugar -function Stranded(component::Symbol; layers::Int, d, n::Int, lay = 11.0, mat) - @assert layers >= 1 "stranded: layers must be ≥ 1 (includes the central wire)." - specs = PartSpec[] - dspec = _spec(d) - - # 1) central wire: 1 layer, n=1, lay=0.0 - push!( - specs, - PartSpec(component, DataModel.WireArray, 1; - dim = dspec, args = (1, (0.0, nothing)), material = mat), - ) - - # 2) rings: (layers-1) layers, base n, common lay - if layers > 1 - push!( - specs, - PartSpec(component, DataModel.WireArray, layers - 1; - dim = dspec, args = (n, _spec(lay)), material = mat), - ) - end - - return specs -end - -end - -module Insulator - -using ..ParametricBuilder: PartSpec, _spec -using ...DataModel: DataModel - -Tubular(component::Symbol; layers::Int, t, mat) = - PartSpec(component, DataModel.Insulator, layers; - dim = _spec(t), args = (), material = mat) - -Semicon(component::Symbol; layers::Int, t, mat) = - PartSpec(component, DataModel.Semicon, layers; - dim = _spec(t), args = (), material = mat) -end +# spec: (value_spec, pct_spec) — pct_spec can be `nothing | number | vector | (lo,hi,n)` +""" +PartSpec: +- component::Symbol # e.g. :core, :sheath, :jacket +- part_type::Type # WireArray, Tubular, Strip, Insulator, Semicon, … +- n_layers::Int # how many stacked layers of this part_type +- dim::Tuple # diameter OR thickness OR radius (spec, pct) +- args::Tuple # specialized ctor positional args +- material::MaterialSpec +""" +struct PartSpec + component::Symbol + part_type::Type + n_layers::Int + dim::Tuple # (spec, pct) + args::Tuple # positional args; each entry is either a number or (spec, pct) + material::MaterialSpec +end + +PartSpec(component::Symbol, part_type::Type, n_layers::Int; + dim, args = (), material::MaterialSpec) = + PartSpec(component, part_type, n_layers, dim, args, material) + +""" +CableBuilderSpec: +- cable_id::String +- parts::Vector{PartSpec} # may interleave conductor/insulator arbitrarily +- nominal::Union{Nothing,DataModel.NominalData} +""" +struct CableBuilderSpec + cable_id::String + parts::Vector{PartSpec} + nominal::Union{Nothing, DataModel.NominalData} +end +CableBuilder(id::AbstractString, parts::Vector{PartSpec}; nominal = nothing) = + CableBuilderSpec(String(id), parts, nominal) + +# --- minimal flattening helpers (accept PartSpec or collections of them) ----- +function _collect_parts!(acc::Vector{PartSpec}, x) + if x isa PartSpec + push!(acc, x) + elseif x isa AbstractVector + @inbounds for y in x + _collect_parts!(acc, y) + end + else + error("Expected PartSpec or a collection of PartSpec; got $(typeof(x))") + end + return acc +end + +# ctor that accepts a vector with possible nested vectors (no splat needed) +function CableBuilder(id::AbstractString, parts_any::AbstractVector; nominal = nothing) + acc = PartSpec[] + _collect_parts!(acc, parts_any) + return CableBuilderSpec(String(id), acc, nominal) # calls your primary ctor +end + +# ctor that accepts varargs (mixed PartSpec and vectors), plus nominal kw +function CableBuilder(id::AbstractString, parts...; nominal = nothing) + acc = PartSpec[] + @inbounds for p in parts + _collect_parts!(acc, p) + end + return CableBuilderSpec(String(id), acc, nominal) +end + +struct PartChoice + idx::Int # index in ps vector (1-based) + role::Symbol # :conductor or :insulator + T::Type + dim::Any # chosen scalar (Diameter/Thickness proxy input) + args::Tuple # chosen positional args (scalars) + mat::Materials.Material # concrete material used + layers::Int # n_layers replicated with that choice +end + +struct ComponentTrace + name::String + choices::Vector{PartChoice} +end + +struct DesignTrace + cable_id::String + components::Vector{ComponentTrace} +end + + + +# ----- anchor: last physical layer, not the container ----- +@inline _anchor(x::Real) = x +@inline _anchor(x::DataModel.AbstractConductorPart) = x +@inline _anchor(x::DataModel.AbstractInsulatorPart) = x + +@inline function _anchor(g::DataModel.ConductorGroup) + L = getfield(g, :layers) + @assert !isempty(L) "ConductorGroup has no layers to anchor on." + return L[end] +end +@inline function _anchor(g::DataModel.InsulatorGroup) + L = getfield(g, :layers) + @assert !isempty(L) "InsulatorGroup has no layers to anchor on." + return L[end] +end + +# ----- proxy for radius_ext by CONTRACT ----- +@inline function _resolve_dim(T::Type, is_abs_first::Bool) + return T <: DataModel.AbstractWireArray ? :diameter : + (is_abs_first && T === DataModel.Tubular ? :diameter : :thickness) +end + +@inline _make_dim(::Val{:diameter}, d) = DataModel.Diameter(d) +@inline _make_dim(::Val{:thickness}, d) = DataModel.Thickness(d) +@inline _make_dim(::Val{:radius}, r) = r # if direct radius +@inline _make_dim(sym::Symbol, d) = _make_dim(Val(sym), d) + + +function _init_cg(T::Type, base, dim_val, args_pos::Tuple, mat; abs_first::Bool) + + r_in = _anchor(base) + sym = _resolve_dim(T, abs_first) + _ = _make_dim(sym, dim_val) # keeps intent (WireArray ignores this) + + if T <: DataModel.AbstractWireArray + @assert length(args_pos) ≥ 1 "WireArray needs (n, [lay])." + n = args_pos[1] + lay = length(args_pos) ≥ 2 ? args_pos[2] : 0.0 + return DataModel.ConductorGroup( + DataModel.WireArray(r_in, DataModel.Diameter(dim_val), n, lay, mat), + ) + else + return DataModel.ConductorGroup(T(r_in, _make_dim(sym, dim_val), args_pos..., mat)) + end +end + + +function _add_conductor!( + cg::DataModel.ConductorGroup, + T::Type, + dim_val, + args_pos::Tuple, + mat; + layer::Int, +) + if T <: DataModel.AbstractWireArray + @assert length(args_pos) ≥ 1 "WireArray needs (n, [lay])." + n = args_pos[1] + lay = length(args_pos) ≥ 2 ? args_pos[2] : 0.0 + add!(cg, DataModel.WireArray, DataModel.Diameter(dim_val), layer*n, lay, mat) + else + # thickness by contract for all non-wire additions + add!(cg, T, _make_dim(:thickness, dim_val), args_pos..., mat) + end +end + + +function _init_ig(T::Type, cg::DataModel.ConductorGroup, dim_val, args_pos::Tuple, mat) + c_last = _anchor(cg) + obj = T(c_last, DataModel.Thickness(dim_val), args_pos..., mat) # insulators use THICKNESS + return DataModel.InsulatorGroup(obj) +end + + +function _add_insulator!( + ig::DataModel.InsulatorGroup, + T::Type, + dim_val, + args_pos::Tuple, + mat, +) + add!(ig, T, DataModel.Thickness(dim_val), args_pos..., mat) +end + +# Build all variants of ONE component, anchored at `base` (0.0 for the very first) +function _make_variants(ps::Vector{PartSpec}, base) + cond = [p for p in ps if p.part_type <: DataModel.AbstractConductorPart] + insu = [p for p in ps if p.part_type <: DataModel.AbstractInsulatorPart] + isempty(cond) && error("component has no conductors") + isempty(insu) && error("component has no insulators") + + variants = Tuple{DataModel.CableComponent, DataModel.InsulatorGroup, ComponentTrace}[] + + # ---------------- first conductor choice spaces ---------------- + p1c = cond[1] + mats1 = _make_range(p1c.material) + dims1 = _make_range(p1c.dim[1]; pct = p1c.dim[2]) + args1s = collect(_expand_args(p1c.args)) # Vector{<:Tuple} + + # remaining conductors — spaces, with COUPLING flags to p1c + # Tuple layout: (pc, mcs_or_nothing, dcs_or_nothing, acs_or_nothing) + rest_cond_spaces = Tuple{PartSpec, Any, Union{Nothing, Any}, Union{Nothing, Any}}[] + for pc in cond[2:end] + same_mat = (pc.material == p1c.material) + same_dim = (pc.dim == p1c.dim) + same_args = (pc.args == p1c.args) + + mcs = same_mat ? nothing : _make_range(pc.material) + dcs = same_dim ? nothing : _make_range(pc.dim[1]; pct = pc.dim[2]) + acs = same_args ? nothing : collect(_expand_args(pc.args)) + + push!(rest_cond_spaces, (pc, mcs, dcs, acs)) + end + + # ---------------- first insulator choice spaces ---------------- + p1i = insu[1] + matsi = _make_range(p1i.material) + dimsi = _make_range(p1i.dim[1]; pct = p1i.dim[2]) + args1i = collect(_expand_args(p1i.args)) + + # remaining insulators — spaces, with COUPLING flags to p1i + rest_ins_spaces = Tuple{PartSpec, Any, Union{Nothing, Any}, Union{Nothing, Any}}[] + for pi in insu[2:end] + same_mat = (pi.material == p1i.material) + same_dim = (pi.dim == p1i.dim) + same_args = (pi.args == p1i.args) + + m2 = same_mat ? nothing : _make_range(pi.material) + d2 = same_dim ? nothing : _make_range(pi.dim[1]; pct = pi.dim[2]) + a2 = same_args ? nothing : collect(_expand_args(pi.args)) + + push!(rest_ins_spaces, (pi, m2, d2, a2)) + end + + # ---------------- selection stacks (resolved tuples) ------------- + # chosen_c stores (pc, mc, dc, ac) + # chosen_i stores (pi, m2i, d2i, a2i) + chosen_c = Vector{NTuple{4, Any}}() + chosen_i = Vector{NTuple{4, Any}}() + + # ---------------- build with current resolved choices ------------ + function build_with_current_selection(mat1, d1, a1, mi, di, ai) + # 1) conductors + cg = _init_cg(p1c.part_type, base, d1, a1, mat1; abs_first = base == 0.0) + for k in 2:p1c.n_layers + _add_conductor!(cg, p1c.part_type, d1, a1, mat1; layer = k) + end + for (pc, mc, dc, ac) in chosen_c + for k in 1:pc.n_layers + _add_conductor!(cg, pc.part_type, dc, ac, mc; layer = k) + end + end + + # 2) insulators + ig = _init_ig(p1i.part_type, cg, di, ai, mi) + for (pi, m2i, d2i, a2i) in chosen_i + for k in 1:pi.n_layers + _add_insulator!(ig, pi.part_type, d2i, a2i, m2i) + end + end + + # assemble trace + choices = PartChoice[] + # first conductor spec + push!(choices, PartChoice(1, :conductor, p1c.part_type, d1, a1, mat1, p1c.n_layers)) + # remaining conductors + for (j, (pc, mc, dc, ac)) in enumerate(chosen_c) + push!( + choices, + PartChoice(1 + j, :conductor, pc.part_type, dc, ac, mc, pc.n_layers), + ) + end + # first insulator spec + push!( + choices, + PartChoice( + length(choices)+1, + :insulator, + p1i.part_type, + di, + ai, + mi, + p1i.n_layers, + ), + ) + # remaining insulators + for (pi, m2i, d2i, a2i) in chosen_i + push!( + choices, + PartChoice( + length(choices)+1, + :insulator, + pi.part_type, + d2i, + a2i, + m2i, + pi.n_layers, + ), + ) + end + ctrace = ComponentTrace(String(ps[1].component), choices) + + push!( + variants, + (DataModel.CableComponent(String(ps[1].component), cg, ig), ig, ctrace), + ) + + # push!(variants, (DataModel.CableComponent(String(ps[1].component), cg, ig), ig)) + end + + # ---------------- enumerate insulators with coupling ------------- + function choose_ins(idx::Int, mi, di, ai, mat1, d1, a1) + if idx > length(rest_ins_spaces) + build_with_current_selection(mat1, d1, a1, mi, di, ai) + return + end + pi, m2, d2, a2 = rest_ins_spaces[idx] + + Ms = (m2 === nothing) ? (mi,) : m2 + Ds = (d2 === nothing) ? (di,) : d2 + As = (a2 === nothing) ? (ai,) : a2 + + for m2i in Ms, d2i in Ds, a2i in As + push!(chosen_i, (pi, m2i, d2i, a2i)) + choose_ins(idx + 1, mi, di, ai, mat1, d1, a1) + pop!(chosen_i) + end + end + + # ---------------- enumerate conductors with coupling ------------- + function choose_cond(idx::Int, mat1, d1, a1, mi, di, ai) + if idx > length(rest_cond_spaces) + empty!(chosen_i) + choose_ins(1, mi, di, ai, mat1, d1, a1) + return + end + pc, mcs, dcs, acs = rest_cond_spaces[idx] + + Ms = (mcs === nothing) ? (mat1,) : mcs + Ds = (dcs === nothing) ? (d1,) : dcs + As = (acs === nothing) ? (a1,) : acs + + for mc in Ms, dc in Ds, ac in As + push!(chosen_c, (pc, mc, dc, ac)) + choose_cond(idx + 1, mat1, d1, a1, mi, di, ai) + pop!(chosen_c) + end + end + + # ---------------- top-level selection loops ---------------------- + for mat1 in mats1, d1 in dims1, a1 in args1s + for mi in matsi, di in dimsi, ai in args1i + empty!(chosen_c) + empty!(chosen_i) + choose_cond(1, mat1, d1, a1, mi, di, ai) + end + end + + return variants +end + + +function build(cbs::CableBuilderSpec; trace::Bool = false) + comp_names = unique(p.component for p in cbs.parts) + by_comp = Dict{Symbol, Vector{PartSpec}}() + for p in cbs.parts + get!(by_comp, p.component, PartSpec[]) |> v -> push!(v, p) + end + + # partials: (built_components, last_ig_or_nothing) + partials = Tuple{ + Vector{DataModel.CableComponent}, + Union{Nothing, DataModel.InsulatorGroup}, + Vector{ComponentTrace}, + }[(DataModel.CableComponent[], nothing, ComponentTrace[])] + + for cname in comp_names + ps = by_comp[cname] + new_partials = Tuple{ + Vector{DataModel.CableComponent}, + Union{Nothing, DataModel.InsulatorGroup}, + Vector{ComponentTrace}, + }[] + for (built, last_ig, tr) in partials + base = last_ig === nothing ? 0.0 : last_ig + for (comp, ig, ctrace) in _make_variants(ps, base) + push!(new_partials, (vcat(built, comp), ig, [tr...; ctrace])) + end + end + partials = new_partials + end + + + + if !trace + designs = DataModel.CableDesign[] + for (comps, _) in ((x[1], x[2]) for x in partials) + des = DataModel.CableDesign(cbs.cable_id, comps[1]; nominal_data = cbs.nominal) + for k in Iterators.drop(eachindex(comps), 1) + add!(des, comps[k]) + end + push!(designs, des) + end + return designs + else + designs = DataModel.CableDesign[] + traces = DesignTrace[] + for (comps, _, ctraces) in partials + des = DataModel.CableDesign(cbs.cable_id, comps[1]; nominal_data = cbs.nominal) + for k in 2:length(comps) + ; + add!(des, comps[k]); + end + push!(designs, des) + push!(traces, DesignTrace(cbs.cable_id, ctraces)) + end + return designs, traces + end +end + +""" + iterate_designs(cbs) -> Channel{DataModel.CableDesign} + +Lazy stream of `CableDesign`s built from `CableBuilderSpec` without allocating all of them. +Works with `for d in iterate_designs(cbs)`. +""" +function iterate_designs(cbs::CableBuilderSpec) + # group by component + comp_names = unique(p.component for p in cbs.parts) + by_comp = Dict{Symbol, Vector{PartSpec}}() + for p in cbs.parts + get!(by_comp, p.component, PartSpec[]) |> v -> push!(v, p) + end + + return Channel{DataModel.CableDesign}(32) do ch + built = DataModel.CableComponent[] + lastig = Ref{Union{Nothing, DataModel.InsulatorGroup}}(nothing) + + function dfs(i::Int) + if i > length(comp_names) + des = DataModel.CableDesign( + cbs.cable_id, + built[1]; + nominal_data = cbs.nominal, + ) + for k in 2:length(built) + ; + add!(des, built[k]); + end + put!(ch, des) + return + end + cname = comp_names[i] + ps = by_comp[cname] + base = (lastig[] === nothing) ? 0.0 : lastig[] + + for (comp, ig) in _make_variants(ps, base) + push!(built, comp) + prev = lastig[]; + lastig[] = ig + dfs(i + 1) + lastig[] = prev + pop!(built) + end + end + + dfs(1) + end +end + +module Conductor + +using ..ParametricBuilder: PartSpec, _spec +using ...DataModel: DataModel + +# wire: args are (n, lay) +Wires(component::Symbol; layers::Int, d, n::Int, lay = 11.0, mat) = + PartSpec(component, DataModel.WireArray, layers; + dim = _spec(d), args = (n, _spec(lay)), material = mat) + +# tube: no extra args +Tubular(component::Symbol; layers::Int, t, mat) = + PartSpec(component, DataModel.Tubular, layers; + dim = _spec(t), args = (), material = mat) + +# strip: args are (width, lay) +Strip(component::Symbol; layers::Int, t, w, lay = 0.0, mat) = + PartSpec(component, DataModel.Strip, layers; + dim = _spec(t), args = (_spec(w), _spec(lay)), material = mat) + +# central + hex rings sugar +function Stranded(component::Symbol; layers::Int, d, n::Int, lay = 11.0, mat) + @assert layers >= 1 "stranded: layers must be ≥ 1 (includes the central wire)." + specs = PartSpec[] + dspec = _spec(d) + + # 1) central wire: 1 layer, n=1, lay=0.0 + push!( + specs, + PartSpec(component, DataModel.WireArray, 1; + dim = dspec, args = (1, (0.0, nothing)), material = mat), + ) + + # 2) rings: (layers-1) layers, base n, common lay + if layers > 1 + push!( + specs, + PartSpec(component, DataModel.WireArray, layers - 1; + dim = dspec, args = (n, _spec(lay)), material = mat), + ) + end + + return specs +end + +end + +module Insulator + +using ..ParametricBuilder: PartSpec, _spec +using ...DataModel: DataModel + +Tubular(component::Symbol; layers::Int, t, mat) = + PartSpec(component, DataModel.Insulator, layers; + dim = _spec(t), args = (), material = mat) + +Semicon(component::Symbol; layers::Int, t, mat) = + PartSpec(component, DataModel.Semicon, layers; + dim = _spec(t), args = (), material = mat) +end diff --git a/src/parametricbuilder/materialspec.jl b/src/parametricbuilder/materialspec.jl index f18ab2a0..1eda3b44 100644 --- a/src/parametricbuilder/materialspec.jl +++ b/src/parametricbuilder/materialspec.jl @@ -1,70 +1,75 @@ -# Use lib/material nominal; kw is either percent-only or (value,pct) -_pair_from_nominal(nom, x) = - x === nothing ? (nom, nothing) : - (x isa Tuple && length(x)==2) ? x : - (nom, x) - -# -------------------- material spec -------------------- - -""" -MaterialSpec: pass specs for fields (value spec + optional %unc) - -Example: - MaterialSpec(; rho=(2.826e-8, nothing), - eps_r=(1.0, nothing), - mu_r=(1.0, nothing), - T0=(20.0, nothing), - alpha=(4.0e-3, nothing)) -""" -struct MaterialSpec - rho::Any; - eps_r::Any; - mu_r::Any; - T0::Any; - alpha::Any -end -MaterialSpec(; rho, eps_r, mu_r, T0, alpha) = MaterialSpec(rho, eps_r, mu_r, T0, alpha) - -# --- 1) Ad-hoc numeric: values (or (value,pct)) --- -Material(; rho, eps_r = 1.0, mu_r = 1.0, T0 = 20.0, alpha = 0.0) = - MaterialSpec( - rho = _spec(rho), - eps_r = _spec(eps_r), - mu_r = _spec(mu_r), - T0 = _spec(T0), - alpha = _spec(alpha), - ) - -# --- 2) From an existing Material: append %unc by default, or override with (value,pct) --- -function Material( - m::Materials.Material; - rho = nothing, - eps_r = nothing, - mu_r = nothing, - T0 = nothing, - alpha = nothing, -) - MaterialSpec( - rho = _pair_from_nominal(m.rho, rho), - eps_r = _pair_from_nominal(m.eps_r, eps_r), - mu_r = _pair_from_nominal(m.mu_r, mu_r), - T0 = _pair_from_nominal(m.T0, T0), - alpha = _pair_from_nominal(m.alpha, alpha), - ) -end - -# --- 3) From a MaterialsLibrary + name --- -Material(lib::Materials.MaterialsLibrary, name::AbstractString; kwargs...) = - Material(get(lib, name); kwargs...) -Material(lib::Materials.MaterialsLibrary, name::Symbol; kwargs...) = - Material(lib, String(name); kwargs...) - - -function _make_range(ms::MaterialSpec) - ρs = _make_range(ms.rho[1]; pct = ms.rho[2]) - εs = _make_range(ms.eps_r[1]; pct = ms.eps_r[2]) - μs = _make_range(ms.mu_r[1]; pct = ms.mu_r[2]) - Ts = _make_range(ms.T0[1]; pct = ms.T0[2]) - αs = _make_range(ms.alpha[1]; pct = ms.alpha[2]) - [Materials.Material(ρ, ε, μ, T, α) for (ρ, ε, μ, T, α) in product(ρs, εs, μs, Ts, αs)] +# Use lib/material nominal; kw is either percent-only or (value,pct) +_pair_from_nominal(nom, x) = + x === nothing ? (nom, nothing) : + (x isa Tuple && length(x)==2) ? x : + (nom, x) + +# -------------------- material spec -------------------- + +""" +MaterialSpec: pass specs for fields (value spec + optional %unc) + +Example: + MaterialSpec(; rho=(2.826e-8, nothing), + eps_r=(1.0, nothing), + mu_r=(1.0, nothing), + T0=(20.0, nothing), + alpha=(4.0e-3, nothing)) +""" +struct MaterialSpec + rho::Any; + eps_r::Any; + mu_r::Any; + T0::Any; + alpha::Any; + kappa::Any; +end +MaterialSpec(; rho, eps_r, mu_r, T0, alpha, kappa) = MaterialSpec(rho, eps_r, mu_r, T0, alpha, kappa) + +# --- 1) Ad-hoc numeric: values (or (value,pct)) --- +Material(; rho, eps_r = 1.0, mu_r = 1.0, T0 = 20.0, alpha = 0.0, kappa = 1.0) = + MaterialSpec( + rho = _spec(rho), + eps_r = _spec(eps_r), + mu_r = _spec(mu_r), + T0 = _spec(T0), + alpha = _spec(alpha), + kappa = _spec(kappa), + ) + +# --- 2) From an existing Material: append %unc by default, or override with (value,pct) --- +function Material( + m::Materials.Material; + rho = nothing, + eps_r = nothing, + mu_r = nothing, + T0 = nothing, + alpha = nothing, + kappa = nothing, +) + MaterialSpec( + rho = _pair_from_nominal(m.rho, rho), + eps_r = _pair_from_nominal(m.eps_r, eps_r), + mu_r = _pair_from_nominal(m.mu_r, mu_r), + T0 = _pair_from_nominal(m.T0, T0), + alpha = _pair_from_nominal(m.alpha, alpha), + kappa = _pair_from_nominal(m.kappa, kappa), + ) +end + +# --- 3) From a MaterialsLibrary + name --- +Material(lib::Materials.MaterialsLibrary, name::AbstractString; kwargs...) = + Material(get(lib, name); kwargs...) +Material(lib::Materials.MaterialsLibrary, name::Symbol; kwargs...) = + Material(lib, String(name); kwargs...) + + +function _make_range(ms::MaterialSpec) + ρs = _make_range(ms.rho[1]; pct = ms.rho[2]) + εs = _make_range(ms.eps_r[1]; pct = ms.eps_r[2]) + μs = _make_range(ms.mu_r[1]; pct = ms.mu_r[2]) + Ts = _make_range(ms.T0[1]; pct = ms.T0[2]) + αs = _make_range(ms.alpha[1]; pct = ms.alpha[2]) + κs = _make_range(ms.kappa[1]; pct = ms.kappa[2]) + [Materials.Material(ρ, ε, μ, T, α, κ) for (ρ, ε, μ, T, α, κ) in product(ρs, εs, μs, Ts, αs, κs)] end \ No newline at end of file diff --git a/src/parametricbuilder/systembuilderspec.jl b/src/parametricbuilder/systembuilderspec.jl index 73b7be1f..c5bb8f71 100644 --- a/src/parametricbuilder/systembuilderspec.jl +++ b/src/parametricbuilder/systembuilderspec.jl @@ -1,156 +1,158 @@ - -# ───────────────────────────────────────────────────────────────────────────── -# Left-hand mapping syntax -# Accepts (:core,1) etc., with positions: -# at(x=..., y=..., dx=..., dy=..., phases = (:core=>1, :sheath=>0, :jacket=>0)) -# ───────────────────────────────────────────────────────────────────────────── -const _MapItem = Union{ - Tuple{Symbol, Int}, Tuple{String, Int}, - Pair{Symbol, Int}, Pair{String, Int}, -} - -_mapdict(items::_MapItem...) = Dict{String, Int}( - (it isa Pair ? (string(first(it)) => Int(last(it))) - : (string(it[1]) => Int(it[2]))) - for it in items -) - -struct _Pos - x0::Number - y0::Number - dx::Any - dy::Any - conn::Dict{String, Int} -end - -# normalize phases input to a splattable tuple of _MapItem -_iter_phases(p) = (p,) # single Pair or Tuple(:sym,Int) -_iter_phases(p::Tuple) = p # tuple of items -_iter_phases(v::AbstractVector{<:_MapItem}) = Tuple(v) # vector of items - -function at(; x, y, dx = 0.0, dy = 0.0, phases = nothing) - items = phases === nothing ? () : _iter_phases(phases) - return _Pos(x, y, dx, dy, _mapdict(items...)) -end - -# ───────────────────────────────────────────────────────────────────────────── -# Earth and system specs -# ───────────────────────────────────────────────────────────────────────────── -struct EarthSpec - rho::Any; - eps_r::Any; - mu_r::Any; - t::Any -end -EarthSpec(; rho, eps_r = 1.0, mu_r = 1.0, t = Inf) = - EarthSpec(_spec(rho), _spec(eps_r), _spec(mu_r), _spec(t)) - -Earth(; rho, eps_r = 1.0, mu_r = 1.0, t = Inf) = - EarthSpec(_spec(rho), _spec(eps_r), _spec(mu_r), _spec(t)) - -struct SystemBuilderSpec - system_id::String - builder::CableBuilderSpec - positions::Vector{_Pos} - length::Any # (valuespec, pctspec) or scalar - temperature::Any # (valuespec, pctspec) or scalar - earth::EarthSpec - frequencies::Vector{Float64} -end - -function SystemBuilderSpec(id::AbstractString, cbs::CableBuilderSpec, - positions::Vector{_Pos}; - length = 1000.0, temperature = 20.0, earth::EarthSpec, f::AbstractVector{<:Real}) - return SystemBuilderSpec( - String(id), - cbs, - positions, - _spec(length), - _spec(temperature), - earth, - collect(float.(f)), - ) -end - -SystemBuilder(id::AbstractString, cbs::CableBuilderSpec, - positions::Vector{_Pos}; - length = 1000.0, temperature = 20.0, earth::EarthSpec, f::AbstractVector{<:Real}) = SystemBuilderSpec(id, cbs, positions; length, temperature, earth, f) - -# ───────────────────────────────────────────────────────────────────────────── -# Internals: expand range/% grammar via ParametricBuilder helpers -# ───────────────────────────────────────────────────────────────────────────── -@inline _expand_pair(specpair) = _make_range(specpair[1]; pct = specpair[2]) - -# (nothing, pct) on dx/dy ⇒ attach % to the anchor itself (no displacement sweep) -@inline function _axis(anchor::Number, dspec) - spec, pct = _spec(dspec) - if spec === nothing - return _make_range(anchor; pct = pct) # uncertain anchor - else - return (anchor .+ v for v in _make_range(spec; pct = pct)) # displaced anchor - end -end - -_expand_position(p::_Pos) = - ((x, y, p.conn) for x in _axis(p.x0, p.dx), y in _axis(p.y0, p.dy)) - -_expand_earth(e::EarthSpec) = ( - (ρ, ε, μ, t) - for ρ in _expand_pair(e.rho), #_make_range(e.rho[1]; pct = e.rho[2]), - ε in _expand_pair(e.eps_r), #_make_range(e.epsr[1]; pct = e.epsr[2]), - μ in _expand_pair(e.mu_r), #_make_range(e.mur[1]; pct = e.mur[2]), - t in _expand_pair(e.t) #_make_range(e.t[1]; pct = e.t[2]) -) - -# ───────────────────────────────────────────────────────────────────────────── -# Main iterator: yields fully-formed LineParametersProblem objects -# Overlaps are *not* emitted (skipped with warning by catching the geometry error). -# Designs are identical per system realization (no cross-mixing). -# ───────────────────────────────────────────────────────────────────────────── -function iterate_problems(spec::SystemBuilderSpec) - return Channel{LineParametersProblem}(32) do ch - produced = 0 - try - for des in spec.builder - for L in _expand_pair(spec.length) - pos_spaces = map(_expand_position, spec.positions) - for choice in product(pos_spaces...) - try - x1, y1, c1 = choice[1] - sys = DataModel.LineCableSystem(spec.system_id, L, - DataModel.CablePosition(des, x1, y1, c1)) - for k in Iterators.drop(eachindex(choice), 1) - xk, yk, ck = choice[k] - sys = add!(sys, des, xk, yk, ck) - end - for T in _expand_pair(spec.temperature) - for (ρ, ε, μ, t) in _expand_earth(spec.earth) - em = EarthModel(spec.frequencies, ρ, ε, μ; t = t) - prob = LineParametersProblem(sys; - temperature = T, earth_props = em, - frequencies = spec.frequencies) - put!(ch, prob) - produced += 1 - end - end - catch e - if occursin("overlap", sprint(showerror, e)) - @warn sprint(showerror, e) - @warn "Skipping..." - continue - else - rethrow() - end - end - end - end - end - catch e - @error "iterate SystemBuilderSpec failed" exception=(e, catch_backtrace()) - finally - @info "iterate SystemBuilderSpec finished" produced=produced upper_bound=cardinality( - spec, - ) - end - end -end + +# ───────────────────────────────────────────────────────────────────────────── +# Left-hand mapping syntax +# Accepts (:core,1) etc., with positions: +# at(x=..., y=..., dx=..., dy=..., phases = (:core=>1, :sheath=>0, :jacket=>0)) +# ───────────────────────────────────────────────────────────────────────────── +const _MapItem = Union{ + Tuple{Symbol, Int}, Tuple{String, Int}, + Pair{Symbol, Int}, Pair{String, Int}, +} + +_mapdict(items::_MapItem...) = Dict{String, Int}( + (it isa Pair ? (string(first(it)) => Int(last(it))) + : (string(it[1]) => Int(it[2]))) + for it in items +) + +struct _Pos + x0::Number + y0::Number + dx::Any + dy::Any + conn::Dict{String, Int} +end + +# normalize phases input to a splattable tuple of _MapItem +_iter_phases(p) = (p,) # single Pair or Tuple(:sym,Int) +_iter_phases(p::Tuple) = p # tuple of items +_iter_phases(v::AbstractVector{<:_MapItem}) = Tuple(v) # vector of items + +function at(; x, y, dx = 0.0, dy = 0.0, phases = nothing) + items = phases === nothing ? () : _iter_phases(phases) + return _Pos(x, y, dx, dy, _mapdict(items...)) +end + +# ───────────────────────────────────────────────────────────────────────────── +# Earth and system specs +# ───────────────────────────────────────────────────────────────────────────── +struct EarthSpec + rho::Any; + eps_r::Any; + mu_r::Any; + kappa::Any; + t::Any +end +EarthSpec(; rho, eps_r = 1.0, mu_r = 1.0, kappa = 1.0, t = Inf) = + EarthSpec(_spec(rho), _spec(eps_r), _spec(mu_r), _spec(kappa), _spec(t)) + +Earth(; rho, eps_r = 1.0, mu_r = 1.0, kappa = 1.0, t = Inf) = + EarthSpec(_spec(rho), _spec(eps_r), _spec(mu_r), _spec(kappa), _spec(t)) + +struct SystemBuilderSpec + system_id::String + builder::CableBuilderSpec + positions::Vector{_Pos} + length::Any # (valuespec, pctspec) or scalar + temperature::Any # (valuespec, pctspec) or scalar + earth::EarthSpec + frequencies::Vector{Float64} +end + +function SystemBuilderSpec(id::AbstractString, cbs::CableBuilderSpec, + positions::Vector{_Pos}; + length = 1000.0, temperature = 20.0, earth::EarthSpec, f::AbstractVector{<:Real}) + return SystemBuilderSpec( + String(id), + cbs, + positions, + _spec(length), + _spec(temperature), + earth, + collect(float.(f)), + ) +end + +SystemBuilder(id::AbstractString, cbs::CableBuilderSpec, + positions::Vector{_Pos}; + length = 1000.0, temperature = 20.0, earth::EarthSpec, f::AbstractVector{<:Real}) = SystemBuilderSpec(id, cbs, positions; length, temperature, earth, f) + +# ───────────────────────────────────────────────────────────────────────────── +# Internals: expand range/% grammar via ParametricBuilder helpers +# ───────────────────────────────────────────────────────────────────────────── +@inline _expand_pair(specpair) = _make_range(specpair[1]; pct = specpair[2]) + +# (nothing, pct) on dx/dy ⇒ attach % to the anchor itself (no displacement sweep) +@inline function _axis(anchor::Number, dspec) + spec, pct = _spec(dspec) + if spec === nothing + return _make_range(anchor; pct = pct) # uncertain anchor + else + return (anchor .+ v for v in _make_range(spec; pct = pct)) # displaced anchor + end +end + +_expand_position(p::_Pos) = + ((x, y, p.conn) for x in _axis(p.x0, p.dx), y in _axis(p.y0, p.dy)) + +_expand_earth(e::EarthSpec) = ( + (ρ, ε, μ, t, κ) + for ρ in _expand_pair(e.rho), #_make_range(e.rho[1]; pct = e.rho[2]), + ε in _expand_pair(e.eps_r), #_make_range(e.epsr[1]; pct = e.epsr[2]), + μ in _expand_pair(e.mu_r), #_make_range(e.mur[1]; pct = e.mur[2]), + t in _expand_pair(e.t), #_make_range(e.t[1]; pct = e.t[2]), + κ in _expand_pair(e.kappa) +) + +# ───────────────────────────────────────────────────────────────────────────── +# Main iterator: yields fully-formed LineParametersProblem objects +# Overlaps are *not* emitted (skipped with warning by catching the geometry error). +# Designs are identical per system realization (no cross-mixing). +# ───────────────────────────────────────────────────────────────────────────── +function iterate_problems(spec::SystemBuilderSpec) + return Channel{LineParametersProblem}(32) do ch + produced = 0 + try + for des in spec.builder + for L in _expand_pair(spec.length) + pos_spaces = map(_expand_position, spec.positions) + for choice in product(pos_spaces...) + try + x1, y1, c1 = choice[1] + sys = DataModel.LineCableSystem(spec.system_id, L, + DataModel.CablePosition(des, x1, y1, c1)) + for k in Iterators.drop(eachindex(choice), 1) + xk, yk, ck = choice[k] + sys = add!(sys, des, xk, yk, ck) + end + for T in _expand_pair(spec.temperature) + for (ρ, ε, μ, t, κ) in _expand_earth(spec.earth) + em = EarthModel(spec.frequencies, ρ, ε, μ, κ; t = t) + prob = LineParametersProblem(sys; + temperature = T, earth_props = em, + frequencies = spec.frequencies) + put!(ch, prob) + produced += 1 + end + end + catch e + if occursin("overlap", sprint(showerror, e)) + @warn sprint(showerror, e) + @warn "Skipping..." + continue + else + rethrow() + end + end + end + end + end + catch e + @error "iterate SystemBuilderSpec failed" exception=(e, catch_backtrace()) + finally + @info "iterate SystemBuilderSpec finished" produced=produced upper_bound=cardinality( + spec, + ) + end + end +end diff --git a/src/parametricbuilder/wirepatterns/WirePatterns.jl b/src/parametricbuilder/wirepatterns/WirePatterns.jl index fa9c9e39..48a1047e 100644 --- a/src/parametricbuilder/wirepatterns/WirePatterns.jl +++ b/src/parametricbuilder/wirepatterns/WirePatterns.jl @@ -1,341 +1,341 @@ -module WirePatterns - -# ──────────────────────────────────────────────────────────────────────────── -# Public API -# ──────────────────────────────────────────────────────────────────────────── - -# export ScreenPattern, HexaPattern -export make_stranded, make_screened - -# ──────────────────────────────────────────────────────────────────────────── -# Types -# ──────────────────────────────────────────────────────────────────────────── - -""" - struct HexaPattern - -Result for a single design choice. - -Fields: -- `layers::Int` — number of concentric layers (1 = center only). -- `wires::Int` — total number of wires, N(L) = 1 + 3L(L-1). -- `wire_diameter_m::Float64` — strand diameter [m]. -- `total_area_m2::Float64` — summed metallic area [m²]. -- `awg::String` — AWG label from the table (informative). -""" -struct HexaPattern - layers::Int - wires::Int - wire_diameter_m::Float64 - total_area_m2::Float64 - awg::String -end - -""" - struct ScreenPattern - -Screen wires design. - -Fields: -- `wires::Int` — number of wires on the wire array (N). -- `wire_diameter_m::Float64` — strand diameter [m]. -- `lay_diameter_m::Float64` — laying diameter Dm [m]. -- `radius_m::Float64` — wire array centerline radius = (Dm + d)/2 [m]. -- `total_area_m2::Float64` — N * π/4 * d^2 [m²]. -- `coverage_pct::Float64` — 100 * N*d / (π*Dm*sinα) [%]. -- `awg::String` — AWG label from the table (informative). -""" -struct ScreenPattern - wires::Int - wire_diameter_m::Float64 - lay_diameter_m::Float64 - radius_m::Float64 - total_area_m2::Float64 - coverage_pct::Float64 - awg::String -end - -# ──────────────────────────────────────────────────────────────────────────── -# Utils -# ──────────────────────────────────────────────────────────────────────────── - -_wire_area(dw::Real) = (pi/4) * (dw^2) # area of one wire - -# ---- AWG exact formulas (solid wire) ---- -const _AWG_BASE = 92.0 -const _D0_MM = 0.127 # 0.005 in in mm -const _AREA0_MM2 = 0.012668 # (π/4)*0.127^2 -const _LN_BASE = log(_AWG_BASE) - -awg_to_d_mm(n::Real) = _D0_MM * (_AWG_BASE ^ ((36 - n)/39)) -awg_to_area_mm2(n::Real) = _AREA0_MM2 * (_AWG_BASE ^ ((36 - n)/19.5)) - -d_mm_to_awg(d_mm::Real) = 36 - 39 * (log(d_mm/_D0_MM) / _LN_BASE) -area_mm2_to_awg(A_mm2::Real) = 36 - 19.5 * (log(A_mm2/_AREA0_MM2) / _LN_BASE) - -function awg_label(n::Integer) - n == -3 && return "0000 (4/0)" - n == -2 && return "000 (3/0)" - n == -1 && return "00 (2/0)" - n == 0 && return "0 (1/0)" - return string(n) -end - -"Generate (label, diameter_m) for AWG n in [nmin, nmax]." -function awg_sizes(nmin::Integer = -3, nmax::Integer = 40) - out = Tuple{String, Float64}[] - @inbounds for n in nmin:nmax - d_m = awg_to_d_mm(n) / 1000.0 - push!(out, (awg_label(n), d_m)) - end - return out -end - -"Apply a compaction/fill factor to solid area to approximate stranded metallic CSA." -stranded_area_mm2(n::Real; fill_factor::Real = 0.94) = fill_factor * awg_to_area_mm2(n) - -# ──────────────────────────────────────────────────────────────────────────── -# Hexagonal strand patterns -# ──────────────────────────────────────────────────────────────────────────── - -# ---- wire-count constraints per target area (mm²) ---- -const _WIRE_RULES = Tuple{Int, Int, Union{Int, Nothing}}[ - (10, 6, 7), - (16, 6, 7), - (25, 6, 7), - (35, 6, 7), - (50, 6, 19), - (70, 12, 19), - (95, 15, 19), - (120, 15, 37), - (150, 15, 37), - (185, 30, 37), - (240, 30, 37), - (300, 30, 61), - (400, 53, 61), - (500, 53, 61), - (630, 53, 91), - (800, 53, 91), - (1000, 53, 91), -] - -""" - make_stranded(target_area_m2::Real; nmin::Integer=-3, nmax::Integer=40) - -Compute hexagonal-pattern strand layouts that approximate or meet the target metallic cross-section, imposing allowed total-wire ranges by target area. - -Inputs: -- `target_mm2` — target metallic area [mm²]. -- `nmin`,`nmax` — AWG range to consider (default 4/0 … 40). - -Returns: -- `best_match` — within allowed N(L), minimize |A − target|. -- `min_layers` — within allowed N(L) and A ≥ target, minimize layers (tie: smallest excess, then smaller diameter). - Fallback: within allowed, pick largest A < target (tie: smaller L, then smaller diameter). -- `min_diam` — within allowed N(L) and A ≥ target, minimize diameter, then layers, then excess. - Fallback: within allowed, pick smallest diameter with largest A < target (then smallest L). -""" -function make_stranded(target_mm2::Real; nmin::Integer = -3, nmax::Integer = 40) - @assert target_mm2 > 0 "Target cross-section must be positive." - @assert nmin <= nmax "nmin must be ≤ nmax." - - target_area_m2 = target_mm2 * 1e-6 # m² - # ---- hex geometry ---- - _hex_N(L::Int) = 1 + 3L*(L - 1) # total wires after L layers - _to_choice((dw, L, N, A, awg)) = HexaPattern(L, N, dw, A, awg) - - # Return (minN, maxN::Union{Int,Nothing}) for target in mm² - function _allowed_wires(target_mm2::Real) - for (thr, minN, maxN) in _WIRE_RULES - if target_mm2 <= thr - return (minN, maxN) - end - end - return (53, nothing) # > 1000 mm² -> min 53, no maximum - end - - @inline function _allowed_N(N::Int, minN::Int, maxN::Union{Int, Nothing}) - maxN === nothing ? (N >= minN) : (N >= minN && N <= maxN) - end - - # Allowed wire-count range from target (mm²) - minN, maxN = _allowed_wires(target_mm2) - - # AWG sizes (label, d_m) - sizes = awg_sizes(nmin, nmax) - @assert !isempty(sizes) "AWG range produced no sizes." - - # Build allowed candidates: (dw, L, N, A, awg) - candidates = Vector{Tuple{Float64, Int, Int, Float64, String}}() - for (awg, dw) in sizes - a1 = _wire_area(dw) - @inbounds for L in 1:300 - N = _hex_N(L) - if _allowed_N(N, minN, maxN) - A = N * a1 - push!(candidates, (dw, L, N, A, awg)) - end - if maxN !== nothing && N > maxN - break - end - end - end - @assert !isempty(candidates) "No allowed candidates under the imposed wire-count span." - - # ---- best_match: minimize |A - target| (tie: smaller dw, then smaller L) ---- - rank_keys = [(abs(A - target_area_m2), dw, L) for (dw, L, N, A, _) in candidates] - best_match = _to_choice(candidates[argmin(rank_keys)]) - - # Split feasible/infeasible for next selectors - feas = filter(((dw, L, N, A, awg),)->A >= target_area_m2, candidates) - infeas = filter(((dw, L, N, A, awg),)->A < target_area_m2, candidates) - - # ---- min_layers ---- - if !isempty(feas) - # minimal layers, then minimal excess, then smaller diameter - keys_L = [(L, A - target_area_m2, dw) for (dw, L, N, A, _) in feas] - min_layers = _to_choice(feas[argmin(keys_L)]) - else - # fallback: closest from below (largest A), then minimal L, then smaller dw - keys_fb = [(-A, L, dw) for (dw, L, N, A, _) in infeas] - min_layers = _to_choice(infeas[argmin(keys_fb)]) - end - - # ---- min_diam ---- - if !isempty(feas) - # smallest diameter; for it, minimal layers; then smallest excess - sort!(feas, by = x -> (x[1], x[2], x[4] - target_area_m2)) # (dw asc, L asc, excess asc) - min_diam = _to_choice(first(feas)) - else - # fallback: smallest diameter with best undershoot; then minimal layers - sort!(infeas, by = x -> (x[1], -(x[4]), x[2])) # (dw asc, A desc, L asc) - min_diam = _to_choice(first(infeas)) - end - - return (; best_match, min_layers, min_diam) -end - -# ──────────────────────────────────────────────────────────────────────────── -# Screen (single wire array) patterns -# ──────────────────────────────────────────────────────────────────────────── - -""" - make_screened(A_req_m2::Real, Dm_m::Real; - alpha_deg::Real=15.0, coverage_min_pct::Real=85.0, - gap_frac::Real=0.0, min_wires::Int=3, extra_span::Int=8, - nmin::Integer=-3, nmax::Integer=40) - -Compute screen wire layouts that approximate or meet the target metallic cross-section, imposing: - - 1) CSA: N * (π/4) * d^2 ≥ A_req_m2 - 2) Cover: (N*d)/(π*Dm*sinα) * 100 ≥ coverage_min_pct - -while enforcing no-overlap wire array geometry (with optional clearance `gap_frac`). - -Arguments: -- `A_req_mm2` — required metallic cross-section [mm²]. -- `Dm_mm` — laying diameter (screen centerline) [mm]. -- `alpha_deg` — lay angle α in degrees (default 20°). -- `coverage_min_pct` — required geometric coverage (default 85%). -- `gap_frac` — extra clearance fraction in the no-overlap check (default 0). -- `min_wires` — lower bound on N to avoid degenerate “non-wire-array" cases (default 3; set 6 for stronger symmetry). -- `extra_span` — consider up to this many extra wires above the minimal requirement for better best_match search. -- `nmin`,`nmax` — AWG range to consider (default 4/0 … 40). - -Returns: -- `min_wires` — minimal N (≥ min_wires) that satisfies both CSA & coverage & geometry; - tie-break: smaller d, then smaller excess area. -- `min_diam` — smallest d that can satisfy both constraints; for it, minimal feasible N; - tie-break: smaller excess area. -- `best_match`— among all feasible combos, area closest to A_req_m2; tie: smaller N, then smaller d. -""" -function make_screened(A_req_mm2::Real, Dm_mm::Real; - alpha_deg::Real = 15.0, coverage_min_pct::Real = 85.0, - gap_frac::Real = 0.0, min_wires::Int = 6, extra_span::Int = 8, - nmin::Integer = -3, nmax::Integer = 40, - coverage_max_pct::Real = 100.0, # NEW: cap coverage to a single layer - max_overshoot_pct::Real = 10.0, # NEW: optional cap on A overshoot (∞ to disable) - custom_diameters_mm::AbstractVector{<:Real} = Float64[]) - - @assert 0.0 < coverage_min_pct <= 100.0 - @assert coverage_max_pct >= coverage_min_pct - @assert max_overshoot_pct ≥ 0 - @assert A_req_mm2 > 0 - @assert Dm_mm > 0 - - A_req_m2 = A_req_mm2 * 1e-6 # m² - Dm_m = Dm_mm * 1e-3 # m - - # --- helpers --- - function _max_wires_single_layer(Dm::Real, d::Real; gap_frac::Real = 0.0) - s = d*(1 + gap_frac) / (Dm + d) - if !(0.0 < s < 1.0) - ; - return 0; - end - return max(0, floor(Int, pi / asin(s))) - end - _to_choice((N, d, Dm, A, cov, awg)) = ScreenPattern(N, d, Dm, 0.5*(Dm + d), A, cov, awg) - - α = deg2rad(alpha_deg) - sα = sin(α); - @assert sα > 0 - - # AWG sizes + optional customs - sizes = awg_sizes(nmin, nmax) - for d in custom_diameters_mm - push!(sizes, ("custom($(round(d; digits=3)) mm)", Float64(d))) - end - @assert !isempty(sizes) - - # Build candidates that satisfy BOTH constraints + geometry + coverage upper bound - candidates = Tuple{Int, Float64, Float64, Float64, Float64, String}[] # (N,d,Dm,A,cov,awg) - - for (awg, d) in sizes - a1 = _wire_area(d) - N_csa = ceil(Int, A_req_m2 / a1) - N_cov = ceil(Int, (coverage_min_pct/100.0) * (pi*Dm_m*sα) / d) - N_min = max(min_wires, N_csa, N_cov) - N_max = _max_wires_single_layer(Dm_m, d; gap_frac = gap_frac) - if N_max <= 0 || N_min > N_max - continue - end - - # Try N from N_min upward but reject coverage > coverage_max_pct and overshoot > max_overshoot_pct - upper = min(N_min + extra_span, N_max) - @inbounds for N in N_min:upper - A = N * a1 - cov = 100.0 * (N*d) / (pi*Dm_m*sα) - if cov > coverage_max_pct - break # for fixed d, cov grows linearly with N; larger N will also violate - end - if isfinite(max_overshoot_pct) - if A > A_req_m2 * (1 + max_overshoot_pct/100) - continue - end - end - push!(candidates, (N, d, Dm_m, A, cov, awg)) - end - end - - @assert !isempty(candidates) "No feasible screen with given CSA, Dm, α, coverage bounds, and geometry." - - # --- selectors (tweaked) --- - - # 1) min_wires: minimize N; tie → minimize |A−Areq|; then smaller d - keys_minN = [(N, abs(A - A_req_m2), d) for (N, d, _, A, _, _) in candidates] - min_wires = _to_choice(candidates[argmin(keys_minN)]) - - # 2) min_diam: smallest d; for it, minimal |A−Areq|; then minimal N - sort!(candidates, by = x -> (x[2], abs(x[4] - A_req_m2), x[1])) # (d asc, |ΔA| asc, N asc) - min_diam = _to_choice(first(candidates)) - - # 3) best_match: closest area to A_req; tie → smaller N, then smaller d - keys_best = [(abs(A - A_req_m2), N, d) for (N, d, _, A, _, _) in candidates] - best_match = _to_choice(candidates[argmin(keys_best)]) - - return (; min_wires, min_diam, best_match) -end - - -end # module +module WirePatterns + +# ──────────────────────────────────────────────────────────────────────────── +# Public API +# ──────────────────────────────────────────────────────────────────────────── + +# export ScreenPattern, HexaPattern +export make_stranded, make_screened + +# ──────────────────────────────────────────────────────────────────────────── +# Types +# ──────────────────────────────────────────────────────────────────────────── + +""" + struct HexaPattern + +Result for a single design choice. + +Fields: +- `layers::Int` — number of concentric layers (1 = center only). +- `wires::Int` — total number of wires, N(L) = 1 + 3L(L-1). +- `wire_diameter_m::Float64` — strand diameter [m]. +- `total_area_m2::Float64` — summed metallic area [m²]. +- `awg::String` — AWG label from the table (informative). +""" +struct HexaPattern + layers::Int + wires::Int + wire_diameter_m::Float64 + total_area_m2::Float64 + awg::String +end + +""" + struct ScreenPattern + +Screen wires design. + +Fields: +- `wires::Int` — number of wires on the wire array (N). +- `wire_diameter_m::Float64` — strand diameter [m]. +- `lay_diameter_m::Float64` — laying diameter Dm [m]. +- `radius_m::Float64` — wire array centerline radius = (Dm + d)/2 [m]. +- `total_area_m2::Float64` — N * π/4 * d^2 [m²]. +- `coverage_pct::Float64` — 100 * N*d / (π*Dm*sinα) [%]. +- `awg::String` — AWG label from the table (informative). +""" +struct ScreenPattern + wires::Int + wire_diameter_m::Float64 + lay_diameter_m::Float64 + radius_m::Float64 + total_area_m2::Float64 + coverage_pct::Float64 + awg::String +end + +# ──────────────────────────────────────────────────────────────────────────── +# Utils +# ──────────────────────────────────────────────────────────────────────────── + +_wire_area(dw::Real) = (pi/4) * (dw^2) # area of one wire + +# ---- AWG exact formulas (solid wire) ---- +const _AWG_BASE = 92.0 +const _D0_MM = 0.127 # 0.005 in in mm +const _AREA0_MM2 = 0.012668 # (π/4)*0.127^2 +const _LN_BASE = log(_AWG_BASE) + +awg_to_d_mm(n::Real) = _D0_MM * (_AWG_BASE ^ ((36 - n)/39)) +awg_to_area_mm2(n::Real) = _AREA0_MM2 * (_AWG_BASE ^ ((36 - n)/19.5)) + +d_mm_to_awg(d_mm::Real) = 36 - 39 * (log(d_mm/_D0_MM) / _LN_BASE) +area_mm2_to_awg(A_mm2::Real) = 36 - 19.5 * (log(A_mm2/_AREA0_MM2) / _LN_BASE) + +function awg_label(n::Integer) + n == -3 && return "0000 (4/0)" + n == -2 && return "000 (3/0)" + n == -1 && return "00 (2/0)" + n == 0 && return "0 (1/0)" + return string(n) +end + +"Generate (label, diameter_m) for AWG n in [nmin, nmax]." +function awg_sizes(nmin::Integer = -3, nmax::Integer = 40) + out = Tuple{String, Float64}[] + @inbounds for n in nmin:nmax + d_m = awg_to_d_mm(n) / 1000.0 + push!(out, (awg_label(n), d_m)) + end + return out +end + +"Apply a compaction/fill factor to solid area to approximate stranded metallic CSA." +stranded_area_mm2(n::Real; fill_factor::Real = 0.94) = fill_factor * awg_to_area_mm2(n) + +# ──────────────────────────────────────────────────────────────────────────── +# Hexagonal strand patterns +# ──────────────────────────────────────────────────────────────────────────── + +# ---- wire-count constraints per target area (mm²) ---- +const _WIRE_RULES = Tuple{Int, Int, Union{Int, Nothing}}[ + (10, 6, 7), + (16, 6, 7), + (25, 6, 7), + (35, 6, 7), + (50, 6, 19), + (70, 12, 19), + (95, 15, 19), + (120, 15, 37), + (150, 15, 37), + (185, 30, 37), + (240, 30, 37), + (300, 30, 61), + (400, 53, 61), + (500, 53, 61), + (630, 53, 91), + (800, 53, 91), + (1000, 53, 91), +] + +""" + make_stranded(target_area_m2::Real; nmin::Integer=-3, nmax::Integer=40) + +Compute hexagonal-pattern strand layouts that approximate or meet the target metallic cross-section, imposing allowed total-wire ranges by target area. + +Inputs: +- `target_mm2` — target metallic area [mm²]. +- `nmin`,`nmax` — AWG range to consider (default 4/0 … 40). + +Returns: +- `best_match` — within allowed N(L), minimize |A − target|. +- `min_layers` — within allowed N(L) and A ≥ target, minimize layers (tie: smallest excess, then smaller diameter). + Fallback: within allowed, pick largest A < target (tie: smaller L, then smaller diameter). +- `min_diam` — within allowed N(L) and A ≥ target, minimize diameter, then layers, then excess. + Fallback: within allowed, pick smallest diameter with largest A < target (then smallest L). +""" +function make_stranded(target_mm2::Real; nmin::Integer = -3, nmax::Integer = 40) + @assert target_mm2 > 0 "Target cross-section must be positive." + @assert nmin <= nmax "nmin must be ≤ nmax." + + target_area_m2 = target_mm2 * 1e-6 # m² + # ---- hex geometry ---- + _hex_N(L::Int) = 1 + 3L*(L - 1) # total wires after L layers + _to_choice((dw, L, N, A, awg)) = HexaPattern(L, N, dw, A, awg) + + # Return (minN, maxN::Union{Int,Nothing}) for target in mm² + function _allowed_wires(target_mm2::Real) + for (thr, minN, maxN) in _WIRE_RULES + if target_mm2 <= thr + return (minN, maxN) + end + end + return (53, nothing) # > 1000 mm² -> min 53, no maximum + end + + @inline function _allowed_N(N::Int, minN::Int, maxN::Union{Int, Nothing}) + maxN === nothing ? (N >= minN) : (N >= minN && N <= maxN) + end + + # Allowed wire-count range from target (mm²) + minN, maxN = _allowed_wires(target_mm2) + + # AWG sizes (label, d_m) + sizes = awg_sizes(nmin, nmax) + @assert !isempty(sizes) "AWG range produced no sizes." + + # Build allowed candidates: (dw, L, N, A, awg) + candidates = Vector{Tuple{Float64, Int, Int, Float64, String}}() + for (awg, dw) in sizes + a1 = _wire_area(dw) + @inbounds for L in 1:300 + N = _hex_N(L) + if _allowed_N(N, minN, maxN) + A = N * a1 + push!(candidates, (dw, L, N, A, awg)) + end + if maxN !== nothing && N > maxN + break + end + end + end + @assert !isempty(candidates) "No allowed candidates under the imposed wire-count span." + + # ---- best_match: minimize |A - target| (tie: smaller dw, then smaller L) ---- + rank_keys = [(abs(A - target_area_m2), dw, L) for (dw, L, N, A, _) in candidates] + best_match = _to_choice(candidates[argmin(rank_keys)]) + + # Split feasible/infeasible for next selectors + feas = filter(((dw, L, N, A, awg),)->A >= target_area_m2, candidates) + infeas = filter(((dw, L, N, A, awg),)->A < target_area_m2, candidates) + + # ---- min_layers ---- + if !isempty(feas) + # minimal layers, then minimal excess, then smaller diameter + keys_L = [(L, A - target_area_m2, dw) for (dw, L, N, A, _) in feas] + min_layers = _to_choice(feas[argmin(keys_L)]) + else + # fallback: closest from below (largest A), then minimal L, then smaller dw + keys_fb = [(-A, L, dw) for (dw, L, N, A, _) in infeas] + min_layers = _to_choice(infeas[argmin(keys_fb)]) + end + + # ---- min_diam ---- + if !isempty(feas) + # smallest diameter; for it, minimal layers; then smallest excess + sort!(feas, by = x -> (x[1], x[2], x[4] - target_area_m2)) # (dw asc, L asc, excess asc) + min_diam = _to_choice(first(feas)) + else + # fallback: smallest diameter with best undershoot; then minimal layers + sort!(infeas, by = x -> (x[1], -(x[4]), x[2])) # (dw asc, A desc, L asc) + min_diam = _to_choice(first(infeas)) + end + + return (; best_match, min_layers, min_diam) +end + +# ──────────────────────────────────────────────────────────────────────────── +# Screen (single wire array) patterns +# ──────────────────────────────────────────────────────────────────────────── + +""" + make_screened(A_req_m2::Real, Dm_m::Real; + alpha_deg::Real=15.0, coverage_min_pct::Real=85.0, + gap_frac::Real=0.0, min_wires::Int=3, extra_span::Int=8, + nmin::Integer=-3, nmax::Integer=40) + +Compute screen wire layouts that approximate or meet the target metallic cross-section, imposing: + + 1) CSA: N * (π/4) * d^2 ≥ A_req_m2 + 2) Cover: (N*d)/(π*Dm*sinα) * 100 ≥ coverage_min_pct + +while enforcing no-overlap wire array geometry (with optional clearance `gap_frac`). + +Arguments: +- `A_req_mm2` — required metallic cross-section [mm²]. +- `Dm_mm` — laying diameter (screen centerline) [mm]. +- `alpha_deg` — lay angle α in degrees (default 20°). +- `coverage_min_pct` — required geometric coverage (default 85%). +- `gap_frac` — extra clearance fraction in the no-overlap check (default 0). +- `min_wires` — lower bound on N to avoid degenerate “non-wire-array" cases (default 3; set 6 for stronger symmetry). +- `extra_span` — consider up to this many extra wires above the minimal requirement for better best_match search. +- `nmin`,`nmax` — AWG range to consider (default 4/0 … 40). + +Returns: +- `min_wires` — minimal N (≥ min_wires) that satisfies both CSA & coverage & geometry; + tie-break: smaller d, then smaller excess area. +- `min_diam` — smallest d that can satisfy both constraints; for it, minimal feasible N; + tie-break: smaller excess area. +- `best_match`— among all feasible combos, area closest to A_req_m2; tie: smaller N, then smaller d. +""" +function make_screened(A_req_mm2::Real, Dm_mm::Real; + alpha_deg::Real = 15.0, coverage_min_pct::Real = 85.0, + gap_frac::Real = 0.0, min_wires::Int = 6, extra_span::Int = 8, + nmin::Integer = -3, nmax::Integer = 40, + coverage_max_pct::Real = 100.0, # NEW: cap coverage to a single layer + max_overshoot_pct::Real = 10.0, # NEW: optional cap on A overshoot (∞ to disable) + custom_diameters_mm::AbstractVector{<:Real} = Float64[]) + + @assert 0.0 < coverage_min_pct <= 100.0 + @assert coverage_max_pct >= coverage_min_pct + @assert max_overshoot_pct ≥ 0 + @assert A_req_mm2 > 0 + @assert Dm_mm > 0 + + A_req_m2 = A_req_mm2 * 1e-6 # m² + Dm_m = Dm_mm * 1e-3 # m + + # --- helpers --- + function _max_wires_single_layer(Dm::Real, d::Real; gap_frac::Real = 0.0) + s = d*(1 + gap_frac) / (Dm + d) + if !(0.0 < s < 1.0) + ; + return 0; + end + return max(0, floor(Int, pi / asin(s))) + end + _to_choice((N, d, Dm, A, cov, awg)) = ScreenPattern(N, d, Dm, 0.5*(Dm + d), A, cov, awg) + + α = deg2rad(alpha_deg) + sα = sin(α); + @assert sα > 0 + + # AWG sizes + optional customs + sizes = awg_sizes(nmin, nmax) + for d in custom_diameters_mm + push!(sizes, ("custom($(round(d; digits=3)) mm)", Float64(d))) + end + @assert !isempty(sizes) + + # Build candidates that satisfy BOTH constraints + geometry + coverage upper bound + candidates = Tuple{Int, Float64, Float64, Float64, Float64, String}[] # (N,d,Dm,A,cov,awg) + + for (awg, d) in sizes + a1 = _wire_area(d) + N_csa = ceil(Int, A_req_m2 / a1) + N_cov = ceil(Int, (coverage_min_pct/100.0) * (pi*Dm_m*sα) / d) + N_min = max(min_wires, N_csa, N_cov) + N_max = _max_wires_single_layer(Dm_m, d; gap_frac = gap_frac) + if N_max <= 0 || N_min > N_max + continue + end + + # Try N from N_min upward but reject coverage > coverage_max_pct and overshoot > max_overshoot_pct + upper = min(N_min + extra_span, N_max) + @inbounds for N in N_min:upper + A = N * a1 + cov = 100.0 * (N*d) / (pi*Dm_m*sα) + if cov > coverage_max_pct + break # for fixed d, cov grows linearly with N; larger N will also violate + end + if isfinite(max_overshoot_pct) + if A > A_req_m2 * (1 + max_overshoot_pct/100) + continue + end + end + push!(candidates, (N, d, Dm_m, A, cov, awg)) + end + end + + @assert !isempty(candidates) "No feasible screen with given CSA, Dm, α, coverage bounds, and geometry." + + # --- selectors (tweaked) --- + + # 1) min_wires: minimize N; tie → minimize |A−Areq|; then smaller d + keys_minN = [(N, abs(A - A_req_m2), d) for (N, d, _, A, _, _) in candidates] + min_wires = _to_choice(candidates[argmin(keys_minN)]) + + # 2) min_diam: smallest d; for it, minimal |A−Areq|; then minimal N + sort!(candidates, by = x -> (x[2], abs(x[4] - A_req_m2), x[1])) # (d asc, |ΔA| asc, N asc) + min_diam = _to_choice(first(candidates)) + + # 3) best_match: closest area to A_req; tie → smaller N, then smaller d + keys_best = [(abs(A - A_req_m2), N, d) for (N, d, _, A, _, _) in candidates] + best_match = _to_choice(candidates[argmin(keys_best)]) + + return (; min_wires, min_diam, best_match) +end + + +end # module diff --git a/src/plotuicomponents/PlotUIComponents.jl b/src/plotuicomponents/PlotUIComponents.jl index 7a75e62c..804c3043 100644 --- a/src/plotuicomponents/PlotUIComponents.jl +++ b/src/plotuicomponents/PlotUIComponents.jl @@ -1,824 +1,824 @@ -module PlotUIComponents - -using Makie -import ..BackendHandler: current_backend_symbol, _pkgid - -# ----------------------------------------------------------------------------- -# Constants -# ----------------------------------------------------------------------------- - -const FIG_SIZE = (800, 600) -const FIG_PADDING = (80, 60, 40, 40) # left, right, bottom, top -const CTLBAR_HEIGHT = 36 -const STATUSBAR_HEIGHT = 20 -const GRID_ROW_GAP = 6 -const GRID_COL_GAP = 6 -const LEGEND_GAP = 4 -const LEGEND_WIDTH = 140 -const COLORBAR_GAP = 4 -const CTLBAR_GAP = 2 -const BUTTON_MIN_WIDTH = 32 -const BUTTON_ICON_SIZE = 18 -const BUTTON_TEXT_FONT_SIZE = 15 -const AXIS_TITLE_FONT_SIZE = 15 -const AXIS_LABEL_FONT_SIZE = 14 -const AXIS_TICK_FONT_SIZE = 14 -const STATUS_FONT_SIZE = 10 -const BG_COLOR_INTERACTIVE = :grey90 -const BG_COLOR_EXPORT = :white -const ICON_COLOR_ACTIVE = Makie.RGBAf(0.15, 0.15, 0.15, 1.0) -const ICON_COLOR_DISABLED = Makie.RGBAf(0.55, 0.55, 0.55, 1.0) - - -# ----------------------------------------------------------------------------- -# Material UI icons -# ----------------------------------------------------------------------------- -const MI_REFRESH = "\uE5D5" # Material Icons: 'refresh' -const MI_SAVE = "\uE161" # Material Icons: 'save' -const ICON_TTF = joinpath(@__DIR__, "..", "..", "assets", "fonts", "material-icons", "MaterialIcons-Regular.ttf") - -# ----------------------------------------------------------------------------- -# Data structures -# ----------------------------------------------------------------------------- - -mutable struct PlotBackendContext - backend::Symbol - interactive::Bool - window::Union{Nothing, Any} - screen::Union{Nothing, Any} - use_latex_fonts::Bool - icons::Function - icons_font::Union{Nothing, String} - statusbar::Union{Nothing, Makie.Observable{String}} -end - -struct PlotFigureContext - figure::Makie.Figure - canvas_node::Any - legend_grid::Makie.GridLayout - legend_slot::Any - colorbar_slot::Any - ctlbar_node::Makie.GridLayout - placeholder_node::Makie.GridLayout - statusbar_node::Makie.GridLayout -end - -struct ControlReaction - status_string::Union{Nothing, String, Function} - button_color::Union{Nothing, Any} - button_label::Union{Nothing, String} - timeout::Union{Nothing, AbstractFloat} -end - -ControlReaction(; - status_string = nothing, - button_color = nothing, - button_label = nothing, - timeout = 1.5, -) = - ControlReaction(status_string, button_color, button_label, timeout) - -struct ControlButtonSpec - label::Union{Nothing, String} - icon::Union{Nothing, String} - action::Function - on_success::Union{Nothing, ControlReaction} - on_failure::Union{Nothing, ControlReaction} -end - -ControlButtonSpec( - action::Function; - label::Union{Nothing, String} = nothing, - icon::Union{Nothing, String} = nothing, - on_success::Union{Nothing, ControlReaction} = nothing, - on_failure::Union{Nothing, ControlReaction} = nothing, -) = ControlButtonSpec(label, icon, action, on_success, on_failure) - -struct ControlToggleSpec - label::Union{Nothing, String} - action_on::Function - action_off::Function - on_success_on::Union{Nothing, ControlReaction} - on_success_off::Union{Nothing, ControlReaction} - on_failure::Union{Nothing, ControlReaction} - start_active::Bool -end - -ControlToggleSpec( - action_on::Function, - action_off::Function; - label::Union{Nothing, String} = nothing, - on_success_on::Union{Nothing, ControlReaction} = nothing, - on_success_off::Union{Nothing, ControlReaction} = nothing, - on_failure::Union{Nothing, ControlReaction} = nothing, - start_active::Bool = false, -) = ControlToggleSpec( - label, - action_on, - action_off, - on_success_on, - on_success_off, - on_failure, - start_active, -) - -struct PlotBuildArtifacts - axis::Union{Nothing, Makie.Axis} - legends::Union{Nothing, Any} - colorbars::Union{Nothing, Vector{Any}} - control_buttons::Vector{ControlButtonSpec} - control_toggles::Vector{ControlToggleSpec} - status_message::Union{Nothing, String} -end - -PlotBuildArtifacts(; axis = nothing, legends = nothing, colorbars = nothing, - control_buttons = ControlButtonSpec[], control_toggles = ControlToggleSpec[], - status_message = nothing) = - PlotBuildArtifacts( - axis, - legends, - colorbars, - control_buttons, - control_toggles, - status_message, - ) - -struct PlotAssembly - backend_ctx::PlotBackendContext - figure_ctx::PlotFigureContext - figure::Makie.Figure - axis::Any - buttons::Vector{Makie.Button} - legend::Any - colorbars::Vector{Any} - status_label::Any - artifacts::PlotBuildArtifacts -end - -# ----------------------------------------------------------------------------- -# Backend helpers -# ----------------------------------------------------------------------------- - -"""Create a GLMakie screen if GL backend is active; otherwise return nothing.""" -function gl_screen(title::AbstractString) - if current_backend_symbol() == :gl - mod = Base.require(_pkgid(:gl)) - ctor = getproperty(mod, :Screen) - return Base.invokelatest(ctor; title = String(title)) - end - return nothing -end - -# tiny helper to build "icon + text" labels ergonomically --- -""" -with_icon(icon; text="", isize=14, tsize=12, color=:black, gap=4, - dy_icon=-0.18, dy_text=0.0) - -- `dy_icon`, `dy_text`: vertical tweaks in *em* units (fraction of that part's fontsize). - Negative moves down, positive moves up. -""" -with_icon(icon::AbstractString; text::AbstractString = "", - isize::Int = BUTTON_ICON_SIZE, tsize::Int = BUTTON_TEXT_FONT_SIZE, color = :black, - gap::Int = 2, - dy_icon::Float64 = -0.18, dy_text::Float64 = 0.0) = - text == "" ? - rich(icon; font = :icons, fontsize = isize, color = color, offset = (0, dy_icon)) : - rich( - rich(icon; font = :icons, fontsize = isize, color = color, offset = (0, dy_icon)), - rich(" "^gap; font = :regular, fontsize = tsize, color = color), - rich(text; font = :regular, fontsize = tsize, color = color, offset = (0, dy_text)), - ) - -function build_backend_context( - backend::Symbol; - interactive::Union{Nothing, Bool} = nothing, - window = nothing, - screen = nothing, - icons::Function = (icon; text = nothing, kwargs...) -> - (text === nothing ? string(icon) : string(text)), - use_latex_fonts::Bool = false, - icons_font::Union{Nothing, String} = nothing, - statusbar::Union{Nothing, Makie.Observable{String}} = nothing, -) - is_interactive = - interactive === nothing ? backend in (:gl, :wgl, :wglmakie) : interactive - chan = statusbar - if chan === nothing && is_interactive - chan = Makie.Observable("") - end - return PlotBackendContext( - backend, - is_interactive, - window, - screen, - use_latex_fonts, - icons, - icons_font, - chan, - ) -end - -function attach_window!(ctx::PlotBackendContext; window = nothing, screen = nothing) - ctx.window = window - ctx.screen = screen - return ctx -end - -function _make_window( - backend_handler::Module, - backend::Union{Nothing, Symbol} = nothing; - title::AbstractString = "LineCableModels Plot", - icons::Function = (icon; text = nothing, kwargs...) -> - (text === nothing ? string(icon) : string(text)), - use_latex_fonts::Bool = false, - icons_font::Union{Nothing, String} = nothing, - statusbar::Union{Nothing, Makie.Observable{String}} = nothing, - interactive_override::Union{Nothing, Bool} = nothing, -) - actual_backend = backend_handler.ensure_backend!(backend) - is_interactive = - interactive_override === nothing ? actual_backend in (:gl, :wgl) : - interactive_override - ctx = build_backend_context( - actual_backend; - interactive = is_interactive, - icons = icons, - use_latex_fonts = use_latex_fonts, - icons_font = icons_font, - statusbar = statusbar, - ) - if is_interactive && actual_backend == :gl - scr = gl_screen(title) - if scr !== nothing - attach_window!(ctx; window = scr, screen = scr) - end - end - return ctx -end - -function theme_for( - ctx::PlotBackendContext; - mode::Symbol = ctx.interactive ? :interactive : :export, -) - background = mode === :interactive ? BG_COLOR_INTERACTIVE : BG_COLOR_EXPORT - base = Makie.Theme() - if ctx.use_latex_fonts && mode == :export - base = merge(base, Makie.theme_latexfonts()) - end - icon_font = ctx.icons_font - custom = - icon_font === nothing ? - Makie.Theme( - backgroundcolor = background, - Axis = ( - titlesize = AXIS_TITLE_FONT_SIZE, - xlabelsize = AXIS_LABEL_FONT_SIZE, - ylabelsize = AXIS_LABEL_FONT_SIZE, - xticklabelsize = AXIS_TICK_FONT_SIZE, - yticklabelsize = AXIS_TICK_FONT_SIZE, - ), - Legend = ( - fontsize = AXIS_LABEL_FONT_SIZE, - labelsize = AXIS_LABEL_FONT_SIZE, - ), - Colorbar = ( - labelsize = AXIS_LABEL_FONT_SIZE, - ticklabelsize = AXIS_TICK_FONT_SIZE, - ), - ) : - Makie.Theme( - backgroundcolor = background, - fonts = (; icons = icon_font), - Axis = ( - titlesize = AXIS_TITLE_FONT_SIZE, - xlabelsize = AXIS_LABEL_FONT_SIZE, - ylabelsize = AXIS_LABEL_FONT_SIZE, - xticklabelsize = AXIS_TICK_FONT_SIZE, - yticklabelsize = AXIS_TICK_FONT_SIZE, - ), - Legend = ( - fontsize = AXIS_LABEL_FONT_SIZE, - labelsize = AXIS_LABEL_FONT_SIZE, - ), - Colorbar = ( - labelsize = AXIS_LABEL_FONT_SIZE, - ticklabelsize = AXIS_TICK_FONT_SIZE, - ), - ) - return merge(base, custom) -end - -_configure_theme!( - ctx::PlotBackendContext; - mode::Symbol = ctx.interactive ? :interactive : :export, -) = - theme_for(ctx; mode = mode) - -function with_plot_theme( - f::Function, - ctx::PlotBackendContext; - mode::Union{Nothing, Symbol} = nothing, -) - chosen_mode = mode === nothing ? (ctx.interactive ? :interactive : :export) : mode - theme = _configure_theme!(ctx; mode = chosen_mode) - return Makie.with_theme(theme) do - f() - end -end - -# ----------------------------------------------------------------------------- -# Figure helpers -# ----------------------------------------------------------------------------- - -function _make_figure( - ctx::PlotBackendContext; - fig_size::Tuple{Int, Int} = FIG_SIZE, - figure_padding::NTuple{4, Int} = FIG_PADDING, - legend_panel_width::Int = LEGEND_WIDTH, -) - fig = Makie.Figure(; size = fig_size, figure_padding = figure_padding) - - ctlbar_node = fig[1, 1:2] = Makie.GridLayout() - ctlbar_node.halign = :left - ctlbar_node.valign = :bottom - placeholder_node = fig[2, 1:2] = Makie.GridLayout() - canvas_node = fig[3, 1] - legend_grid = fig[3, 2] = Makie.GridLayout() - statusbar_node = fig[4, 1:2] = Makie.GridLayout() - statusbar_node.halign = :left - - legend_slot = legend_grid[1, 1] - legend_slot[] = Makie.GridLayout() - colorbar_slot = legend_grid[2, 1] - colorbar_slot[] = Makie.GridLayout() - - fig_ctx = PlotFigureContext( - fig, - canvas_node, - legend_grid, - legend_slot, - colorbar_slot, - ctlbar_node, - placeholder_node, - statusbar_node, - ) - - _configure_layout!( - fig_ctx; - interactive = ctx.interactive, - legend_panel_width = legend_panel_width, - ) - return fig_ctx -end - -function _configure_layout!( - fig_ctx::PlotFigureContext; - interactive::Bool = true, - legend_panel_width::Int = LEGEND_WIDTH, -) - layout = fig_ctx.figure.layout - - Makie.rowgap!(layout, GRID_ROW_GAP) - Makie.colgap!(layout, GRID_COL_GAP) - - Makie.rowsize!(layout, 1, Makie.Fixed(interactive ? CTLBAR_HEIGHT : 0)) - Makie.rowsize!(layout, 2, Makie.Fixed(0)) - Makie.rowsize!(layout, 3, Makie.Relative(1.0)) - Makie.rowsize!(layout, 4, Makie.Fixed(interactive ? STATUSBAR_HEIGHT : 0)) - - Makie.colsize!(layout, 1, Makie.Relative(1.0)) - Makie.colsize!(layout, 2, Makie.Fixed(legend_panel_width)) - - Makie.rowgap!(fig_ctx.legend_grid, LEGEND_GAP) - Makie.colgap!(fig_ctx.legend_grid, 0) - - - Makie.rowsize!(fig_ctx.legend_grid, 1, Makie.Auto()) - Makie.rowsize!(fig_ctx.legend_grid, 2, Makie.Auto()) - - return fig_ctx -end - -function _make_canvas!( - fig_ctx::PlotFigureContext; - axis_ctor = Makie.Axis, - axis_options::NamedTuple = NamedTuple(), -) - axis = axis_ctor(fig_ctx.canvas_node; axis_options...) - return axis -end - -# ----------------------------------------------------------------------------- -# Control bar helpers -# ----------------------------------------------------------------------------- - -function _make_ctlbar!( - fig_ctx::PlotFigureContext, - ctx::PlotBackendContext, - button_specs::AbstractVector{ControlButtonSpec}, - toggle_specs::AbstractVector{ControlToggleSpec}; - button_height::Int = max(CTLBAR_HEIGHT - 12, 32), - button_gap::Int = CTLBAR_GAP, -) - if !ctx.interactive || (isempty(button_specs) && isempty(toggle_specs)) - Makie.rowsize!(fig_ctx.figure.layout, 1, Makie.Fixed(0)) - return [], [] - end - - layout = fig_ctx.ctlbar_node - Makie.rowgap!(layout, 0) - Makie.colgap!(layout, button_gap) - Makie.rowsize!(layout, 1, Makie.Fixed(button_height)) - - buttons = Makie.Button[] - toggles = Makie.Toggle[] - col_idx = 1 - - for spec in button_specs - label = _build_button_label(ctx, spec) - button_kwargs = ( - ; label = label, - fontsize = BUTTON_TEXT_FONT_SIZE, - height = button_height, - halign = :left, - ) - if spec.icon !== nothing - width = _preferred_button_width(spec) - if width !== nothing - button_kwargs = (; button_kwargs..., width = width) - end - end - button = Makie.Button(layout[1, col_idx]; button_kwargs...) - push!(buttons, button) - _wire_button_callback!(button, spec, ctx) - col_idx += 1 - end - - for spec in toggle_specs - gl = layout[1, col_idx] = Makie.GridLayout() - gl.halign = :left - gl.valign = :center - - toggle = Makie.Toggle(gl[1, 2]; active = spec.start_active) - if spec.label !== nothing - Makie.Label(gl[1, 1], spec.label, halign = :right) - end - - push!(toggles, toggle) - _wire_toggle_callback!(toggle, spec, ctx) - col_idx += 1 - end - - return buttons, toggles -end - -function _build_button_label(ctx::PlotBackendContext, spec::ControlButtonSpec) - icon_fn = ctx.icons - label_text = spec.label === nothing ? "" : spec.label - if spec.icon === nothing - return label_text - end - try - return icon_fn(spec.icon; text = label_text, gap = 6) - catch err - if err isa MethodError - return isempty(label_text) ? string(spec.icon) : label_text - else - rethrow(err) - end - end -end - -function _preferred_button_width(spec::ControlButtonSpec) - if spec.icon !== nothing && spec.label === nothing - return BUTTON_MIN_WIDTH - end - return nothing -end - -function _wire_button_callback!(button, spec::ControlButtonSpec, ctx::PlotBackendContext) - ensure_statusbar!(ctx) - - Makie.on(button.clicks) do _ - Base.@async begin - try - result = _invoke_button_action(spec.action, ctx, button) - _apply_reaction!(ctx, button, spec.on_success, result) - catch err - _apply_reaction!(ctx, button, spec.on_failure, sprint(showerror, err)) - end - end - end - return button -end - -function _wire_toggle_callback!(toggle, spec::ControlToggleSpec, ctx::PlotBackendContext) - ensure_statusbar!(ctx) - - Makie.on(toggle.active) do is_active - Base.@async begin - original_state = !is_active - try - if is_active - result = _invoke_button_action(spec.action_on, ctx, toggle) - _apply_reaction!(ctx, toggle, spec.on_success_on, result) - else - result = _invoke_button_action(spec.action_off, ctx, toggle) - _apply_reaction!(ctx, toggle, spec.on_success_off, result) - end - catch err - _apply_reaction!(ctx, toggle, spec.on_failure, sprint(showerror, err)) - end - end - end - return toggle -end - -function _invoke_button_action(action::Function, ctx::PlotBackendContext, button) - try - return Base.invokelatest(action, ctx, button) - catch err - if err isa MethodError && err.f === action - try - return Base.invokelatest(action, ctx) - catch err2 - if err2 isa MethodError && err2.f === action - return Base.invokelatest(action) - else - throw(err2) - end - end - else - throw(err) - end - end -end - - -function _apply_reaction!( - ctx::PlotBackendContext, - button, - reaction::Union{Nothing, ControlReaction}, - result, -) - has_color = hasproperty(button, :buttoncolor) - original_color = has_color ? button.buttoncolor[] : nothing - has_label = hasproperty(button, :label) - original_label = has_label ? button.label[] : nothing - - # Determine the status message - status_msg = nothing - if reaction !== nothing && reaction.status_string !== nothing - if reaction.status_string isa Function - status_msg = reaction.status_string(result) - else - status_msg = reaction.status_string - end - elseif result isa AbstractString && !isempty(result) - status_msg = result - end - - # Apply reaction and status update - if status_msg !== nothing - update_status!(ctx, status_msg) - end - - if reaction !== nothing - if reaction.button_color !== nothing && has_color - button.buttoncolor[] = Makie.to_color(reaction.button_color) - end - if reaction.button_label !== nothing - button.label[] = reaction.button_label - end - end - - # Handle timeout and UI restoration - timeout = reaction !== nothing ? reaction.timeout : 1.6 - if timeout !== nothing && isfinite(timeout) - sleep(timeout) - if status_msg !== nothing - clear_status!(ctx) - end - if reaction !== nothing - if reaction.button_color !== nothing && has_color - button.buttoncolor[] = original_color - end - if reaction.button_label !== nothing && has_label - button.label[] = original_label - end - end - end - - return nothing -end - -clear_status!(ctx) = begin - # non-breaking space keeps the row height while looking empty - update_status!(ctx, "\u00A0") - -end - -# ----------------------------------------------------------------------------- -# Legend & colorbar helpers -# ----------------------------------------------------------------------------- - -"""Populate the legend area. Accepts `nothing`, a builder function, or a Makie plot object.""" -function _make_legend!(fig_ctx::PlotFigureContext, content; kwargs...) - slot = fig_ctx.legend_slot - if content === nothing - slot[] = Makie.GridLayout() - Makie.rowsize!(fig_ctx.legend_grid, 1, Makie.Fixed(0)) - return nothing - end - - Makie.rowsize!(fig_ctx.legend_grid, 1, Makie.Auto()) - container = Makie.GridLayout() - slot[] = container - built = _materialize_component!(container[1, 1], content; kwargs...) - if built !== nothing - if hasproperty(built, :valign) - built.valign[] = :top - end - if hasproperty(built, :halign) - built.halign[] = :left - end - end - return built -end - -"""Populate the colorbar stack with zero or more builder specs.""" -function _make_colorbars!( - fig_ctx::PlotFigureContext, - specs::Union{Nothing, AbstractVector}; - kwargs..., -) - slot = fig_ctx.colorbar_slot - if specs === nothing || isempty(specs) - slot[] = Makie.GridLayout() - Makie.rowsize!(fig_ctx.legend_grid, 2, Makie.Fixed(0)) - return Any[] - end - - Makie.rowsize!(fig_ctx.legend_grid, 2, Makie.Auto()) - container = Makie.GridLayout() - slot[] = container - Makie.rowgap!(container, COLORBAR_GAP) - - built = Any[] - row = 1 - for spec in specs - spec === nothing && continue - node = container[row, 1] - push!(built, _materialize_component!(node, spec; kwargs...)) - row += 1 - end - return built -end - -function _materialize_component!(parent, spec; kwargs...) - if spec isa Function - return spec(parent; kwargs...) - elseif Makie.isplot(spec) - parent[] = spec - return spec - else - try - parent[] = spec - return spec - catch err - if err isa MethodError - error("Unsupported component specification $(typeof(spec))") - else - rethrow(err) - end - end - end -end - - -# ----------------------------------------------------------------------------- -# Status helpers -# ----------------------------------------------------------------------------- - -function _make_statusbar!( - fig_ctx::PlotFigureContext, - ctx::PlotBackendContext; - initial_message::AbstractString = "", -) - if !ctx.interactive - Makie.rowsize!(fig_ctx.figure.layout, 4, Makie.Fixed(0)) - return nothing - end - - status_obs = ensure_statusbar!(ctx) - if !isempty(initial_message) - status_obs[] = String(initial_message) - end - - label = Makie.Label(fig_ctx.statusbar_node[1, 1]; - text = status_obs, - fontsize = STATUS_FONT_SIZE, - halign = :left, - tellwidth = false, - tellheight = false, - ) - return label -end - -function ensure_statusbar!(ctx::PlotBackendContext) - if ctx.statusbar === nothing - ctx.statusbar = Makie.Observable("") - end - return ctx.statusbar -end - -function update_status!(ctx::PlotBackendContext, message::AbstractString) - chan = ensure_statusbar!(ctx) - chan[] = String(message) - return chan -end - -# ----------------------------------------------------------------------------- -# Orchestration helpers -# ----------------------------------------------------------------------------- - -function _run_plot_pipeline( - backend_ctx::PlotBackendContext, - plot_fn::Function; - fig_size::Tuple{Int, Int} = FIG_SIZE, - figure_padding::NTuple{4, Int} = FIG_PADDING, - legend_panel_width::Int = LEGEND_WIDTH, - axis_ctor = Makie.Axis, - axis_kwargs::NamedTuple = NamedTuple(), - extra_buttons::AbstractVector{ControlButtonSpec} = ControlButtonSpec[], - initial_status::Union{Nothing, String} = nothing, -) - fig_ctx = _make_figure( - backend_ctx; - fig_size = fig_size, - figure_padding = figure_padding, - legend_panel_width = legend_panel_width, - ) - - axis = - isempty(axis_kwargs) ? - _make_canvas!(fig_ctx; axis_ctor = axis_ctor) : - _make_canvas!(fig_ctx; axis_ctor = axis_ctor, axis_options = axis_kwargs) - - artifacts = plot_fn(fig_ctx, backend_ctx, axis) - artifacts = artifacts === nothing ? PlotBuildArtifacts(axis = axis) : artifacts - - axis = artifacts.axis === nothing ? axis : artifacts.axis - - button_specs = ControlButtonSpec[] - isempty(extra_buttons) || append!(button_specs, extra_buttons) - isempty(artifacts.control_buttons) || append!(button_specs, artifacts.control_buttons) - - buttons, toggles = - _make_ctlbar!(fig_ctx, backend_ctx, button_specs, artifacts.control_toggles) - - legend_obj = _make_legend!(fig_ctx, artifacts.legends) - colorbar_objs = _make_colorbars!(fig_ctx, artifacts.colorbars) - - status_message = artifacts.status_message - if status_message === nothing - status_message = initial_status - end - status_message = status_message === nothing ? "" : status_message - - status_label = _make_statusbar!(fig_ctx, backend_ctx; initial_message = status_message) - if !isempty(status_message) - update_status!(backend_ctx, status_message) - end - - return PlotAssembly( - backend_ctx, - fig_ctx, - fig_ctx.figure, - axis, - buttons, - legend_obj, - colorbar_objs, - status_label, - artifacts, - ) -end - -make_window_context(args...; kwargs...) = _make_window(args...; kwargs...) -make_standard_figure(args...; kwargs...) = _make_figure(args...; kwargs...) -configure_layout!(args...; kwargs...) = _configure_layout!(args...; kwargs...) -make_canvas!(args...; kwargs...) = _make_canvas!(args...; kwargs...) -make_ctlbar!(args...; kwargs...) = _make_ctlbar!(args...; kwargs...) -make_legend!(args...; kwargs...) = _make_legend!(args...; kwargs...) -make_colorbars!(args...; kwargs...) = _make_colorbars!(args...; kwargs...) -make_statusbar!(args...; kwargs...) = _make_statusbar!(args...; kwargs...) -run_plot_pipeline(args...; kwargs...) = _run_plot_pipeline(args...; kwargs...) - -function ensure_export_background!(fig) - if fig !== nothing && hasproperty(fig, :scene) - fig.scene.backgroundcolor[] = Makie.to_color(BG_COLOR_EXPORT) - end - return fig -end - -end # module PlotUIComponents +module PlotUIComponents + +using Makie +import ..BackendHandler: current_backend_symbol, _pkgid + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- + +const FIG_SIZE = (800, 600) +const FIG_PADDING = (80, 60, 40, 40) # left, right, bottom, top +const CTLBAR_HEIGHT = 36 +const STATUSBAR_HEIGHT = 20 +const GRID_ROW_GAP = 6 +const GRID_COL_GAP = 6 +const LEGEND_GAP = 4 +const LEGEND_WIDTH = 140 +const COLORBAR_GAP = 4 +const CTLBAR_GAP = 2 +const BUTTON_MIN_WIDTH = 32 +const BUTTON_ICON_SIZE = 18 +const BUTTON_TEXT_FONT_SIZE = 15 +const AXIS_TITLE_FONT_SIZE = 15 +const AXIS_LABEL_FONT_SIZE = 14 +const AXIS_TICK_FONT_SIZE = 14 +const STATUS_FONT_SIZE = 10 +const BG_COLOR_INTERACTIVE = :grey90 +const BG_COLOR_EXPORT = :white +const ICON_COLOR_ACTIVE = Makie.RGBAf(0.15, 0.15, 0.15, 1.0) +const ICON_COLOR_DISABLED = Makie.RGBAf(0.55, 0.55, 0.55, 1.0) + + +# ----------------------------------------------------------------------------- +# Material UI icons +# ----------------------------------------------------------------------------- +const MI_REFRESH = "\uE5D5" # Material Icons: 'refresh' +const MI_SAVE = "\uE161" # Material Icons: 'save' +const ICON_TTF = joinpath(@__DIR__, "..", "..", "assets", "fonts", "material-icons", "MaterialIcons-Regular.ttf") + +# ----------------------------------------------------------------------------- +# Data structures +# ----------------------------------------------------------------------------- + +mutable struct PlotBackendContext + backend::Symbol + interactive::Bool + window::Union{Nothing, Any} + screen::Union{Nothing, Any} + use_latex_fonts::Bool + icons::Function + icons_font::Union{Nothing, String} + statusbar::Union{Nothing, Makie.Observable{String}} +end + +struct PlotFigureContext + figure::Makie.Figure + canvas_node::Any + legend_grid::Makie.GridLayout + legend_slot::Any + colorbar_slot::Any + ctlbar_node::Makie.GridLayout + placeholder_node::Makie.GridLayout + statusbar_node::Makie.GridLayout +end + +struct ControlReaction + status_string::Union{Nothing, String, Function} + button_color::Union{Nothing, Any} + button_label::Union{Nothing, String} + timeout::Union{Nothing, AbstractFloat} +end + +ControlReaction(; + status_string = nothing, + button_color = nothing, + button_label = nothing, + timeout = 1.5, +) = + ControlReaction(status_string, button_color, button_label, timeout) + +struct ControlButtonSpec + label::Union{Nothing, String} + icon::Union{Nothing, String} + action::Function + on_success::Union{Nothing, ControlReaction} + on_failure::Union{Nothing, ControlReaction} +end + +ControlButtonSpec( + action::Function; + label::Union{Nothing, String} = nothing, + icon::Union{Nothing, String} = nothing, + on_success::Union{Nothing, ControlReaction} = nothing, + on_failure::Union{Nothing, ControlReaction} = nothing, +) = ControlButtonSpec(label, icon, action, on_success, on_failure) + +struct ControlToggleSpec + label::Union{Nothing, String} + action_on::Function + action_off::Function + on_success_on::Union{Nothing, ControlReaction} + on_success_off::Union{Nothing, ControlReaction} + on_failure::Union{Nothing, ControlReaction} + start_active::Bool +end + +ControlToggleSpec( + action_on::Function, + action_off::Function; + label::Union{Nothing, String} = nothing, + on_success_on::Union{Nothing, ControlReaction} = nothing, + on_success_off::Union{Nothing, ControlReaction} = nothing, + on_failure::Union{Nothing, ControlReaction} = nothing, + start_active::Bool = false, +) = ControlToggleSpec( + label, + action_on, + action_off, + on_success_on, + on_success_off, + on_failure, + start_active, +) + +struct PlotBuildArtifacts + axis::Union{Nothing, Makie.Axis} + legends::Union{Nothing, Any} + colorbars::Union{Nothing, Vector{Any}} + control_buttons::Vector{ControlButtonSpec} + control_toggles::Vector{ControlToggleSpec} + status_message::Union{Nothing, String} +end + +PlotBuildArtifacts(; axis = nothing, legends = nothing, colorbars = nothing, + control_buttons = ControlButtonSpec[], control_toggles = ControlToggleSpec[], + status_message = nothing) = + PlotBuildArtifacts( + axis, + legends, + colorbars, + control_buttons, + control_toggles, + status_message, + ) + +struct PlotAssembly + backend_ctx::PlotBackendContext + figure_ctx::PlotFigureContext + figure::Makie.Figure + axis::Any + buttons::Vector{Makie.Button} + legend::Any + colorbars::Vector{Any} + status_label::Any + artifacts::PlotBuildArtifacts +end + +# ----------------------------------------------------------------------------- +# Backend helpers +# ----------------------------------------------------------------------------- + +"""Create a GLMakie screen if GL backend is active; otherwise return nothing.""" +function gl_screen(title::AbstractString) + if current_backend_symbol() == :gl + mod = Base.require(_pkgid(:gl)) + ctor = getproperty(mod, :Screen) + return Base.invokelatest(ctor; title = String(title)) + end + return nothing +end + +# tiny helper to build "icon + text" labels ergonomically --- +""" +with_icon(icon; text="", isize=14, tsize=12, color=:black, gap=4, + dy_icon=-0.18, dy_text=0.0) + +- `dy_icon`, `dy_text`: vertical tweaks in *em* units (fraction of that part's fontsize). + Negative moves down, positive moves up. +""" +with_icon(icon::AbstractString; text::AbstractString = "", + isize::Int = BUTTON_ICON_SIZE, tsize::Int = BUTTON_TEXT_FONT_SIZE, color = :black, + gap::Int = 2, + dy_icon::Float64 = -0.18, dy_text::Float64 = 0.0) = + text == "" ? + rich(icon; font = :icons, fontsize = isize, color = color, offset = (0, dy_icon)) : + rich( + rich(icon; font = :icons, fontsize = isize, color = color, offset = (0, dy_icon)), + rich(" "^gap; font = :regular, fontsize = tsize, color = color), + rich(text; font = :regular, fontsize = tsize, color = color, offset = (0, dy_text)), + ) + +function build_backend_context( + backend::Symbol; + interactive::Union{Nothing, Bool} = nothing, + window = nothing, + screen = nothing, + icons::Function = (icon; text = nothing, kwargs...) -> + (text === nothing ? string(icon) : string(text)), + use_latex_fonts::Bool = false, + icons_font::Union{Nothing, String} = nothing, + statusbar::Union{Nothing, Makie.Observable{String}} = nothing, +) + is_interactive = + interactive === nothing ? backend in (:gl, :wgl, :wglmakie) : interactive + chan = statusbar + if chan === nothing && is_interactive + chan = Makie.Observable("") + end + return PlotBackendContext( + backend, + is_interactive, + window, + screen, + use_latex_fonts, + icons, + icons_font, + chan, + ) +end + +function attach_window!(ctx::PlotBackendContext; window = nothing, screen = nothing) + ctx.window = window + ctx.screen = screen + return ctx +end + +function _make_window( + backend_handler::Module, + backend::Union{Nothing, Symbol} = nothing; + title::AbstractString = "LineCableModels Plot", + icons::Function = (icon; text = nothing, kwargs...) -> + (text === nothing ? string(icon) : string(text)), + use_latex_fonts::Bool = false, + icons_font::Union{Nothing, String} = nothing, + statusbar::Union{Nothing, Makie.Observable{String}} = nothing, + interactive_override::Union{Nothing, Bool} = nothing, +) + actual_backend = backend_handler.ensure_backend!(backend) + is_interactive = + interactive_override === nothing ? actual_backend in (:gl, :wgl) : + interactive_override + ctx = build_backend_context( + actual_backend; + interactive = is_interactive, + icons = icons, + use_latex_fonts = use_latex_fonts, + icons_font = icons_font, + statusbar = statusbar, + ) + if is_interactive && actual_backend == :gl + scr = gl_screen(title) + if scr !== nothing + attach_window!(ctx; window = scr, screen = scr) + end + end + return ctx +end + +function theme_for( + ctx::PlotBackendContext; + mode::Symbol = ctx.interactive ? :interactive : :export, +) + background = mode === :interactive ? BG_COLOR_INTERACTIVE : BG_COLOR_EXPORT + base = Makie.Theme() + if ctx.use_latex_fonts && mode == :export + base = merge(base, Makie.theme_latexfonts()) + end + icon_font = ctx.icons_font + custom = + icon_font === nothing ? + Makie.Theme( + backgroundcolor = background, + Axis = ( + titlesize = AXIS_TITLE_FONT_SIZE, + xlabelsize = AXIS_LABEL_FONT_SIZE, + ylabelsize = AXIS_LABEL_FONT_SIZE, + xticklabelsize = AXIS_TICK_FONT_SIZE, + yticklabelsize = AXIS_TICK_FONT_SIZE, + ), + Legend = ( + fontsize = AXIS_LABEL_FONT_SIZE, + labelsize = AXIS_LABEL_FONT_SIZE, + ), + Colorbar = ( + labelsize = AXIS_LABEL_FONT_SIZE, + ticklabelsize = AXIS_TICK_FONT_SIZE, + ), + ) : + Makie.Theme( + backgroundcolor = background, + fonts = (; icons = icon_font), + Axis = ( + titlesize = AXIS_TITLE_FONT_SIZE, + xlabelsize = AXIS_LABEL_FONT_SIZE, + ylabelsize = AXIS_LABEL_FONT_SIZE, + xticklabelsize = AXIS_TICK_FONT_SIZE, + yticklabelsize = AXIS_TICK_FONT_SIZE, + ), + Legend = ( + fontsize = AXIS_LABEL_FONT_SIZE, + labelsize = AXIS_LABEL_FONT_SIZE, + ), + Colorbar = ( + labelsize = AXIS_LABEL_FONT_SIZE, + ticklabelsize = AXIS_TICK_FONT_SIZE, + ), + ) + return merge(base, custom) +end + +_configure_theme!( + ctx::PlotBackendContext; + mode::Symbol = ctx.interactive ? :interactive : :export, +) = + theme_for(ctx; mode = mode) + +function with_plot_theme( + f::Function, + ctx::PlotBackendContext; + mode::Union{Nothing, Symbol} = nothing, +) + chosen_mode = mode === nothing ? (ctx.interactive ? :interactive : :export) : mode + theme = _configure_theme!(ctx; mode = chosen_mode) + return Makie.with_theme(theme) do + f() + end +end + +# ----------------------------------------------------------------------------- +# Figure helpers +# ----------------------------------------------------------------------------- + +function _make_figure( + ctx::PlotBackendContext; + fig_size::Tuple{Int, Int} = FIG_SIZE, + figure_padding::NTuple{4, Int} = FIG_PADDING, + legend_panel_width::Int = LEGEND_WIDTH, +) + fig = Makie.Figure(; size = fig_size, figure_padding = figure_padding) + + ctlbar_node = fig[1, 1:2] = Makie.GridLayout() + ctlbar_node.halign = :left + ctlbar_node.valign = :bottom + placeholder_node = fig[2, 1:2] = Makie.GridLayout() + canvas_node = fig[3, 1] + legend_grid = fig[3, 2] = Makie.GridLayout() + statusbar_node = fig[4, 1:2] = Makie.GridLayout() + statusbar_node.halign = :left + + legend_slot = legend_grid[1, 1] + legend_slot[] = Makie.GridLayout() + colorbar_slot = legend_grid[2, 1] + colorbar_slot[] = Makie.GridLayout() + + fig_ctx = PlotFigureContext( + fig, + canvas_node, + legend_grid, + legend_slot, + colorbar_slot, + ctlbar_node, + placeholder_node, + statusbar_node, + ) + + _configure_layout!( + fig_ctx; + interactive = ctx.interactive, + legend_panel_width = legend_panel_width, + ) + return fig_ctx +end + +function _configure_layout!( + fig_ctx::PlotFigureContext; + interactive::Bool = true, + legend_panel_width::Int = LEGEND_WIDTH, +) + layout = fig_ctx.figure.layout + + Makie.rowgap!(layout, GRID_ROW_GAP) + Makie.colgap!(layout, GRID_COL_GAP) + + Makie.rowsize!(layout, 1, Makie.Fixed(interactive ? CTLBAR_HEIGHT : 0)) + Makie.rowsize!(layout, 2, Makie.Fixed(0)) + Makie.rowsize!(layout, 3, Makie.Relative(1.0)) + Makie.rowsize!(layout, 4, Makie.Fixed(interactive ? STATUSBAR_HEIGHT : 0)) + + Makie.colsize!(layout, 1, Makie.Relative(1.0)) + Makie.colsize!(layout, 2, Makie.Fixed(legend_panel_width)) + + Makie.rowgap!(fig_ctx.legend_grid, LEGEND_GAP) + Makie.colgap!(fig_ctx.legend_grid, 0) + + + Makie.rowsize!(fig_ctx.legend_grid, 1, Makie.Auto()) + Makie.rowsize!(fig_ctx.legend_grid, 2, Makie.Auto()) + + return fig_ctx +end + +function _make_canvas!( + fig_ctx::PlotFigureContext; + axis_ctor = Makie.Axis, + axis_options::NamedTuple = NamedTuple(), +) + axis = axis_ctor(fig_ctx.canvas_node; axis_options...) + return axis +end + +# ----------------------------------------------------------------------------- +# Control bar helpers +# ----------------------------------------------------------------------------- + +function _make_ctlbar!( + fig_ctx::PlotFigureContext, + ctx::PlotBackendContext, + button_specs::AbstractVector{ControlButtonSpec}, + toggle_specs::AbstractVector{ControlToggleSpec}; + button_height::Int = max(CTLBAR_HEIGHT - 12, 32), + button_gap::Int = CTLBAR_GAP, +) + if !ctx.interactive || (isempty(button_specs) && isempty(toggle_specs)) + Makie.rowsize!(fig_ctx.figure.layout, 1, Makie.Fixed(0)) + return [], [] + end + + layout = fig_ctx.ctlbar_node + Makie.rowgap!(layout, 0) + Makie.colgap!(layout, button_gap) + Makie.rowsize!(layout, 1, Makie.Fixed(button_height)) + + buttons = Makie.Button[] + toggles = Makie.Toggle[] + col_idx = 1 + + for spec in button_specs + label = _build_button_label(ctx, spec) + button_kwargs = ( + ; label = label, + fontsize = BUTTON_TEXT_FONT_SIZE, + height = button_height, + halign = :left, + ) + if spec.icon !== nothing + width = _preferred_button_width(spec) + if width !== nothing + button_kwargs = (; button_kwargs..., width = width) + end + end + button = Makie.Button(layout[1, col_idx]; button_kwargs...) + push!(buttons, button) + _wire_button_callback!(button, spec, ctx) + col_idx += 1 + end + + for spec in toggle_specs + gl = layout[1, col_idx] = Makie.GridLayout() + gl.halign = :left + gl.valign = :center + + toggle = Makie.Toggle(gl[1, 2]; active = spec.start_active) + if spec.label !== nothing + Makie.Label(gl[1, 1], spec.label, halign = :right) + end + + push!(toggles, toggle) + _wire_toggle_callback!(toggle, spec, ctx) + col_idx += 1 + end + + return buttons, toggles +end + +function _build_button_label(ctx::PlotBackendContext, spec::ControlButtonSpec) + icon_fn = ctx.icons + label_text = spec.label === nothing ? "" : spec.label + if spec.icon === nothing + return label_text + end + try + return icon_fn(spec.icon; text = label_text, gap = 6) + catch err + if err isa MethodError + return isempty(label_text) ? string(spec.icon) : label_text + else + rethrow(err) + end + end +end + +function _preferred_button_width(spec::ControlButtonSpec) + if spec.icon !== nothing && spec.label === nothing + return BUTTON_MIN_WIDTH + end + return nothing +end + +function _wire_button_callback!(button, spec::ControlButtonSpec, ctx::PlotBackendContext) + ensure_statusbar!(ctx) + + Makie.on(button.clicks) do _ + Base.@async begin + try + result = _invoke_button_action(spec.action, ctx, button) + _apply_reaction!(ctx, button, spec.on_success, result) + catch err + _apply_reaction!(ctx, button, spec.on_failure, sprint(showerror, err)) + end + end + end + return button +end + +function _wire_toggle_callback!(toggle, spec::ControlToggleSpec, ctx::PlotBackendContext) + ensure_statusbar!(ctx) + + Makie.on(toggle.active) do is_active + Base.@async begin + original_state = !is_active + try + if is_active + result = _invoke_button_action(spec.action_on, ctx, toggle) + _apply_reaction!(ctx, toggle, spec.on_success_on, result) + else + result = _invoke_button_action(spec.action_off, ctx, toggle) + _apply_reaction!(ctx, toggle, spec.on_success_off, result) + end + catch err + _apply_reaction!(ctx, toggle, spec.on_failure, sprint(showerror, err)) + end + end + end + return toggle +end + +function _invoke_button_action(action::Function, ctx::PlotBackendContext, button) + try + return Base.invokelatest(action, ctx, button) + catch err + if err isa MethodError && err.f === action + try + return Base.invokelatest(action, ctx) + catch err2 + if err2 isa MethodError && err2.f === action + return Base.invokelatest(action) + else + throw(err2) + end + end + else + throw(err) + end + end +end + + +function _apply_reaction!( + ctx::PlotBackendContext, + button, + reaction::Union{Nothing, ControlReaction}, + result, +) + has_color = hasproperty(button, :buttoncolor) + original_color = has_color ? button.buttoncolor[] : nothing + has_label = hasproperty(button, :label) + original_label = has_label ? button.label[] : nothing + + # Determine the status message + status_msg = nothing + if reaction !== nothing && reaction.status_string !== nothing + if reaction.status_string isa Function + status_msg = reaction.status_string(result) + else + status_msg = reaction.status_string + end + elseif result isa AbstractString && !isempty(result) + status_msg = result + end + + # Apply reaction and status update + if status_msg !== nothing + update_status!(ctx, status_msg) + end + + if reaction !== nothing + if reaction.button_color !== nothing && has_color + button.buttoncolor[] = Makie.to_color(reaction.button_color) + end + if reaction.button_label !== nothing + button.label[] = reaction.button_label + end + end + + # Handle timeout and UI restoration + timeout = reaction !== nothing ? reaction.timeout : 1.6 + if timeout !== nothing && isfinite(timeout) + sleep(timeout) + if status_msg !== nothing + clear_status!(ctx) + end + if reaction !== nothing + if reaction.button_color !== nothing && has_color + button.buttoncolor[] = original_color + end + if reaction.button_label !== nothing && has_label + button.label[] = original_label + end + end + end + + return nothing +end + +clear_status!(ctx) = begin + # non-breaking space keeps the row height while looking empty + update_status!(ctx, "\u00A0") + +end + +# ----------------------------------------------------------------------------- +# Legend & colorbar helpers +# ----------------------------------------------------------------------------- + +"""Populate the legend area. Accepts `nothing`, a builder function, or a Makie plot object.""" +function _make_legend!(fig_ctx::PlotFigureContext, content; kwargs...) + slot = fig_ctx.legend_slot + if content === nothing + slot[] = Makie.GridLayout() + Makie.rowsize!(fig_ctx.legend_grid, 1, Makie.Fixed(0)) + return nothing + end + + Makie.rowsize!(fig_ctx.legend_grid, 1, Makie.Auto()) + container = Makie.GridLayout() + slot[] = container + built = _materialize_component!(container[1, 1], content; kwargs...) + if built !== nothing + if hasproperty(built, :valign) + built.valign[] = :top + end + if hasproperty(built, :halign) + built.halign[] = :left + end + end + return built +end + +"""Populate the colorbar stack with zero or more builder specs.""" +function _make_colorbars!( + fig_ctx::PlotFigureContext, + specs::Union{Nothing, AbstractVector}; + kwargs..., +) + slot = fig_ctx.colorbar_slot + if specs === nothing || isempty(specs) + slot[] = Makie.GridLayout() + Makie.rowsize!(fig_ctx.legend_grid, 2, Makie.Fixed(0)) + return Any[] + end + + Makie.rowsize!(fig_ctx.legend_grid, 2, Makie.Auto()) + container = Makie.GridLayout() + slot[] = container + Makie.rowgap!(container, COLORBAR_GAP) + + built = Any[] + row = 1 + for spec in specs + spec === nothing && continue + node = container[row, 1] + push!(built, _materialize_component!(node, spec; kwargs...)) + row += 1 + end + return built +end + +function _materialize_component!(parent, spec; kwargs...) + if spec isa Function + return spec(parent; kwargs...) + elseif Makie.isplot(spec) + parent[] = spec + return spec + else + try + parent[] = spec + return spec + catch err + if err isa MethodError + error("Unsupported component specification $(typeof(spec))") + else + rethrow(err) + end + end + end +end + + +# ----------------------------------------------------------------------------- +# Status helpers +# ----------------------------------------------------------------------------- + +function _make_statusbar!( + fig_ctx::PlotFigureContext, + ctx::PlotBackendContext; + initial_message::AbstractString = "", +) + if !ctx.interactive + Makie.rowsize!(fig_ctx.figure.layout, 4, Makie.Fixed(0)) + return nothing + end + + status_obs = ensure_statusbar!(ctx) + if !isempty(initial_message) + status_obs[] = String(initial_message) + end + + label = Makie.Label(fig_ctx.statusbar_node[1, 1]; + text = status_obs, + fontsize = STATUS_FONT_SIZE, + halign = :left, + tellwidth = false, + tellheight = false, + ) + return label +end + +function ensure_statusbar!(ctx::PlotBackendContext) + if ctx.statusbar === nothing + ctx.statusbar = Makie.Observable("") + end + return ctx.statusbar +end + +function update_status!(ctx::PlotBackendContext, message::AbstractString) + chan = ensure_statusbar!(ctx) + chan[] = String(message) + return chan +end + +# ----------------------------------------------------------------------------- +# Orchestration helpers +# ----------------------------------------------------------------------------- + +function _run_plot_pipeline( + backend_ctx::PlotBackendContext, + plot_fn::Function; + fig_size::Tuple{Int, Int} = FIG_SIZE, + figure_padding::NTuple{4, Int} = FIG_PADDING, + legend_panel_width::Int = LEGEND_WIDTH, + axis_ctor = Makie.Axis, + axis_kwargs::NamedTuple = NamedTuple(), + extra_buttons::AbstractVector{ControlButtonSpec} = ControlButtonSpec[], + initial_status::Union{Nothing, String} = nothing, +) + fig_ctx = _make_figure( + backend_ctx; + fig_size = fig_size, + figure_padding = figure_padding, + legend_panel_width = legend_panel_width, + ) + + axis = + isempty(axis_kwargs) ? + _make_canvas!(fig_ctx; axis_ctor = axis_ctor) : + _make_canvas!(fig_ctx; axis_ctor = axis_ctor, axis_options = axis_kwargs) + + artifacts = plot_fn(fig_ctx, backend_ctx, axis) + artifacts = artifacts === nothing ? PlotBuildArtifacts(axis = axis) : artifacts + + axis = artifacts.axis === nothing ? axis : artifacts.axis + + button_specs = ControlButtonSpec[] + isempty(extra_buttons) || append!(button_specs, extra_buttons) + isempty(artifacts.control_buttons) || append!(button_specs, artifacts.control_buttons) + + buttons, toggles = + _make_ctlbar!(fig_ctx, backend_ctx, button_specs, artifacts.control_toggles) + + legend_obj = _make_legend!(fig_ctx, artifacts.legends) + colorbar_objs = _make_colorbars!(fig_ctx, artifacts.colorbars) + + status_message = artifacts.status_message + if status_message === nothing + status_message = initial_status + end + status_message = status_message === nothing ? "" : status_message + + status_label = _make_statusbar!(fig_ctx, backend_ctx; initial_message = status_message) + if !isempty(status_message) + update_status!(backend_ctx, status_message) + end + + return PlotAssembly( + backend_ctx, + fig_ctx, + fig_ctx.figure, + axis, + buttons, + legend_obj, + colorbar_objs, + status_label, + artifacts, + ) +end + +make_window_context(args...; kwargs...) = _make_window(args...; kwargs...) +make_standard_figure(args...; kwargs...) = _make_figure(args...; kwargs...) +configure_layout!(args...; kwargs...) = _configure_layout!(args...; kwargs...) +make_canvas!(args...; kwargs...) = _make_canvas!(args...; kwargs...) +make_ctlbar!(args...; kwargs...) = _make_ctlbar!(args...; kwargs...) +make_legend!(args...; kwargs...) = _make_legend!(args...; kwargs...) +make_colorbars!(args...; kwargs...) = _make_colorbars!(args...; kwargs...) +make_statusbar!(args...; kwargs...) = _make_statusbar!(args...; kwargs...) +run_plot_pipeline(args...; kwargs...) = _run_plot_pipeline(args...; kwargs...) + +function ensure_export_background!(fig) + if fig !== nothing && hasproperty(fig, :scene) + fig.scene.backgroundcolor[] = Makie.to_color(BG_COLOR_EXPORT) + end + return fig +end + +end # module PlotUIComponents diff --git a/src/uncertainbessels/UncertainBessels.jl b/src/uncertainbessels/UncertainBessels.jl index 435dfd49..c7bf2552 100644 --- a/src/uncertainbessels/UncertainBessels.jl +++ b/src/uncertainbessels/UncertainBessels.jl @@ -1,173 +1,173 @@ -""" - LineCableModels.UncertainBessels - -Uncertainty-aware wrappers for Bessel functions. - -[`UncertainBessels`](@ref) lifts selected functions from `SpecialFunctions` so they accept -`Measurement` and `Complex{Measurement}` inputs. The wrapper evaluates the -underlying function at the nominal complex argument and propagates uncertainty -via first-order finite differences using the four partial derivatives ``\\frac{\\partial \\mathrm{Re} \\, f}{\\partial x}, \\frac{\\partial \\mathrm{Re} \\, f}{\\partial y}, \\frac{\\partial \\mathrm{Im} \\, f}{\\partial x}, \\frac{\\partial \\mathrm{Im} \\, f}{\\partial y}`` with ``x = \\mathrm{Re}(z)`` and ``y = \\mathrm{Im}(z)``. No new Bessel algorithms are implemented: for plain numeric inputs, results and numerical behaviour are those of -`SpecialFunctions`. - -Numerical scaling (as defined by `SpecialFunctions`) is supported for the -“x” variants (e.g. `besselix`, `besselkx`, `besseljx`, …) to improve stability -for large or complex arguments. In particular, the modified functions use -exponential factors to temper growth along ``\\mathrm{Re}(z)`` (e.g. ``I_\\nu`` and ``K_\\nu``); -other scaled variants follow conventions in `SpecialFunctions` and DLMF guidance -for complex arguments. See [NIST:DLMF](@cite) and [6897971](@cite). - -# Overview - -- Thin, uncertainty-aware wrappers around `SpecialFunctions` (`besselj`, `bessely`, - `besseli`, `besselk`, `besselh`) and their scaled counterparts (`…x`). -- For `Complex{Measurement}` inputs, uncertainty is propagated using the 4-component - gradient with respect to ``\\mathrm{Re}(z)`` and ``\\mathrm{Im}(z)``. -- For `Measurement` (real) inputs, a 1-D finite-difference derivative is used. -- No change in semantics for `Real`/`Complex` inputs: calls delegate to `SpecialFunctions`. - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) - -# Usage - -```julia -# do not import SpecialFunctions directly -using LineCableModels.UncertainBessels -z = complex(1.0, 1.0 ± 0.5) -J0_cpl = besselj(0, z) # Complex{Measurement} -J0_nom = besselj(0, value(z)) # nominal comparison -I1 = besselix(1, z) # scaled I1 with uncertainty -``` - -# Numerical notes - -- Scaled modified Bessels remove large exponential factors along ``\\mathrm{Re}(z)`` (e.g., ``I_\\nu`` and ``K_\\nu`` are scaled by opposite signs of ``|\\mathrm{Re}(z)|``), improving conditioning. Scaled forms for the other families follow the definitions in `SpecialFunctions` and DLMF. -- Uncertainty propagation is first order (linearization at the nominal point). - Large uncertainties or strong nonlinearity may reduce accuracy. - -# See also - -- [`LineCableModels.Engine.InternalImpedance`](@ref) -- [`LineCableModels.Engine.EarthImpedance`](@ref) -""" -module UncertainBessels - -# Module-specific dependencies -using ..Commons -using Calculus: Calculus -using SpecialFunctions: SpecialFunctions -using Measurements: Measurements, Measurement - -export besselix, besselkx, besseljx, besselyx, besselhx -export besseli, besselk, besselj, bessely, besselh - -# Complex argument with measurement parts -@inline function _lift_complex_measurement(f, ν, ẑ::Complex{<:Measurement}) - return Measurements.result( - f(ν, Measurements.value(ẑ)), - vcat( - Calculus.gradient( - x -> real(f(ν, complex(x[1], x[2]))), - [reim(Measurements.value(ẑ))...], - ), - Calculus.gradient( - x -> imag(f(ν, complex(x[1], x[2]))), - [reim(Measurements.value(ẑ))...], - ), - ), - ẑ, - ) -end - -# Real argument with measurement -@inline function _lift_real_measurement(f, ν, x::Measurements.Measurement) - x0 = Measurements.value(x) - y0 = f(ν, x0) - dy = Calculus.derivative(t -> f(ν, t), x0) - return Measurements.result(y0, (dy,), x) -end - - -# Complex inputs with uncertainty -@inline besselix(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = - _lift_complex_measurement(SpecialFunctions.besselix, ν, z) -@inline besselkx(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = - _lift_complex_measurement(SpecialFunctions.besselkx, ν, z) -@inline besseljx(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = - _lift_complex_measurement(SpecialFunctions.besseljx, ν, z) -@inline besselyx(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = - _lift_complex_measurement(SpecialFunctions.besselyx, ν, z) -@inline besselhx(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = - _lift_complex_measurement(SpecialFunctions.besselhx, ν, z) -@inline besselj(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = - _lift_complex_measurement(SpecialFunctions.besselj, ν, z) -@inline bessely(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = - _lift_complex_measurement(SpecialFunctions.bessely, ν, z) -@inline besseli(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = - _lift_complex_measurement(SpecialFunctions.besseli, ν, z) -@inline besselk(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = - _lift_complex_measurement(SpecialFunctions.besselk, ν, z) -@inline besselh(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = - _lift_complex_measurement(SpecialFunctions.besselh, ν, z) - -# Real inputs with uncertainty -@inline besselix(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = - _lift_real_measurement(SpecialFunctions.besselix, ν, x) -@inline besselkx(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = - _lift_real_measurement(SpecialFunctions.besselkx, ν, x) -@inline besseljx(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = - _lift_real_measurement(SpecialFunctions.besseljx, ν, x) -@inline besselyx(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = - _lift_real_measurement(SpecialFunctions.besselyx, ν, x) -@inline besselhx(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = - _lift_real_measurement(SpecialFunctions.besselhx, ν, x) -@inline besselj(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = - _lift_real_measurement(SpecialFunctions.besselj, ν, x) -@inline bessely(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = - _lift_real_measurement(SpecialFunctions.bessely, ν, x) -@inline besseli(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = - _lift_real_measurement(SpecialFunctions.besseli, ν, x) -@inline besselk(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = - _lift_real_measurement(SpecialFunctions.besselk, ν, x) -@inline besselh(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = - _lift_real_measurement(SpecialFunctions.besselh, ν, x) - -# Plain Float/Complex fallbacks -@inline besselix(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselix(ν, z) -@inline besselkx(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselkx(ν, z) -@inline besseljx(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besseljx(ν, z) -@inline besselyx(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselyx(ν, z) -@inline besselhx(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselhx(ν, z) -@inline besselj(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselj(ν, z) -@inline bessely(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.bessely(ν, z) -@inline besseli(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besseli(ν, z) -@inline besselk(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselk(ν, z) -@inline besselh(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselh(ν, z) - -@inline besselix(ν, z::Complex{T}) where {T <: AbstractFloat} = - SpecialFunctions.besselix(ν, z) -@inline besselkx(ν, z::Complex{T}) where {T <: AbstractFloat} = - SpecialFunctions.besselkx(ν, z) -@inline besseljx(ν, z::Complex{T}) where {T <: AbstractFloat} = - SpecialFunctions.besseljx(ν, z) -@inline besselyx(ν, z::Complex{T}) where {T <: AbstractFloat} = - SpecialFunctions.besselyx(ν, z) -@inline besselhx(ν, z::Complex{T}) where {T <: AbstractFloat} = - SpecialFunctions.besselhx(ν, z) -@inline besselj(ν, z::Complex{T}) where {T <: AbstractFloat} = - SpecialFunctions.besselj(ν, z) -@inline bessely(ν, z::Complex{T}) where {T <: AbstractFloat} = - SpecialFunctions.bessely(ν, z) -@inline besseli(ν, z::Complex{T}) where {T <: AbstractFloat} = - SpecialFunctions.besseli(ν, z) -@inline besselk(ν, z::Complex{T}) where {T <: AbstractFloat} = - SpecialFunctions.besselk(ν, z) -@inline besselh(ν, z::Complex{T}) where {T <: AbstractFloat} = - SpecialFunctions.besselh(ν, z) - -end # module UncertainBessels +""" + LineCableModels.UncertainBessels + +Uncertainty-aware wrappers for Bessel functions. + +[`UncertainBessels`](@ref) lifts selected functions from `SpecialFunctions` so they accept +`Measurement` and `Complex{Measurement}` inputs. The wrapper evaluates the +underlying function at the nominal complex argument and propagates uncertainty +via first-order finite differences using the four partial derivatives ``\\frac{\\partial \\mathrm{Re} \\, f}{\\partial x}, \\frac{\\partial \\mathrm{Re} \\, f}{\\partial y}, \\frac{\\partial \\mathrm{Im} \\, f}{\\partial x}, \\frac{\\partial \\mathrm{Im} \\, f}{\\partial y}`` with ``x = \\mathrm{Re}(z)`` and ``y = \\mathrm{Im}(z)``. No new Bessel algorithms are implemented: for plain numeric inputs, results and numerical behaviour are those of +`SpecialFunctions`. + +Numerical scaling (as defined by `SpecialFunctions`) is supported for the +“x” variants (e.g. `besselix`, `besselkx`, `besseljx`, …) to improve stability +for large or complex arguments. In particular, the modified functions use +exponential factors to temper growth along ``\\mathrm{Re}(z)`` (e.g. ``I_\\nu`` and ``K_\\nu``); +other scaled variants follow conventions in `SpecialFunctions` and DLMF guidance +for complex arguments. See [NIST:DLMF](@cite) and [6897971](@cite). + +# Overview + +- Thin, uncertainty-aware wrappers around `SpecialFunctions` (`besselj`, `bessely`, + `besseli`, `besselk`, `besselh`) and their scaled counterparts (`…x`). +- For `Complex{Measurement}` inputs, uncertainty is propagated using the 4-component + gradient with respect to ``\\mathrm{Re}(z)`` and ``\\mathrm{Im}(z)``. +- For `Measurement` (real) inputs, a 1-D finite-difference derivative is used. +- No change in semantics for `Real`/`Complex` inputs: calls delegate to `SpecialFunctions`. + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) + +# Usage + +```julia +# do not import SpecialFunctions directly +using LineCableModels.UncertainBessels +z = complex(1.0, 1.0 ± 0.5) +J0_cpl = besselj(0, z) # Complex{Measurement} +J0_nom = besselj(0, value(z)) # nominal comparison +I1 = besselix(1, z) # scaled I1 with uncertainty +``` + +# Numerical notes + +- Scaled modified Bessels remove large exponential factors along ``\\mathrm{Re}(z)`` (e.g., ``I_\\nu`` and ``K_\\nu`` are scaled by opposite signs of ``|\\mathrm{Re}(z)|``), improving conditioning. Scaled forms for the other families follow the definitions in `SpecialFunctions` and DLMF. +- Uncertainty propagation is first order (linearization at the nominal point). + Large uncertainties or strong nonlinearity may reduce accuracy. + +# See also + +- [`LineCableModels.Engine.InternalImpedance`](@ref) +- [`LineCableModels.Engine.EarthImpedance`](@ref) +""" +module UncertainBessels + +# Module-specific dependencies +using ..Commons +using Calculus: Calculus +using SpecialFunctions: SpecialFunctions +using Measurements: Measurements, Measurement + +export besselix, besselkx, besseljx, besselyx, besselhx +export besseli, besselk, besselj, bessely, besselh + +# Complex argument with measurement parts +@inline function _lift_complex_measurement(f, ν, ẑ::Complex{<:Measurement}) + return Measurements.result( + f(ν, Measurements.value(ẑ)), + vcat( + Calculus.gradient( + x -> real(f(ν, complex(x[1], x[2]))), + [reim(Measurements.value(ẑ))...], + ), + Calculus.gradient( + x -> imag(f(ν, complex(x[1], x[2]))), + [reim(Measurements.value(ẑ))...], + ), + ), + ẑ, + ) +end + +# Real argument with measurement +@inline function _lift_real_measurement(f, ν, x::Measurements.Measurement) + x0 = Measurements.value(x) + y0 = f(ν, x0) + dy = Calculus.derivative(t -> f(ν, t), x0) + return Measurements.result(y0, (dy,), x) +end + + +# Complex inputs with uncertainty +@inline besselix(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = + _lift_complex_measurement(SpecialFunctions.besselix, ν, z) +@inline besselkx(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = + _lift_complex_measurement(SpecialFunctions.besselkx, ν, z) +@inline besseljx(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = + _lift_complex_measurement(SpecialFunctions.besseljx, ν, z) +@inline besselyx(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = + _lift_complex_measurement(SpecialFunctions.besselyx, ν, z) +@inline besselhx(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = + _lift_complex_measurement(SpecialFunctions.besselhx, ν, z) +@inline besselj(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = + _lift_complex_measurement(SpecialFunctions.besselj, ν, z) +@inline bessely(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = + _lift_complex_measurement(SpecialFunctions.bessely, ν, z) +@inline besseli(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = + _lift_complex_measurement(SpecialFunctions.besseli, ν, z) +@inline besselk(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = + _lift_complex_measurement(SpecialFunctions.besselk, ν, z) +@inline besselh(ν, z::Complex{<:Measurements.Measurement{T}}) where {T <: AbstractFloat} = + _lift_complex_measurement(SpecialFunctions.besselh, ν, z) + +# Real inputs with uncertainty +@inline besselix(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = + _lift_real_measurement(SpecialFunctions.besselix, ν, x) +@inline besselkx(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = + _lift_real_measurement(SpecialFunctions.besselkx, ν, x) +@inline besseljx(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = + _lift_real_measurement(SpecialFunctions.besseljx, ν, x) +@inline besselyx(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = + _lift_real_measurement(SpecialFunctions.besselyx, ν, x) +@inline besselhx(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = + _lift_real_measurement(SpecialFunctions.besselhx, ν, x) +@inline besselj(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = + _lift_real_measurement(SpecialFunctions.besselj, ν, x) +@inline bessely(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = + _lift_real_measurement(SpecialFunctions.bessely, ν, x) +@inline besseli(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = + _lift_real_measurement(SpecialFunctions.besseli, ν, x) +@inline besselk(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = + _lift_real_measurement(SpecialFunctions.besselk, ν, x) +@inline besselh(ν, x::Measurements.Measurement{T}) where {T <: AbstractFloat} = + _lift_real_measurement(SpecialFunctions.besselh, ν, x) + +# Plain Float/Complex fallbacks +@inline besselix(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselix(ν, z) +@inline besselkx(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselkx(ν, z) +@inline besseljx(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besseljx(ν, z) +@inline besselyx(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselyx(ν, z) +@inline besselhx(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselhx(ν, z) +@inline besselj(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselj(ν, z) +@inline bessely(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.bessely(ν, z) +@inline besseli(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besseli(ν, z) +@inline besselk(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselk(ν, z) +@inline besselh(ν, z::T) where {T <: AbstractFloat} = SpecialFunctions.besselh(ν, z) + +@inline besselix(ν, z::Complex{T}) where {T <: AbstractFloat} = + SpecialFunctions.besselix(ν, z) +@inline besselkx(ν, z::Complex{T}) where {T <: AbstractFloat} = + SpecialFunctions.besselkx(ν, z) +@inline besseljx(ν, z::Complex{T}) where {T <: AbstractFloat} = + SpecialFunctions.besseljx(ν, z) +@inline besselyx(ν, z::Complex{T}) where {T <: AbstractFloat} = + SpecialFunctions.besselyx(ν, z) +@inline besselhx(ν, z::Complex{T}) where {T <: AbstractFloat} = + SpecialFunctions.besselhx(ν, z) +@inline besselj(ν, z::Complex{T}) where {T <: AbstractFloat} = + SpecialFunctions.besselj(ν, z) +@inline bessely(ν, z::Complex{T}) where {T <: AbstractFloat} = + SpecialFunctions.bessely(ν, z) +@inline besseli(ν, z::Complex{T}) where {T <: AbstractFloat} = + SpecialFunctions.besseli(ν, z) +@inline besselk(ν, z::Complex{T}) where {T <: AbstractFloat} = + SpecialFunctions.besselk(ν, z) +@inline besselh(ν, z::Complex{T}) where {T <: AbstractFloat} = + SpecialFunctions.besselh(ν, z) + +end # module UncertainBessels diff --git a/src/utils/Utils.jl b/src/utils/Utils.jl index 76b96293..7850e453 100644 --- a/src/utils/Utils.jl +++ b/src/utils/Utils.jl @@ -1,498 +1,498 @@ -""" - LineCableModels.Utils - -The [`Utils`](@ref) module provides utility functions for the [`LineCableModels.jl`](index.md) package. This module includes functions for handling measurements, numerical comparisons, and other common tasks. - -# Overview - -- Provides general constants used throughout the package. -- Includes utility functions for numerical comparisons and handling measurements. -- Contains functions to compute uncertainties and bounds for measurements. - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module Utils - -# Export public API -export resolve_T, coerce_to_T, is_headless, is_in_testset, display_path -export set_verbosity! - -export to_nominal, - to_certain, - percent_to_uncertain, - bias_to_uncertain, - to_upper, - to_lower, - percent_error - -export _to_σ, _bessel_diff, symtrans!, line_transpose! - -# Module-specific dependencies -using ..Commons -using ..UncertainBessels: besselk -using Measurements: Measurement, value, uncertainty, measurement, ±, Measurements, result -using Statistics -using Plots -using LinearAlgebra - -""" -$(TYPEDSIGNATURES) - -Returns the nominal (deterministic) value of inputs that may contain -`Measurements.Measurement` numbers, recursively handling Complex and arrays. - -# Arguments - -- `x`: Input value which can be a `Measurement` type or any other type. - -# Returns - -- Measurement → its `value` -- Complex → `complex(to_nominal(real(z)), to_nominal(imag(z)))` -- AbstractArray → broadcasts `to_nominal` elementwise -- Anything else → returned unchanged - -# Examples - -```julia -using Measurements - -$(FUNCTIONNAME)(1.0) # Output: 1.0 -$(FUNCTIONNAME)(5.2 ± 0.3) # Output: 5.2 -``` -""" -to_nominal(x::Measurement) = value(x) -to_nominal(z::Complex) = complex(to_nominal(real(z)), to_nominal(imag(z))) -to_nominal(A::AbstractArray) = to_nominal.(A) -to_nominal(x) = x - -""" -$(TYPEDSIGNATURES) - -Converts a measurement to a value with zero uncertainty, retaining the numeric type `Measurement`. - -# Arguments - -- `value`: Input value that may be a `Measurement` type or another type. - -# Returns - -- If input is a `Measurement`, returns the same value with zero uncertainty; otherwise returns the original value unchanged. - -# Examples - -```julia -x = 5.0 ± 0.1 -result = $(FUNCTIONNAME)(x) # Output: 5.0 ± 0.0 - -y = 10.0 -result = $(FUNCTIONNAME)(y) # Output: 10.0 -``` -""" -function to_certain(value) - return value isa Measurement ? (Measurements.value(value) ± 0.0) : value -end - -""" -$(TYPEDSIGNATURES) - -Converts a value to a measurement with uncertainty based on percentage. - -# Arguments - -- `val`: The nominal value. -- `perc`: The percentage uncertainty (0 to 100). - -# Returns - -- A `Measurement` type with the given value and calculated uncertainty. - -# Examples - -```julia -using Measurements - -$(FUNCTIONNAME)(100.0, 5) # Output: 100.0 ± 5.0 -$(FUNCTIONNAME)(10.0, 10) # Output: 10.0 ± 1.0 -``` -""" -function percent_to_uncertain(val, perc) #perc from 0 to 100 - measurement(val, (perc * val) / 100) -end - -""" -$(TYPEDSIGNATURES) - -Computes the uncertainty of a measurement by incorporating systematic bias. - -# Arguments - -- `nominal`: The deterministic nominal value (Float64). -- `measurements`: A vector of `Measurement` values from the `Measurements.jl` package. - -# Returns - -- A new `Measurement` object representing the mean measurement value with an uncertainty that accounts for both statistical variation and systematic bias. - -# Notes - -- Computes the mean value and its associated uncertainty from the given `measurements`. -- Determines the **bias** as the absolute difference between the deterministic `nominal` value and the mean measurement. -- The final uncertainty is the sum of the standard uncertainty (`sigma_mean`) and the systematic bias. - -# Examples -```julia -using Measurements - -nominal = 10.0 -measurements = [10.2 ± 0.1, 9.8 ± 0.2, 10.1 ± 0.15] -result = $(FUNCTIONNAME)(nominal, measurements) -println(result) # Output: Measurement with adjusted uncertainty -``` -""" -function bias_to_uncertain(nominal::Float64, measurements::Vector{<:Measurement}) - # Compute the mean value and uncertainty from the measurements - mean_measurement = mean(measurements) - mean_value = Measurements.value(mean_measurement) # Central value - sigma_mean = Measurements.uncertainty(mean_measurement) # Uncertainty of the mean - # Compute the bias (deterministic nominal value minus mean measurement) - bias = abs(nominal - mean_value) - return mean_value ± (sigma_mean + bias) -end - -""" -$(TYPEDSIGNATURES) - -Computes the upper bound of a measurement value. - -# Arguments - -- `m`: A numerical value, expected to be of type `Measurement` from the `Measurements.jl` package. - -# Returns - -- The upper bound of `m`, computed as `value(m) + uncertainty(m)` if `m` is a `Measurement`. -- `NaN` if `m` is not a `Measurement`. - -# Examples - -```julia -using Measurements - -m = 10.0 ± 2.0 -upper = $(FUNCTIONNAME)(m) # Output: 12.0 - -not_a_measurement = 5.0 -upper_invalid = $(FUNCTIONNAME)(not_a_measurement) # Output: NaN -``` -""" -function to_upper(m::Number) - if m isa Measurement - return Measurements.value(m) + Measurements.uncertainty(m) - else - return NaN - end -end - -""" -$(TYPEDSIGNATURES) - -Computes the lower bound of a measurement value. - -# Arguments - -- `m`: A numerical value, expected to be of type `Measurement` from the `Measurements.jl` package. - -# Returns - -- The lower bound, computed as `value(m) - uncertainty(m)` if `m` is a `Measurement`. -- `NaN` if `m` is not a `Measurement`. - -# Examples - -```julia -using Measurements - -m = 10.0 ± 2.0 -lower = $(FUNCTIONNAME)(m) # Output: 8.0 - -not_a_measurement = 5.0 -lower_invalid = $(FUNCTIONNAME)(not_a_measurement) # Output: NaN -``` -""" -function to_lower(m::Number) - if m isa Measurement - return Measurements.value(m) - Measurements.uncertainty(m) - else - return NaN - end -end - -""" -$(TYPEDSIGNATURES) - -Computes the percentage uncertainty of a measurement. - -# Arguments - -- `m`: A numerical value, expected to be of type `Measurement` from the `Measurements.jl` package. - -# Returns - -- The percentage uncertainty, computed as `100 * uncertainty(m) / value(m)`, if `m` is a `Measurement`. -- `NaN` if `m` is not a `Measurement`. - -# Examples - -```julia -using Measurements - -m = 10.0 ± 2.0 -percent_err = $(FUNCTIONNAME)(m) # Output: 20.0 - -not_a_measurement = 5.0 -percent_err_invalid = $(FUNCTIONNAME)(not_a_measurement) # Output: NaN -``` -""" -function percent_error(m::Number) - if m isa Measurement - return 100 * Measurements.uncertainty(m) / Measurements.value(m) - else - return NaN - end -end - -@inline _nudge_float(x::AbstractFloat) = isfinite(x) && x == trunc(x) ? nextfloat(x) : x #redundant and I dont care - -_coerce_args_to_T(args...) = - any(x -> x isa Measurement, args) ? Measurement{BASE_FLOAT} : BASE_FLOAT - -# Promote scalar to T if T is Measurement; otherwise take nominal if x is Measurement. -function _coerce_scalar_to_T(x, ::Type{T}) where {T} - if T <: Measurement - return x isa Measurement ? x : (zero(T) + x) - else - return x isa Measurement ? T(value(x)) : convert(T, x) - end -end - -# Arrays: promote/demote elementwise, preserving shape. Arrays NEVER decide T. -function _coerce_array_to_T(A::AbstractArray, ::Type{T}) where {T} - if T <: Measurement - return (eltype(A) === T) ? A : (A .+ zero(T)) # Real → Measurement(σ=0) - elseif eltype(A) <: Measurement - B = value.(A) # Measurement → Real (nominal) - return (eltype(B) === T) ? B : convert.(T, B) - else - return (eltype(A) === T) ? A : convert.(T, A) - end -end - -""" -$(TYPEDSIGNATURES) - -Determines if the current execution environment is headless (without display capability). - -# Returns - -- `true` if running in a continuous integration environment or without display access. -- `false` otherwise when a display is available. - -# Examples - -```julia -if $(FUNCTIONNAME)() - # Use non-graphical backend - gr() -else - # Use interactive backend - plotlyjs() -end -``` -""" -function is_headless()::Bool - # 1. Check for common CI environment variables - if get(ENV, "CI", "false") == "true" - return true - end - - # 2. Check if a display is available (primarily for Linux) - if !haskey(ENV, "DISPLAY") && Sys.islinux() - return true - end - - # 3. Check for GR backend's specific headless setting - if get(ENV, "GKSwstype", "") in ("100", "nul", "nil") - return true - end - - return false -end - -function display_path(file_name) - return is_headless() ? basename(file_name) : relpath(file_name) -end - -""" -$(TYPEDSIGNATURES) - -Checks if the code is running inside a `@testset` by checking if `Test` is loaded -in the current session and then calling `get_testset_depth()`. -""" -function is_in_testset() - # Start with the current module - current_module = @__MODULE__ - - # Walk up the module tree (e.g., from the sandbox to Main) - while true - if isdefined(current_module, :Test) && - isdefined(current_module.Test, :get_testset_depth) - # Found the Test module, check the test set depth - return current_module.Test.get_testset_depth() > 0 - end - - # Move to the parent module - parent = parentmodule(current_module) - if parent === current_module # Reached the top (Main) - break - end - current_module = parent - end - - return false -end - - - -""" -Apply `f` to every square block of `M` defined by `map`, in-place. - -- `M`: n×n or n×n×nf (any eltype). -- `map`: length-n; equal ids => same block (non-contiguous ok). -- `f`: function like `f(B::AbstractMatrix, args...) -> k×k Matrix`. -- `args...`: extra positional args passed to `f`. -- `slice_positions`: positions in `args` that should be indexed as `args[i][idx]` - per block (useful for `phase_map`). - -Returns `M`. -""" -function block_transform!(M, - map::AbstractVector{<:Integer}, - f::F, - args...; slice_positions = Int[]) where {F} - n = size(M, 1) - (size(M, 2) == n && length(map) == n) || throw(ArgumentError("shape mismatch")) - groups = unique(map) # preserve first-seen order - blocks = [findall(==(g), map) for g in groups] - - # helper to build per-block args (slice selected ones) - make_args(idx) = - ntuple(i -> (i in slice_positions ? args[i][idx] : args[i]), length(args)) - - if ndims(M) == 2 - for idx in blocks - Bv = @view M[idx, idx] - R = f(Matrix(Bv), make_args(idx)...) # f decides what to do - size(R) == size(Bv) || throw(ArgumentError("f must return $(size(Bv))")) - @inbounds Bv .= R - end - elseif ndims(M) == 3 - _, _, nf = size(M) - for k in 1:nf - for idx in blocks - Bv = @view M[idx, idx, k] - R = f(Matrix(Bv), make_args(idx)...) - size(R) == size(Bv) || throw(ArgumentError("f must return $(size(Bv))")) - @inbounds Bv .= R - end - end - else - throw(ArgumentError("M must be 2D or 3D")) - end - return M -end - -# Non-mutating -block_transform(M, cmap, f, args...; slice_positions = Int[]) = - block_transform!(copy(M), cmap, f, args...; slice_positions = slice_positions) - - -# Reciprocity symmetrization — in place -symtrans!(A) = (A .= 0.5 .* (A .+ transpose(A)); A) - -# Reciprocity symmetrization (power lines want transpose, not adjoint) -symtrans(A) = (A .+ transpose(A)) / 2 - - -# Circulant projection (N×N), least-squares fit: C[i,j] = c[(j-i) mod N] -function line_transpose!(A::AbstractMatrix) - n = size(A, 1); - n == size(A, 2) || throw(ArgumentError("square")) - c = similar(diag(A)) # length n - - # Average wrap-diagonals (use mod to avoid negatives) - @inbounds for k in 0:(n-1) - s = zero(eltype(A)) - for i in 1:n - j = 1 + mod(i-1 + k, n) - s += A[i, j] - end - c[k+1] = s / n - end - # Write back circulant matrix - @inbounds for i in 1:n, j in 1:n - A[i, j] = c[mod1(j - i + 1, n)] #c[1+mod(j-i, n)] - end - return A -end - - -function isdiag_approx(A; rtol = 1e-8, atol = 1e-8) - isapprox(A, Diagonal(diag(A)); rtol = rtol, atol = atol) -end - -function offdiag_ratio(A) - n = size(A, 1) - n == size(A, 2) || throw(ArgumentError("square")) - T = real(float(eltype(A))) - dmax = zero(T) - odmax = zero(T) - @inbounds for j in 1:n - dj = abs(A[j, j]) - dmax = dj > dmax ? dj : dmax - for i in 1:n - i == j && continue - v = abs(A[i, j]) - odmax = v > odmax ? v : odmax - end - end - return odmax / max(dmax, eps(T)) -end - -isdiag_rel(A; τ = 1e-4) = offdiag_ratio(A) ≤ τ - -function issymmetric_approx(A; rtol = 1e-8, atol = 1e-8) - size(A, 1) == size(A, 2) || return false - return isapprox(A, transpose(A); rtol = rtol, atol = atol) -end - - -@inline _to_σ(ρ) = isinf(ρ) ? zero(ρ) : (iszero(ρ) ? inv(zero(ρ)) : inv(ρ)) - -@inline function _bessel_diff(γs, d::T, D::T) where {T} - zmax = max(abs(γs)*d, abs(γs)*D) - return isapprox(to_nominal(zmax), 0.0, atol = TOL) ? log(D/d) : - (besselk(0, γs*d) - besselk(0, γs*D)) -end - -include("logging.jl") -include("typecoercion.jl") -include("macros.jl") - -end # module Utils +""" + LineCableModels.Utils + +The [`Utils`](@ref) module provides utility functions for the [`LineCableModels.jl`](index.md) package. This module includes functions for handling measurements, numerical comparisons, and other common tasks. + +# Overview + +- Provides general constants used throughout the package. +- Includes utility functions for numerical comparisons and handling measurements. +- Contains functions to compute uncertainties and bounds for measurements. + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module Utils + +# Export public API +export resolve_T, coerce_to_T, is_headless, is_in_testset, display_path +export set_verbosity! + +export to_nominal, + to_certain, + percent_to_uncertain, + bias_to_uncertain, + to_upper, + to_lower, + percent_error + +export _to_σ, _bessel_diff, symtrans!, line_transpose! + +# Module-specific dependencies +using ..Commons +using ..UncertainBessels: besselk +using Measurements: Measurement, value, uncertainty, measurement, ±, Measurements, result +using Statistics +using Plots +using LinearAlgebra + +""" +$(TYPEDSIGNATURES) + +Returns the nominal (deterministic) value of inputs that may contain +`Measurements.Measurement` numbers, recursively handling Complex and arrays. + +# Arguments + +- `x`: Input value which can be a `Measurement` type or any other type. + +# Returns + +- Measurement → its `value` +- Complex → `complex(to_nominal(real(z)), to_nominal(imag(z)))` +- AbstractArray → broadcasts `to_nominal` elementwise +- Anything else → returned unchanged + +# Examples + +```julia +using Measurements + +$(FUNCTIONNAME)(1.0) # Output: 1.0 +$(FUNCTIONNAME)(5.2 ± 0.3) # Output: 5.2 +``` +""" +to_nominal(x::Measurement) = value(x) +to_nominal(z::Complex) = complex(to_nominal(real(z)), to_nominal(imag(z))) +to_nominal(A::AbstractArray) = to_nominal.(A) +to_nominal(x) = x + +""" +$(TYPEDSIGNATURES) + +Converts a measurement to a value with zero uncertainty, retaining the numeric type `Measurement`. + +# Arguments + +- `value`: Input value that may be a `Measurement` type or another type. + +# Returns + +- If input is a `Measurement`, returns the same value with zero uncertainty; otherwise returns the original value unchanged. + +# Examples + +```julia +x = 5.0 ± 0.1 +result = $(FUNCTIONNAME)(x) # Output: 5.0 ± 0.0 + +y = 10.0 +result = $(FUNCTIONNAME)(y) # Output: 10.0 +``` +""" +function to_certain(value) + return value isa Measurement ? (Measurements.value(value) ± 0.0) : value +end + +""" +$(TYPEDSIGNATURES) + +Converts a value to a measurement with uncertainty based on percentage. + +# Arguments + +- `val`: The nominal value. +- `perc`: The percentage uncertainty (0 to 100). + +# Returns + +- A `Measurement` type with the given value and calculated uncertainty. + +# Examples + +```julia +using Measurements + +$(FUNCTIONNAME)(100.0, 5) # Output: 100.0 ± 5.0 +$(FUNCTIONNAME)(10.0, 10) # Output: 10.0 ± 1.0 +``` +""" +function percent_to_uncertain(val, perc) #perc from 0 to 100 + measurement(val, (perc * val) / 100) +end + +""" +$(TYPEDSIGNATURES) + +Computes the uncertainty of a measurement by incorporating systematic bias. + +# Arguments + +- `nominal`: The deterministic nominal value (Float64). +- `measurements`: A vector of `Measurement` values from the `Measurements.jl` package. + +# Returns + +- A new `Measurement` object representing the mean measurement value with an uncertainty that accounts for both statistical variation and systematic bias. + +# Notes + +- Computes the mean value and its associated uncertainty from the given `measurements`. +- Determines the **bias** as the absolute difference between the deterministic `nominal` value and the mean measurement. +- The final uncertainty is the sum of the standard uncertainty (`sigma_mean`) and the systematic bias. + +# Examples +```julia +using Measurements + +nominal = 10.0 +measurements = [10.2 ± 0.1, 9.8 ± 0.2, 10.1 ± 0.15] +result = $(FUNCTIONNAME)(nominal, measurements) +println(result) # Output: Measurement with adjusted uncertainty +``` +""" +function bias_to_uncertain(nominal::Float64, measurements::Vector{<:Measurement}) + # Compute the mean value and uncertainty from the measurements + mean_measurement = mean(measurements) + mean_value = Measurements.value(mean_measurement) # Central value + sigma_mean = Measurements.uncertainty(mean_measurement) # Uncertainty of the mean + # Compute the bias (deterministic nominal value minus mean measurement) + bias = abs(nominal - mean_value) + return mean_value ± (sigma_mean + bias) +end + +""" +$(TYPEDSIGNATURES) + +Computes the upper bound of a measurement value. + +# Arguments + +- `m`: A numerical value, expected to be of type `Measurement` from the `Measurements.jl` package. + +# Returns + +- The upper bound of `m`, computed as `value(m) + uncertainty(m)` if `m` is a `Measurement`. +- `NaN` if `m` is not a `Measurement`. + +# Examples + +```julia +using Measurements + +m = 10.0 ± 2.0 +upper = $(FUNCTIONNAME)(m) # Output: 12.0 + +not_a_measurement = 5.0 +upper_invalid = $(FUNCTIONNAME)(not_a_measurement) # Output: NaN +``` +""" +function to_upper(m::Number) + if m isa Measurement + return Measurements.value(m) + Measurements.uncertainty(m) + else + return NaN + end +end + +""" +$(TYPEDSIGNATURES) + +Computes the lower bound of a measurement value. + +# Arguments + +- `m`: A numerical value, expected to be of type `Measurement` from the `Measurements.jl` package. + +# Returns + +- The lower bound, computed as `value(m) - uncertainty(m)` if `m` is a `Measurement`. +- `NaN` if `m` is not a `Measurement`. + +# Examples + +```julia +using Measurements + +m = 10.0 ± 2.0 +lower = $(FUNCTIONNAME)(m) # Output: 8.0 + +not_a_measurement = 5.0 +lower_invalid = $(FUNCTIONNAME)(not_a_measurement) # Output: NaN +``` +""" +function to_lower(m::Number) + if m isa Measurement + return Measurements.value(m) - Measurements.uncertainty(m) + else + return NaN + end +end + +""" +$(TYPEDSIGNATURES) + +Computes the percentage uncertainty of a measurement. + +# Arguments + +- `m`: A numerical value, expected to be of type `Measurement` from the `Measurements.jl` package. + +# Returns + +- The percentage uncertainty, computed as `100 * uncertainty(m) / value(m)`, if `m` is a `Measurement`. +- `NaN` if `m` is not a `Measurement`. + +# Examples + +```julia +using Measurements + +m = 10.0 ± 2.0 +percent_err = $(FUNCTIONNAME)(m) # Output: 20.0 + +not_a_measurement = 5.0 +percent_err_invalid = $(FUNCTIONNAME)(not_a_measurement) # Output: NaN +``` +""" +function percent_error(m::Number) + if m isa Measurement + return 100 * Measurements.uncertainty(m) / Measurements.value(m) + else + return NaN + end +end + +@inline _nudge_float(x::AbstractFloat) = isfinite(x) && x == trunc(x) ? nextfloat(x) : x #redundant and I dont care + +_coerce_args_to_T(args...) = + any(x -> x isa Measurement, args) ? Measurement{BASE_FLOAT} : BASE_FLOAT + +# Promote scalar to T if T is Measurement; otherwise take nominal if x is Measurement. +function _coerce_scalar_to_T(x, ::Type{T}) where {T} + if T <: Measurement + return x isa Measurement ? x : (zero(T) + x) + else + return x isa Measurement ? T(value(x)) : convert(T, x) + end +end + +# Arrays: promote/demote elementwise, preserving shape. Arrays NEVER decide T. +function _coerce_array_to_T(A::AbstractArray, ::Type{T}) where {T} + if T <: Measurement + return (eltype(A) === T) ? A : (A .+ zero(T)) # Real → Measurement(σ=0) + elseif eltype(A) <: Measurement + B = value.(A) # Measurement → Real (nominal) + return (eltype(B) === T) ? B : convert.(T, B) + else + return (eltype(A) === T) ? A : convert.(T, A) + end +end + +""" +$(TYPEDSIGNATURES) + +Determines if the current execution environment is headless (without display capability). + +# Returns + +- `true` if running in a continuous integration environment or without display access. +- `false` otherwise when a display is available. + +# Examples + +```julia +if $(FUNCTIONNAME)() + # Use non-graphical backend + gr() +else + # Use interactive backend + plotlyjs() +end +``` +""" +function is_headless()::Bool + # 1. Check for common CI environment variables + if get(ENV, "CI", "false") == "true" + return true + end + + # 2. Check if a display is available (primarily for Linux) + if !haskey(ENV, "DISPLAY") && Sys.islinux() + return true + end + + # 3. Check for GR backend's specific headless setting + if get(ENV, "GKSwstype", "") in ("100", "nul", "nil") + return true + end + + return false +end + +function display_path(file_name) + return is_headless() ? basename(file_name) : relpath(file_name) +end + +""" +$(TYPEDSIGNATURES) + +Checks if the code is running inside a `@testset` by checking if `Test` is loaded +in the current session and then calling `get_testset_depth()`. +""" +function is_in_testset() + # Start with the current module + current_module = @__MODULE__ + + # Walk up the module tree (e.g., from the sandbox to Main) + while true + if isdefined(current_module, :Test) && + isdefined(current_module.Test, :get_testset_depth) + # Found the Test module, check the test set depth + return current_module.Test.get_testset_depth() > 0 + end + + # Move to the parent module + parent = parentmodule(current_module) + if parent === current_module # Reached the top (Main) + break + end + current_module = parent + end + + return false +end + + + +""" +Apply `f` to every square block of `M` defined by `map`, in-place. + +- `M`: n×n or n×n×nf (any eltype). +- `map`: length-n; equal ids => same block (non-contiguous ok). +- `f`: function like `f(B::AbstractMatrix, args...) -> k×k Matrix`. +- `args...`: extra positional args passed to `f`. +- `slice_positions`: positions in `args` that should be indexed as `args[i][idx]` + per block (useful for `phase_map`). + +Returns `M`. +""" +function block_transform!(M, + map::AbstractVector{<:Integer}, + f::F, + args...; slice_positions = Int[]) where {F} + n = size(M, 1) + (size(M, 2) == n && length(map) == n) || throw(ArgumentError("shape mismatch")) + groups = unique(map) # preserve first-seen order + blocks = [findall(==(g), map) for g in groups] + + # helper to build per-block args (slice selected ones) + make_args(idx) = + ntuple(i -> (i in slice_positions ? args[i][idx] : args[i]), length(args)) + + if ndims(M) == 2 + for idx in blocks + Bv = @view M[idx, idx] + R = f(Matrix(Bv), make_args(idx)...) # f decides what to do + size(R) == size(Bv) || throw(ArgumentError("f must return $(size(Bv))")) + @inbounds Bv .= R + end + elseif ndims(M) == 3 + _, _, nf = size(M) + for k in 1:nf + for idx in blocks + Bv = @view M[idx, idx, k] + R = f(Matrix(Bv), make_args(idx)...) + size(R) == size(Bv) || throw(ArgumentError("f must return $(size(Bv))")) + @inbounds Bv .= R + end + end + else + throw(ArgumentError("M must be 2D or 3D")) + end + return M +end + +# Non-mutating +block_transform(M, cmap, f, args...; slice_positions = Int[]) = + block_transform!(copy(M), cmap, f, args...; slice_positions = slice_positions) + + +# Reciprocity symmetrization — in place +symtrans!(A) = (A .= 0.5 .* (A .+ transpose(A)); A) + +# Reciprocity symmetrization (power lines want transpose, not adjoint) +symtrans(A) = (A .+ transpose(A)) / 2 + + +# Circulant projection (N×N), least-squares fit: C[i,j] = c[(j-i) mod N] +function line_transpose!(A::AbstractMatrix) + n = size(A, 1); + n == size(A, 2) || throw(ArgumentError("square")) + c = similar(diag(A)) # length n + + # Average wrap-diagonals (use mod to avoid negatives) + @inbounds for k in 0:(n-1) + s = zero(eltype(A)) + for i in 1:n + j = 1 + mod(i-1 + k, n) + s += A[i, j] + end + c[k+1] = s / n + end + # Write back circulant matrix + @inbounds for i in 1:n, j in 1:n + A[i, j] = c[mod1(j - i + 1, n)] #c[1+mod(j-i, n)] + end + return A +end + + +function isdiag_approx(A; rtol = 1e-8, atol = 1e-8) + isapprox(A, Diagonal(diag(A)); rtol = rtol, atol = atol) +end + +function offdiag_ratio(A) + n = size(A, 1) + n == size(A, 2) || throw(ArgumentError("square")) + T = real(float(eltype(A))) + dmax = zero(T) + odmax = zero(T) + @inbounds for j in 1:n + dj = abs(A[j, j]) + dmax = dj > dmax ? dj : dmax + for i in 1:n + i == j && continue + v = abs(A[i, j]) + odmax = v > odmax ? v : odmax + end + end + return odmax / max(dmax, eps(T)) +end + +isdiag_rel(A; τ = 1e-4) = offdiag_ratio(A) ≤ τ + +function issymmetric_approx(A; rtol = 1e-8, atol = 1e-8) + size(A, 1) == size(A, 2) || return false + return isapprox(A, transpose(A); rtol = rtol, atol = atol) +end + + +@inline _to_σ(ρ) = isinf(ρ) ? zero(ρ) : (iszero(ρ) ? inv(zero(ρ)) : inv(ρ)) + +@inline function _bessel_diff(γs, d::T, D::T) where {T} + zmax = max(abs(γs)*d, abs(γs)*D) + return isapprox(to_nominal(zmax), 0.0, atol = TOL) ? log(D/d) : + (besselk(0, γs*d) - besselk(0, γs*D)) +end + +include("logging.jl") +include("typecoercion.jl") +include("macros.jl") + +end # module Utils diff --git a/src/utils/commondeps.jl b/src/utils/commondeps.jl index ec7185e7..014c80da 100644 --- a/src/utils/commondeps.jl +++ b/src/utils/commondeps.jl @@ -1 +1 @@ -using DocStringExtensions, Reexport, ForceImport +using DocStringExtensions, Reexport, ForceImport diff --git a/src/utils/logging.jl b/src/utils/logging.jl index 62243ee6..52f18a15 100644 --- a/src/utils/logging.jl +++ b/src/utils/logging.jl @@ -1,53 +1,53 @@ -using Logging -using Logging: AbstractLogger, LogLevel, Info, global_logger -using LoggingExtras: TeeLogger, FileLogger -using Dates -using Printf - - - -struct TimestampLogger <: AbstractLogger - logger::AbstractLogger -end - -Logging.min_enabled_level(logger::TimestampLogger) = - Logging.min_enabled_level(logger.logger) -Logging.shouldlog(logger::TimestampLogger, level, _module, group, id) = - Logging.shouldlog(logger.logger, level, _module, group, id) - -function Logging.handle_message(logger::TimestampLogger, level, message, _module, group, id, - filepath, line; kwargs...) - timestamp = Dates.format(now(), "yyyy-mm-dd HH:MM:SS") - new_message = "[$timestamp] $message" - Logging.handle_message(logger.logger, level, new_message, _module, group, id, - filepath, line; kwargs...) -end - -function set_verbosity!(verbosity::Int, logfile::Union{String, Nothing} = nothing) - level = verbosity >= 2 ? Logging.Debug : - verbosity == 1 ? Logging.Info : Logging.Warn - - # Create console logger - console_logger = ConsoleLogger(stderr, level) - - if isnothing(logfile) - # Log to console only - global_logger(TimestampLogger(console_logger)) - else - # Try to set up file logging with fallback to console-only - try - file_logger = FileLogger(logfile, level) - combined_logger = TeeLogger(console_logger, file_logger) - global_logger(TimestampLogger(combined_logger)) - catch e - @warn "Failed to set up file logging to $(display_path(logfile)): $e" - - global_logger(TimestampLogger(console_logger)) - end - end -end - -function __init__() - # Set a default logging level when the package is loaded at runtime. - set_verbosity!(0) -end +using Logging +using Logging: AbstractLogger, LogLevel, Info, global_logger +using LoggingExtras: TeeLogger, FileLogger +using Dates +using Printf + + + +struct TimestampLogger <: AbstractLogger + logger::AbstractLogger +end + +Logging.min_enabled_level(logger::TimestampLogger) = + Logging.min_enabled_level(logger.logger) +Logging.shouldlog(logger::TimestampLogger, level, _module, group, id) = + Logging.shouldlog(logger.logger, level, _module, group, id) + +function Logging.handle_message(logger::TimestampLogger, level, message, _module, group, id, + filepath, line; kwargs...) + timestamp = Dates.format(now(), "yyyy-mm-dd HH:MM:SS") + new_message = "[$timestamp] $message" + Logging.handle_message(logger.logger, level, new_message, _module, group, id, + filepath, line; kwargs...) +end + +function set_verbosity!(verbosity::Int, logfile::Union{String, Nothing} = nothing) + level = verbosity >= 2 ? Logging.Debug : + verbosity == 1 ? Logging.Info : Logging.Warn + + # Create console logger + console_logger = ConsoleLogger(stderr, level) + + if isnothing(logfile) + # Log to console only + global_logger(TimestampLogger(console_logger)) + else + # Try to set up file logging with fallback to console-only + try + file_logger = FileLogger(logfile, level) + combined_logger = TeeLogger(console_logger, file_logger) + global_logger(TimestampLogger(combined_logger)) + catch e + @warn "Failed to set up file logging to $(display_path(logfile)): $e" + + global_logger(TimestampLogger(console_logger)) + end + end +end + +function __init__() + # Set a default logging level when the package is loaded at runtime. + set_verbosity!(0) +end diff --git a/src/utils/macros.jl b/src/utils/macros.jl index 8c6bd6dd..a8a9672c 100644 --- a/src/utils/macros.jl +++ b/src/utils/macros.jl @@ -1,263 +1,263 @@ -export @parameterize, @measurify - -using MacroTools - -macro parameterize(container_expr, union_expr) - # Evaluate the Union type from the provided expression - local union_type - try - # Core.eval gets the *value* of the symbol passed in (e.g., the actual Union type) - union_type = Core.eval(__module__, union_expr) - catch e - Base.error("Expression `$union_expr` could not be evaluated. Make sure it's a defined const or type.") - end - - # Sanity check - if !(union_type isa Union) - Base.error("Second argument must be a Union type. Got a `$(typeof(union_type))` instead.") - end - - # Base.uniontypes gets the component types, e.g., (Float64, Measurement{Float64}) - component_types = Base.uniontypes(union_type) - - # Define a recursive function to substitute the placeholder `_` - function substitute_placeholder(expr, replacement_type) - # If the current part of the expression is the placeholder symbol, - # we replace it with the target type (e.g., Float64). - if expr == :_ - return replacement_type - # If the current part is another expression (like `Array{_,3}`), - # we need to recurse into its arguments to find the placeholder. - elseif expr isa Expr - # Rebuild the expression with the substituted arguments. - new_args = [substitute_placeholder(arg, replacement_type) for arg in expr.args] - return Expr(expr.head, new_args...) - # Otherwise, it's a literal or symbol we don't need to change (e.g., `:Array` or `3`). - else - return expr - end - end - - # Build the list of new, concrete types - parameterized_types = [substitute_placeholder(container_expr, t) for t in component_types] - - # Wrap the new types in a single `Union{...}` expression and escape - final_expr = Expr(:curly, :Union, parameterized_types...) - return esc(final_expr) -end - -""" - @measurify(function_definition) - -Wraps a function definition. If any argument tied to a parametric type `T` is a -`Measurement`, this macro automatically promotes any other arguments of the same -parametric type `T` to `Measurement` with zero uncertainty. Other arguments -(e.g., `i::Int`) are forwarded without changes. -""" -macro measurify(def) - # --- helpers at expand time --- - function _contains_tvar(expr, typevars::Set{Symbol}) - found = false - MacroTools.postwalk(expr) do x - if x isa Symbol && x in typevars - found = true - end - x - end - found - end - function _relax_container_type(ty, typevars::Set{Symbol}, bounds::Dict{Symbol,Any}) - if ty isa Expr && ty.head == :curly - head = ty.args[1] - params = Any[_contains_tvar(p, typevars) ? - MacroTools.postwalk(p) do x - (x isa Symbol && haskey(bounds, x)) ? bounds[x] : x - end |> x -> Expr(:<:, x) : - p - for p in ty.args[2:end]] - return Expr(:curly, head, params...) - else - return MacroTools.postwalk(ty) do x - (x isa Symbol && haskey(bounds, x)) ? bounds[x] : x - end - end - end - _ARRAY_HEADS = Set{Symbol}( - [:Vector, :Array, :AbstractVector, :AbstractArray, - :Matrix, :AbstractMatrix, :UnitRange, :StepRange, :AbstractRange] - ) - _is_array_annot(ty) = - ty isa Expr && ty.head == :curly && ty.args[1] isa Symbol && (ty.args[1] in _ARRAY_HEADS) - # --- end helpers --- - - # Normalize and split - if def.head == :(=) - def = MacroTools.longdef(def) - elseif def.head != :function - Base.error("@measurify must wrap a function definition") - end - dict = MacroTools.splitdef(def) - - where_items = get(dict, :whereparams, []) - typevars = Set{Symbol}() - bounds = Dict{Symbol,Any}() - for w in where_items - tv, ub = w isa Expr && w.head == :(<:) ? (w.args[1], w.args[2]) : (w, :Any) - push!(typevars, tv) - bounds[tv] = ub - end - - posargs = get(dict, :args, []) - kwargs = get(dict, :kwargs, []) - - # Collect symbols by role - scalar_syms = Symbol[] # positionals/keywords whose annot is exactly ::T - array_syms = Symbol[] # args like Vector{T}, AbstractArray{T}, ranges - anchor_sym = nothing # first parametric NON-array arg that contains T (e.g., EarthModel{T}) - - # Build relaxed wrapper signature - wrapper_pos = Expr[] - wrapper_kw = Expr[] - - # Positionals - for arg in posargs - nm, ty, default = MacroTools.splitarg(arg) - if (ty isa Symbol) && haskey(bounds, ty) - push!(scalar_syms, nm) - push!(wrapper_pos, Expr(:(::), nm, bounds[ty])) - elseif (ty !== nothing) && _contains_tvar(ty, typevars) - if _is_array_annot(ty) - push!(array_syms, nm) - elseif anchor_sym === nothing - anchor_sym = nm - end - push!(wrapper_pos, Expr(:(::), nm, _relax_container_type(ty, typevars, bounds))) - else - push!(wrapper_pos, arg) - end - end - - # Keywords - for kw in kwargs - nm, ty, default = MacroTools.splitarg(kw) - if (ty isa Symbol) && haskey(bounds, ty) - push!(scalar_syms, nm) - elseif (ty !== nothing) && _contains_tvar(ty, typevars) && _is_array_annot(ty) - push!(array_syms, nm) - end - push!(wrapper_kw, MacroTools.postwalk(kw) do x - (x isa Symbol && haskey(bounds, x)) ? bounds[x] : x - end) - end - - tight = def - wrapper_dict = deepcopy(dict) - wrapper_dict[:args] = wrapper_pos - wrapper_dict[:kwargs] = wrapper_kw - - # ---- wrapper body ---- - # 1) TargetType: scalars-only; if there is an anchor (e.g., model::EarthModel{T}), - # use a WIDENING rule seeded with zero(T). - scalar_vals = Any[:($s) for s in scalar_syms] - target_decl = anchor_sym === nothing ? - :(TargetType = _coerce_args_to_T($(scalar_vals...))) : - quote - Tanchor = first(typeof($(anchor_sym)).parameters) - TargetType = isempty(($(scalar_vals...,))) ? Tanchor : - _coerce_args_to_T(zero(Tanchor), $(scalar_vals...)) - end - - # 2) If anchored, CONVERT the anchor only if its T differs (avoid clone-on-noop) - anchor_convert = anchor_sym === nothing ? nothing : quote - Tcur = first(typeof($(anchor_sym)).parameters) - if Tcur !== TargetType - $(anchor_sym) = convert(EarthModel{TargetType}, $(anchor_sym)) - end - end - - # 3) Coerce arrays and scalars - array_casts = [:($a = _coerce_array_to_T($a, TargetType)) for a in array_syms] - scalar_casts = [:($s = _coerce_scalar_to_T($s, TargetType)) for s in scalar_syms] - - # 4) Forward call - arg_names = [MacroTools.splitarg(a)[1] for a in posargs] - kw_forwards = [Expr(:kw, MacroTools.splitarg(kw)[1], MacroTools.splitarg(kw)[1]) for kw in kwargs] - forward_call = Expr(:call, dict[:name]) - !isempty(kw_forwards) && push!(forward_call.args, Expr(:parameters, kw_forwards...)) - append!(forward_call.args, arg_names) - - wrapper_body = quote - $target_decl - $(anchor_convert === nothing ? nothing : anchor_convert) - $(array_casts...) - $(scalar_casts...) - $forward_call - end - # ---------------------- - - # Drop where if no raw typevars remain in wrapper signature - needs_where = any(_contains_tvar(arg, typevars) for arg in [wrapper_pos..., wrapper_kw...]) - if !needs_where - delete!(wrapper_dict, :whereparams) - end - - wrapper_dict[:body] = wrapper_body - loose = MacroTools.combinedef(wrapper_dict) - - # Keep your doc footer - return esc(Expr(:block, :(Base.@__doc__ $tight), loose)) -end - -""" -$(TYPEDSIGNATURES) - -Automatically exports public functions, types, and modules from a module. This is meant for temporary development chores and should never be used in production code. - -# Arguments - -- None. - -# Returns - -- An `export` expression containing all public symbols that should be exported. - -# Notes - -This macro scans the current module for all defined symbols and automatically generates an `export` statement for public functions, types, and submodules, excluding built-in and private names. Private names are considered those starting with an underscore ('_'), as per standard Julia conventions. - -# Examples - -```julia -@autoexport -``` -""" -macro autoexport() - mod = __module__ - - # Get all names defined in the module, including unexported ones - all_names = names(mod; all=true) - - # List of names to explicitly exclude - excluded_names = Set([:eval, :include, :using, :import, :export, :require]) - - # Filter out private names (starting with '_'), module name, built-in functions, and auto-generated method symbols - public_names = Symbol[] - for name in all_names - str_name = string(name) - - startswith(str_name, "@_") && continue # Skip private macros - startswith(str_name, "_") && continue # Skip private names - name === nameof(mod) && continue # Skip the module's own name - name in excluded_names && continue # Skip built-in functions - startswith(str_name, "#") && continue # Skip generated method symbols (e.g., #eval, #include) - - if isdefined(mod, name) - val = getfield(mod, name) - if val isa Function || val isa Type || val isa Module - push!(public_names, name) - end - end - end - - return esc(Expr(:export, public_names...)) +export @parameterize, @measurify + +using MacroTools + +macro parameterize(container_expr, union_expr) + # Evaluate the Union type from the provided expression + local union_type + try + # Core.eval gets the *value* of the symbol passed in (e.g., the actual Union type) + union_type = Core.eval(__module__, union_expr) + catch e + Base.error("Expression `$union_expr` could not be evaluated. Make sure it's a defined const or type.") + end + + # Sanity check + if !(union_type isa Union) + Base.error("Second argument must be a Union type. Got a `$(typeof(union_type))` instead.") + end + + # Base.uniontypes gets the component types, e.g., (Float64, Measurement{Float64}) + component_types = Base.uniontypes(union_type) + + # Define a recursive function to substitute the placeholder `_` + function substitute_placeholder(expr, replacement_type) + # If the current part of the expression is the placeholder symbol, + # we replace it with the target type (e.g., Float64). + if expr == :_ + return replacement_type + # If the current part is another expression (like `Array{_,3}`), + # we need to recurse into its arguments to find the placeholder. + elseif expr isa Expr + # Rebuild the expression with the substituted arguments. + new_args = [substitute_placeholder(arg, replacement_type) for arg in expr.args] + return Expr(expr.head, new_args...) + # Otherwise, it's a literal or symbol we don't need to change (e.g., `:Array` or `3`). + else + return expr + end + end + + # Build the list of new, concrete types + parameterized_types = [substitute_placeholder(container_expr, t) for t in component_types] + + # Wrap the new types in a single `Union{...}` expression and escape + final_expr = Expr(:curly, :Union, parameterized_types...) + return esc(final_expr) +end + +""" + @measurify(function_definition) + +Wraps a function definition. If any argument tied to a parametric type `T` is a +`Measurement`, this macro automatically promotes any other arguments of the same +parametric type `T` to `Measurement` with zero uncertainty. Other arguments +(e.g., `i::Int`) are forwarded without changes. +""" +macro measurify(def) + # --- helpers at expand time --- + function _contains_tvar(expr, typevars::Set{Symbol}) + found = false + MacroTools.postwalk(expr) do x + if x isa Symbol && x in typevars + found = true + end + x + end + found + end + function _relax_container_type(ty, typevars::Set{Symbol}, bounds::Dict{Symbol,Any}) + if ty isa Expr && ty.head == :curly + head = ty.args[1] + params = Any[_contains_tvar(p, typevars) ? + MacroTools.postwalk(p) do x + (x isa Symbol && haskey(bounds, x)) ? bounds[x] : x + end |> x -> Expr(:<:, x) : + p + for p in ty.args[2:end]] + return Expr(:curly, head, params...) + else + return MacroTools.postwalk(ty) do x + (x isa Symbol && haskey(bounds, x)) ? bounds[x] : x + end + end + end + _ARRAY_HEADS = Set{Symbol}( + [:Vector, :Array, :AbstractVector, :AbstractArray, + :Matrix, :AbstractMatrix, :UnitRange, :StepRange, :AbstractRange] + ) + _is_array_annot(ty) = + ty isa Expr && ty.head == :curly && ty.args[1] isa Symbol && (ty.args[1] in _ARRAY_HEADS) + # --- end helpers --- + + # Normalize and split + if def.head == :(=) + def = MacroTools.longdef(def) + elseif def.head != :function + Base.error("@measurify must wrap a function definition") + end + dict = MacroTools.splitdef(def) + + where_items = get(dict, :whereparams, []) + typevars = Set{Symbol}() + bounds = Dict{Symbol,Any}() + for w in where_items + tv, ub = w isa Expr && w.head == :(<:) ? (w.args[1], w.args[2]) : (w, :Any) + push!(typevars, tv) + bounds[tv] = ub + end + + posargs = get(dict, :args, []) + kwargs = get(dict, :kwargs, []) + + # Collect symbols by role + scalar_syms = Symbol[] # positionals/keywords whose annot is exactly ::T + array_syms = Symbol[] # args like Vector{T}, AbstractArray{T}, ranges + anchor_sym = nothing # first parametric NON-array arg that contains T (e.g., EarthModel{T}) + + # Build relaxed wrapper signature + wrapper_pos = Expr[] + wrapper_kw = Expr[] + + # Positionals + for arg in posargs + nm, ty, default = MacroTools.splitarg(arg) + if (ty isa Symbol) && haskey(bounds, ty) + push!(scalar_syms, nm) + push!(wrapper_pos, Expr(:(::), nm, bounds[ty])) + elseif (ty !== nothing) && _contains_tvar(ty, typevars) + if _is_array_annot(ty) + push!(array_syms, nm) + elseif anchor_sym === nothing + anchor_sym = nm + end + push!(wrapper_pos, Expr(:(::), nm, _relax_container_type(ty, typevars, bounds))) + else + push!(wrapper_pos, arg) + end + end + + # Keywords + for kw in kwargs + nm, ty, default = MacroTools.splitarg(kw) + if (ty isa Symbol) && haskey(bounds, ty) + push!(scalar_syms, nm) + elseif (ty !== nothing) && _contains_tvar(ty, typevars) && _is_array_annot(ty) + push!(array_syms, nm) + end + push!(wrapper_kw, MacroTools.postwalk(kw) do x + (x isa Symbol && haskey(bounds, x)) ? bounds[x] : x + end) + end + + tight = def + wrapper_dict = deepcopy(dict) + wrapper_dict[:args] = wrapper_pos + wrapper_dict[:kwargs] = wrapper_kw + + # ---- wrapper body ---- + # 1) TargetType: scalars-only; if there is an anchor (e.g., model::EarthModel{T}), + # use a WIDENING rule seeded with zero(T). + scalar_vals = Any[:($s) for s in scalar_syms] + target_decl = anchor_sym === nothing ? + :(TargetType = _coerce_args_to_T($(scalar_vals...))) : + quote + Tanchor = first(typeof($(anchor_sym)).parameters) + TargetType = isempty(($(scalar_vals...,))) ? Tanchor : + _coerce_args_to_T(zero(Tanchor), $(scalar_vals...)) + end + + # 2) If anchored, CONVERT the anchor only if its T differs (avoid clone-on-noop) + anchor_convert = anchor_sym === nothing ? nothing : quote + Tcur = first(typeof($(anchor_sym)).parameters) + if Tcur !== TargetType + $(anchor_sym) = convert(EarthModel{TargetType}, $(anchor_sym)) + end + end + + # 3) Coerce arrays and scalars + array_casts = [:($a = _coerce_array_to_T($a, TargetType)) for a in array_syms] + scalar_casts = [:($s = _coerce_scalar_to_T($s, TargetType)) for s in scalar_syms] + + # 4) Forward call + arg_names = [MacroTools.splitarg(a)[1] for a in posargs] + kw_forwards = [Expr(:kw, MacroTools.splitarg(kw)[1], MacroTools.splitarg(kw)[1]) for kw in kwargs] + forward_call = Expr(:call, dict[:name]) + !isempty(kw_forwards) && push!(forward_call.args, Expr(:parameters, kw_forwards...)) + append!(forward_call.args, arg_names) + + wrapper_body = quote + $target_decl + $(anchor_convert === nothing ? nothing : anchor_convert) + $(array_casts...) + $(scalar_casts...) + $forward_call + end + # ---------------------- + + # Drop where if no raw typevars remain in wrapper signature + needs_where = any(_contains_tvar(arg, typevars) for arg in [wrapper_pos..., wrapper_kw...]) + if !needs_where + delete!(wrapper_dict, :whereparams) + end + + wrapper_dict[:body] = wrapper_body + loose = MacroTools.combinedef(wrapper_dict) + + # Keep your doc footer + return esc(Expr(:block, :(Base.@__doc__ $tight), loose)) +end + +""" +$(TYPEDSIGNATURES) + +Automatically exports public functions, types, and modules from a module. This is meant for temporary development chores and should never be used in production code. + +# Arguments + +- None. + +# Returns + +- An `export` expression containing all public symbols that should be exported. + +# Notes + +This macro scans the current module for all defined symbols and automatically generates an `export` statement for public functions, types, and submodules, excluding built-in and private names. Private names are considered those starting with an underscore ('_'), as per standard Julia conventions. + +# Examples + +```julia +@autoexport +``` +""" +macro autoexport() + mod = __module__ + + # Get all names defined in the module, including unexported ones + all_names = names(mod; all=true) + + # List of names to explicitly exclude + excluded_names = Set([:eval, :include, :using, :import, :export, :require]) + + # Filter out private names (starting with '_'), module name, built-in functions, and auto-generated method symbols + public_names = Symbol[] + for name in all_names + str_name = string(name) + + startswith(str_name, "@_") && continue # Skip private macros + startswith(str_name, "_") && continue # Skip private names + name === nameof(mod) && continue # Skip the module's own name + name in excluded_names && continue # Skip built-in functions + startswith(str_name, "#") && continue # Skip generated method symbols (e.g., #eval, #include) + + if isdefined(mod, name) + val = getfield(mod, name) + if val isa Function || val isa Type || val isa Module + push!(public_names, name) + end + end + end + + return esc(Expr(:export, public_names...)) end \ No newline at end of file diff --git a/src/utils/typecoercion.jl b/src/utils/typecoercion.jl index 571ee39c..40f2f231 100644 --- a/src/utils/typecoercion.jl +++ b/src/utils/typecoercion.jl @@ -1,321 +1,321 @@ -""" -$(TYPEDSIGNATURES) - -Determines whether a `Type` contains or is a `Measurements.Measurement` somewhere in its structure. The check is recursive over arrays, tuples (including variadic tuples), named tuples, and union types. For concrete struct types, the predicate descends into field types. Guards are present to avoid infinite recursion through known self‑contained types (e.g., `Complex`). - -# Arguments - -- `::Type`: Type to inspect \\[dimensionless\\]. - -# Returns - -- `Bool` indicating whether a `Measurement` occurs anywhere within the type structure. - -# Notes - -- For `AbstractArray{S}`, only the element type `S` is inspected. -- For `Tuple` and `NamedTuple{N,T}`, the parameters are traversed. -- For `Union`, both branches are inspected. -- Concrete `Complex` types are treated as terminal and are not descended. - -# Examples - -```julia -using Measurements - -$(FUNCTIONNAME)(Float64) # false -$(FUNCTIONNAME)(Measurement{Float64}) # true -$(FUNCTIONNAME)(Vector{Measurement{Float64}}) # true -$(FUNCTIONNAME)(Tuple{Int, Float64}) # false -$(FUNCTIONNAME)(Union{Int, Measurement{Float64}}) # true -``` -""" -_hasmeas_type(::Type{<:Measurement}) = true -_hasmeas_type(::Type{<:AbstractArray{S}}) where {S} = _hasmeas_type(S) -_hasmeas_type(::Type{<:Tuple{}}) = false -_hasmeas_type(::Type{T}) where {T<:Tuple} = - any(_hasmeas_type, Base.unwrap_unionall(T).parameters) -_hasmeas_type(::Type{NamedTuple{N,T}}) where {N,T} = _hasmeas_type(T) -_hasmeas_type(T::Union) = _hasmeas_type(T.a) || _hasmeas_type(T.b) -function _hasmeas_type(T::DataType) - # FIX: Add guard against recursing into Complex, which is self-contained. - T <: Complex && return false - isconcretetype(T) && any(_hasmeas_type, fieldtypes(T)) -end -_hasmeas_type(::Type) = false - -""" -$(TYPEDSIGNATURES) - -Determines whether a `Type` contains or is a `Complex` number type somewhere in its structure. The check is recursive over arrays, tuples (including variadic tuples), named tuples, and union types. For concrete struct types, the predicate descends into field types. Guards are present to avoid infinite recursion through known self‑referential types (e.g., `Measurements.Measurement`). - -# Arguments - -- `::Type`: Type to inspect \\[dimensionless\\]. - -# Returns - -- `Bool` indicating whether a `Complex` type occurs anywhere within the type structure. - -# Notes - -- For `AbstractArray{S}`, only the element type `S` is inspected. -- For `Tuple` and `NamedTuple{N,T}`, the parameters are traversed. -- For `Union`, both branches are inspected. -- Concrete `Measurement` types are treated as terminal and are not descended. - -# Examples - -```julia -$(FUNCTIONNAME)(Float64) # false -$(FUNCTIONNAME)(Complex{Float64}) # true -$(FUNCTIONNAME)(Vector{ComplexF64}) # true -$(FUNCTIONNAME)(Tuple{Int, ComplexF64}) # true -``` - -# Methods - -$(METHODLIST) -""" -function _hascomplex_type end -_hascomplex_type(::Type{<:Complex}) = true -_hascomplex_type(::Type{<:AbstractArray{S}}) where {S} = _hascomplex_type(S) -_hascomplex_type(::Type{<:Tuple{}}) = false -_hascomplex_type(::Type{T}) where {T<:Tuple} = - any(_hascomplex_type, Base.unwrap_unionall(T).parameters) -_hascomplex_type(::Type{NamedTuple{N,T}}) where {N,T} = _hascomplex_type(T) -_hascomplex_type(T::Union) = _hascomplex_type(T.a) || _hascomplex_type(T.b) -function _hascomplex_type(T::DataType) - # FIX: Add guard against recursing into Measurement, which is self-referential - # and known not to contain Complex types. This prevents StackOverflowError. - T <: Measurement && return false - isconcretetype(T) && any(_hascomplex_type, fieldtypes(T)) -end -_hascomplex_type(::Type) = false - -""" -$(TYPEDSIGNATURES) - -Resolves the **promotion target type** to be used by constructors and coercion utilities based on the runtime arguments. The decision uses structure‑aware predicates for `Measurement` and `Complex`: - -- If any argument contains `Measurement` and any contains `Complex`, returns `Complex{Measurement{BASE_FLOAT}}`. -- Else if any contains `Measurement`, returns `Measurement{BASE_FLOAT}`. -- Else if any contains `Complex`, returns `Complex{BASE_FLOAT}`. -- Otherwise returns `BASE_FLOAT`. - -# Arguments - -- `args...`: Values whose types will drive the promotion decision \\[dimensionless\\]. - -# Returns - -- A `Type` suitable for numeric promotion in subsequent coercion. - -# Examples - -```julia -using Measurements - -T = $(FUNCTIONNAME)(1.0, 2.0) # BASE_FLOAT -T = $(FUNCTIONNAME)(1 + 0im, 2.0) # Complex{BASE_FLOAT} -T = $(FUNCTIONNAME)(measurement(1.0, 0.1), 2.0) # Measurement{BASE_FLOAT} -T = $(FUNCTIONNAME)(measurement(1.0, 0.1), 2 + 0im) # Complex{Measurement{BASE_FLOAT}} -``` -""" -function resolve_T(args...) - types = map(typeof, args) - has_meas = any(_hasmeas_type, types) - has_complex = any(_hascomplex_type, types) - - if has_meas && has_complex - return Complex{Measurement{BASE_FLOAT}} - elseif has_meas - return Measurement{BASE_FLOAT} - elseif has_complex - return Complex{BASE_FLOAT} - else - return BASE_FLOAT - end -end - -""" -$(TYPEDSIGNATURES) - -Extracts the real inner type `S` from `Measurement{S}`. - -# Arguments - -- `::Type{Measurement{S}}`: Measurement type wrapper \\[dimensionless\\]. - -# Returns - -- The inner floating‐point type `S` \\[dimensionless\\]. - -# Examples - -```julia -using Measurements - -S = $(FUNCTIONNAME)(Measurement{Float64}) # Float64 -``` -""" -_meas_inner(::Type{Measurement{S}}) where {S} = S - -""" -$(TYPEDSIGNATURES) - -Element‑wise coercion kernel. Converts a *single leaf value* to the target type `T` while preserving semantics for `Measurement`, numeric types, and sentinels. - -# Arguments - -- `x`: Input leaf value \\[dimensionless\\]. -- `::Type{T}`: Target type \\[dimensionless\\]. - -# Returns - -- Value coerced to the target, according to the rules below. - -# Notes - -- `Number → R<:AbstractFloat`: uses `convert(R, x)`. -- `Number → M<:Measurement`: embeds the number as a zero‑uncertainty measurement (i.e., `zero(M) + x`). -- `Measurement → M<:Measurement`: recreates with the target inner type (value and uncertainty cast to `_meas_inner(M)`). -- `Measurement → R<:AbstractFloat`: drops uncertainty and converts the nominal value. -- `nothing` and `missing` pass through unchanged. -- `Bool`, `Symbol`, `String`, `Function`, `DataType`: passed through unchanged for measurement/real targets. -- Fallback: returns `x` unchanged. - -# Examples - -```julia -using Measurements - -$(FUNCTIONNAME)(1.2, Float32) # 1.2f0 -$(FUNCTIONNAME)(1.2, Measurement{Float64}) # 1.2 ± 0.0 -$(FUNCTIONNAME)(measurement(2.0, 0.1), Float32) # 2.0f0 -$(FUNCTIONNAME)(measurement(2.0, 0.1), Measurement{Float32}) # 2.0 ± 0.1 (Float32 inner) -$(FUNCTIONNAME)(missing, Float64) # missing -``` - -# Methods - -$(METHODLIST) -""" -function _coerce_elt_to_T end -_coerce_elt_to_T(x::Number, ::Type{R}) where {R<:AbstractFloat} = convert(R, x) -_coerce_elt_to_T(x::Number, ::Type{M}) where {M<:Measurement} = zero(M) + x -_coerce_elt_to_T(m::Measurement, ::Type{M}) where {M<:Measurement} = - measurement(_meas_inner(M)(value(m)), _meas_inner(M)(uncertainty(m))) -_coerce_elt_to_T(m::Measurement, ::Type{R}) where {R<:AbstractFloat} = convert(R, value(m)) -_coerce_elt_to_T(::Nothing, ::Type{T}) where {T} = nothing -_coerce_elt_to_T(::Missing, ::Type{T}) where {T} = missing -_coerce_elt_to_T(x::Bool, ::Type{M}) where {M<:Measurement} = x -_coerce_elt_to_T(x::Bool, ::Type{R}) where {R<:AbstractFloat} = x -_coerce_elt_to_T(x::Union{Symbol,String,Function,DataType}, ::Type{T}) where {T} = x -_coerce_elt_to_T(x, ::Type{T}) where {T} = x - -""" -$(TYPEDSIGNATURES) - -Public coercion API. Converts scalars and containers to a target type `T`, applying element‑wise coercion recursively. Complex numbers are handled by splitting into real and imaginary parts and coercing each side independently. - -# Arguments - -- `x`: Input value (scalar or container) \\[dimensionless\\]. -- `::Type{T}`: Target type \\[dimensionless\\]. - -# Returns - -- Value coerced to the target type: - - For `Real → Complex{P}`: constructs `Complex{P}(coerce_to_T(re, P), coerce_to_T(im, P))` (imaginary part from `0`). - - For `Complex → Real`: discards the imaginary part and coerces the real part. - - For `AbstractArray`, `Tuple`, `NamedTuple`: coerces each element recursively. - - For other types: defers to `_coerce_elt_to_T`. - -# Examples - -```julia -using Measurements - -# Scalar -$(FUNCTIONNAME)(1.2, Float32) # 1.2f0 -$(FUNCTIONNAME)(1.2, Measurement{Float64}) # 1.2 ± 0.0 -$(FUNCTIONNAME)(1 + 2im, Complex{Float32}) # 1.0f0 + 2.0f0im -$(FUNCTIONNAME)(1 + 2im, Float64) # 1.0 - -# Containers -$(FUNCTIONNAME)([1.0, 2.0], Measurement{Float64}) # measurement array -$(FUNCTIONNAME)((1.0, 2.0), Float32) # (1.0f0, 2.0f0) -$(FUNCTIONNAME)((; a=1.0, b=2.0), Float32) # (a = 1.0f0, b = 2.0f0) -``` -# Methods - -$(METHODLIST) - -# See also - -- [`_coerce_elt_to_T`](@ref) -- [`resolve_T`](@ref) -""" -function coerce_to_T end -# --- No-op for exact type matches (universal short-circuit) -coerce_to_T(x::T, ::Type{T}) where {T} = x # exact-type pass-through, no allocation - -# --- Numbers -# Promote Real to Complex when target is Complex -coerce_to_T(x::Real, ::Type{C}) where {P,C<:Complex{P}} = C(coerce_to_T(x, P)) - -# Complex → same Complex{P}: pass-through (avoid rebuilding) -coerce_to_T(x::Complex{P}, ::Type{Complex{P}}) where {P} = x - -# Complex → Complex{P′}: rebuild parts -coerce_to_T(x::Complex{S}, ::Type{Complex{P}}) where {S,P} = - Complex{P}(coerce_to_T(real(x), P), coerce_to_T(imag(x), P)) - -# Complex → Real: drop imag -coerce_to_T(x::Complex, ::Type{R}) where {R<:Real} = coerce_to_T(real(x), R) - -# Generic numbers → element coercion -coerce_to_T(x::Number, ::Type{T}) where {T} = _coerce_elt_to_T(x, T) - -# --- Containers -# Arrays: return the SAME array when element type already matches exactly -coerce_to_T(A::AbstractArray{T}, ::Type{T}) where {T} = A -coerce_to_T(A::AbstractArray, ::Type{T}) where {T} = broadcast(y -> coerce_to_T(y, T), A) - -# Tuples / NamedTuples (immutables): unavoidable allocation if types change -coerce_to_T(t::Tuple{Vararg{T}}, ::Type{T}) where {T} = t -coerce_to_T(t::Tuple, ::Type{T}) where {T} = map(y -> coerce_to_T(y, T), t) - -# --- NamedTuples (two non-overlapping methods) -# 1) Pass-through when every field is already T (strictly more specific) -coerce_to_T(nt::NamedTuple{K,TT}, ::Type{T}) where {K,T,TT<:Tuple{Vararg{T}}} = nt -# 2) Fallback: rebuild with coerced values -coerce_to_T(nt::NamedTuple{K,TT}, ::Type{T}) where {K,TT<:Tuple,T} = - NamedTuple{K}(map(v -> coerce_to_T(v, T), values(nt))) - -# --- Catch-all (must come last; pairs with the universal short-circuit above) -coerce_to_T(x, ::Type{T}) where {T} = _coerce_elt_to_T(x, T) - -# # No-op for exact type matches -# coerce_to_T(x::T, ::Type{T}) where {T} = x # exact-type pass-through, no allocation - -# # --- Numbers -# # Promote Real to Complex when target is Complex. -# coerce_to_T(x::Real, ::Type{C}) where {P,C<:Complex{P}} = C(coerce_to_T(x, P)) -# # General numbers fall back to element coercion. -# coerce_to_T(x::Number, ::Type{T}) where {T} = _coerce_elt_to_T(x, T) -# # Coerce a Complex number to a target Complex type. -# coerce_to_T(x::Complex, ::Type{C}) where {P,C<:Complex{P}} = -# C(coerce_to_T(real(x), P), coerce_to_T(imag(x), P)) -# # Coerce a Complex number to a Real type (drops imaginary part). -# coerce_to_T(x::Complex, ::Type{R}) where {R<:Real} = coerce_to_T(real(x), R) -# # --- Containers -# # Arrays: return the SAME array when element type already matches exactly -# coerce_to_T(A::AbstractArray, ::Type{T}) where {T} = broadcast(y -> coerce_to_T(y, T), A) -# # Tuples / NamedTuples (immutables): unavoidable allocation if types change -# coerce_to_T(t::Tuple, ::Type{T}) where {T} = map(y -> coerce_to_T(y, T), t) -# coerce_to_T(nt::NamedTuple, ::Type{T}) where {T} = -# NamedTuple{keys(nt)}(map(v -> coerce_to_T(v, T), values(nt))) -# # Fallback for other types. +""" +$(TYPEDSIGNATURES) + +Determines whether a `Type` contains or is a `Measurements.Measurement` somewhere in its structure. The check is recursive over arrays, tuples (including variadic tuples), named tuples, and union types. For concrete struct types, the predicate descends into field types. Guards are present to avoid infinite recursion through known self‑contained types (e.g., `Complex`). + +# Arguments + +- `::Type`: Type to inspect \\[dimensionless\\]. + +# Returns + +- `Bool` indicating whether a `Measurement` occurs anywhere within the type structure. + +# Notes + +- For `AbstractArray{S}`, only the element type `S` is inspected. +- For `Tuple` and `NamedTuple{N,T}`, the parameters are traversed. +- For `Union`, both branches are inspected. +- Concrete `Complex` types are treated as terminal and are not descended. + +# Examples + +```julia +using Measurements + +$(FUNCTIONNAME)(Float64) # false +$(FUNCTIONNAME)(Measurement{Float64}) # true +$(FUNCTIONNAME)(Vector{Measurement{Float64}}) # true +$(FUNCTIONNAME)(Tuple{Int, Float64}) # false +$(FUNCTIONNAME)(Union{Int, Measurement{Float64}}) # true +``` +""" +_hasmeas_type(::Type{<:Measurement}) = true +_hasmeas_type(::Type{<:AbstractArray{S}}) where {S} = _hasmeas_type(S) +_hasmeas_type(::Type{<:Tuple{}}) = false +_hasmeas_type(::Type{T}) where {T<:Tuple} = + any(_hasmeas_type, Base.unwrap_unionall(T).parameters) +_hasmeas_type(::Type{NamedTuple{N,T}}) where {N,T} = _hasmeas_type(T) +_hasmeas_type(T::Union) = _hasmeas_type(T.a) || _hasmeas_type(T.b) +function _hasmeas_type(T::DataType) + # FIX: Add guard against recursing into Complex, which is self-contained. + T <: Complex && return false + isconcretetype(T) && any(_hasmeas_type, fieldtypes(T)) +end +_hasmeas_type(::Type) = false + +""" +$(TYPEDSIGNATURES) + +Determines whether a `Type` contains or is a `Complex` number type somewhere in its structure. The check is recursive over arrays, tuples (including variadic tuples), named tuples, and union types. For concrete struct types, the predicate descends into field types. Guards are present to avoid infinite recursion through known self‑referential types (e.g., `Measurements.Measurement`). + +# Arguments + +- `::Type`: Type to inspect \\[dimensionless\\]. + +# Returns + +- `Bool` indicating whether a `Complex` type occurs anywhere within the type structure. + +# Notes + +- For `AbstractArray{S}`, only the element type `S` is inspected. +- For `Tuple` and `NamedTuple{N,T}`, the parameters are traversed. +- For `Union`, both branches are inspected. +- Concrete `Measurement` types are treated as terminal and are not descended. + +# Examples + +```julia +$(FUNCTIONNAME)(Float64) # false +$(FUNCTIONNAME)(Complex{Float64}) # true +$(FUNCTIONNAME)(Vector{ComplexF64}) # true +$(FUNCTIONNAME)(Tuple{Int, ComplexF64}) # true +``` + +# Methods + +$(METHODLIST) +""" +function _hascomplex_type end +_hascomplex_type(::Type{<:Complex}) = true +_hascomplex_type(::Type{<:AbstractArray{S}}) where {S} = _hascomplex_type(S) +_hascomplex_type(::Type{<:Tuple{}}) = false +_hascomplex_type(::Type{T}) where {T<:Tuple} = + any(_hascomplex_type, Base.unwrap_unionall(T).parameters) +_hascomplex_type(::Type{NamedTuple{N,T}}) where {N,T} = _hascomplex_type(T) +_hascomplex_type(T::Union) = _hascomplex_type(T.a) || _hascomplex_type(T.b) +function _hascomplex_type(T::DataType) + # FIX: Add guard against recursing into Measurement, which is self-referential + # and known not to contain Complex types. This prevents StackOverflowError. + T <: Measurement && return false + isconcretetype(T) && any(_hascomplex_type, fieldtypes(T)) +end +_hascomplex_type(::Type) = false + +""" +$(TYPEDSIGNATURES) + +Resolves the **promotion target type** to be used by constructors and coercion utilities based on the runtime arguments. The decision uses structure‑aware predicates for `Measurement` and `Complex`: + +- If any argument contains `Measurement` and any contains `Complex`, returns `Complex{Measurement{BASE_FLOAT}}`. +- Else if any contains `Measurement`, returns `Measurement{BASE_FLOAT}`. +- Else if any contains `Complex`, returns `Complex{BASE_FLOAT}`. +- Otherwise returns `BASE_FLOAT`. + +# Arguments + +- `args...`: Values whose types will drive the promotion decision \\[dimensionless\\]. + +# Returns + +- A `Type` suitable for numeric promotion in subsequent coercion. + +# Examples + +```julia +using Measurements + +T = $(FUNCTIONNAME)(1.0, 2.0) # BASE_FLOAT +T = $(FUNCTIONNAME)(1 + 0im, 2.0) # Complex{BASE_FLOAT} +T = $(FUNCTIONNAME)(measurement(1.0, 0.1), 2.0) # Measurement{BASE_FLOAT} +T = $(FUNCTIONNAME)(measurement(1.0, 0.1), 2 + 0im) # Complex{Measurement{BASE_FLOAT}} +``` +""" +function resolve_T(args...) + types = map(typeof, args) + has_meas = any(_hasmeas_type, types) + has_complex = any(_hascomplex_type, types) + + if has_meas && has_complex + return Complex{Measurement{BASE_FLOAT}} + elseif has_meas + return Measurement{BASE_FLOAT} + elseif has_complex + return Complex{BASE_FLOAT} + else + return BASE_FLOAT + end +end + +""" +$(TYPEDSIGNATURES) + +Extracts the real inner type `S` from `Measurement{S}`. + +# Arguments + +- `::Type{Measurement{S}}`: Measurement type wrapper \\[dimensionless\\]. + +# Returns + +- The inner floating‐point type `S` \\[dimensionless\\]. + +# Examples + +```julia +using Measurements + +S = $(FUNCTIONNAME)(Measurement{Float64}) # Float64 +``` +""" +_meas_inner(::Type{Measurement{S}}) where {S} = S + +""" +$(TYPEDSIGNATURES) + +Element‑wise coercion kernel. Converts a *single leaf value* to the target type `T` while preserving semantics for `Measurement`, numeric types, and sentinels. + +# Arguments + +- `x`: Input leaf value \\[dimensionless\\]. +- `::Type{T}`: Target type \\[dimensionless\\]. + +# Returns + +- Value coerced to the target, according to the rules below. + +# Notes + +- `Number → R<:AbstractFloat`: uses `convert(R, x)`. +- `Number → M<:Measurement`: embeds the number as a zero‑uncertainty measurement (i.e., `zero(M) + x`). +- `Measurement → M<:Measurement`: recreates with the target inner type (value and uncertainty cast to `_meas_inner(M)`). +- `Measurement → R<:AbstractFloat`: drops uncertainty and converts the nominal value. +- `nothing` and `missing` pass through unchanged. +- `Bool`, `Symbol`, `String`, `Function`, `DataType`: passed through unchanged for measurement/real targets. +- Fallback: returns `x` unchanged. + +# Examples + +```julia +using Measurements + +$(FUNCTIONNAME)(1.2, Float32) # 1.2f0 +$(FUNCTIONNAME)(1.2, Measurement{Float64}) # 1.2 ± 0.0 +$(FUNCTIONNAME)(measurement(2.0, 0.1), Float32) # 2.0f0 +$(FUNCTIONNAME)(measurement(2.0, 0.1), Measurement{Float32}) # 2.0 ± 0.1 (Float32 inner) +$(FUNCTIONNAME)(missing, Float64) # missing +``` + +# Methods + +$(METHODLIST) +""" +function _coerce_elt_to_T end +_coerce_elt_to_T(x::Number, ::Type{R}) where {R<:AbstractFloat} = convert(R, x) +_coerce_elt_to_T(x::Number, ::Type{M}) where {M<:Measurement} = zero(M) + x +_coerce_elt_to_T(m::Measurement, ::Type{M}) where {M<:Measurement} = + measurement(_meas_inner(M)(value(m)), _meas_inner(M)(uncertainty(m))) +_coerce_elt_to_T(m::Measurement, ::Type{R}) where {R<:AbstractFloat} = convert(R, value(m)) +_coerce_elt_to_T(::Nothing, ::Type{T}) where {T} = nothing +_coerce_elt_to_T(::Missing, ::Type{T}) where {T} = missing +_coerce_elt_to_T(x::Bool, ::Type{M}) where {M<:Measurement} = x +_coerce_elt_to_T(x::Bool, ::Type{R}) where {R<:AbstractFloat} = x +_coerce_elt_to_T(x::Union{Symbol,String,Function,DataType}, ::Type{T}) where {T} = x +_coerce_elt_to_T(x, ::Type{T}) where {T} = x + +""" +$(TYPEDSIGNATURES) + +Public coercion API. Converts scalars and containers to a target type `T`, applying element‑wise coercion recursively. Complex numbers are handled by splitting into real and imaginary parts and coercing each side independently. + +# Arguments + +- `x`: Input value (scalar or container) \\[dimensionless\\]. +- `::Type{T}`: Target type \\[dimensionless\\]. + +# Returns + +- Value coerced to the target type: + - For `Real → Complex{P}`: constructs `Complex{P}(coerce_to_T(re, P), coerce_to_T(im, P))` (imaginary part from `0`). + - For `Complex → Real`: discards the imaginary part and coerces the real part. + - For `AbstractArray`, `Tuple`, `NamedTuple`: coerces each element recursively. + - For other types: defers to `_coerce_elt_to_T`. + +# Examples + +```julia +using Measurements + +# Scalar +$(FUNCTIONNAME)(1.2, Float32) # 1.2f0 +$(FUNCTIONNAME)(1.2, Measurement{Float64}) # 1.2 ± 0.0 +$(FUNCTIONNAME)(1 + 2im, Complex{Float32}) # 1.0f0 + 2.0f0im +$(FUNCTIONNAME)(1 + 2im, Float64) # 1.0 + +# Containers +$(FUNCTIONNAME)([1.0, 2.0], Measurement{Float64}) # measurement array +$(FUNCTIONNAME)((1.0, 2.0), Float32) # (1.0f0, 2.0f0) +$(FUNCTIONNAME)((; a=1.0, b=2.0), Float32) # (a = 1.0f0, b = 2.0f0) +``` +# Methods + +$(METHODLIST) + +# See also + +- [`_coerce_elt_to_T`](@ref) +- [`resolve_T`](@ref) +""" +function coerce_to_T end +# --- No-op for exact type matches (universal short-circuit) +coerce_to_T(x::T, ::Type{T}) where {T} = x # exact-type pass-through, no allocation + +# --- Numbers +# Promote Real to Complex when target is Complex +coerce_to_T(x::Real, ::Type{C}) where {P,C<:Complex{P}} = C(coerce_to_T(x, P)) + +# Complex → same Complex{P}: pass-through (avoid rebuilding) +coerce_to_T(x::Complex{P}, ::Type{Complex{P}}) where {P} = x + +# Complex → Complex{P′}: rebuild parts +coerce_to_T(x::Complex{S}, ::Type{Complex{P}}) where {S,P} = + Complex{P}(coerce_to_T(real(x), P), coerce_to_T(imag(x), P)) + +# Complex → Real: drop imag +coerce_to_T(x::Complex, ::Type{R}) where {R<:Real} = coerce_to_T(real(x), R) + +# Generic numbers → element coercion +coerce_to_T(x::Number, ::Type{T}) where {T} = _coerce_elt_to_T(x, T) + +# --- Containers +# Arrays: return the SAME array when element type already matches exactly +coerce_to_T(A::AbstractArray{T}, ::Type{T}) where {T} = A +coerce_to_T(A::AbstractArray, ::Type{T}) where {T} = broadcast(y -> coerce_to_T(y, T), A) + +# Tuples / NamedTuples (immutables): unavoidable allocation if types change +coerce_to_T(t::Tuple{Vararg{T}}, ::Type{T}) where {T} = t +coerce_to_T(t::Tuple, ::Type{T}) where {T} = map(y -> coerce_to_T(y, T), t) + +# --- NamedTuples (two non-overlapping methods) +# 1) Pass-through when every field is already T (strictly more specific) +coerce_to_T(nt::NamedTuple{K,TT}, ::Type{T}) where {K,T,TT<:Tuple{Vararg{T}}} = nt +# 2) Fallback: rebuild with coerced values +coerce_to_T(nt::NamedTuple{K,TT}, ::Type{T}) where {K,TT<:Tuple,T} = + NamedTuple{K}(map(v -> coerce_to_T(v, T), values(nt))) + +# --- Catch-all (must come last; pairs with the universal short-circuit above) +coerce_to_T(x, ::Type{T}) where {T} = _coerce_elt_to_T(x, T) + +# # No-op for exact type matches +# coerce_to_T(x::T, ::Type{T}) where {T} = x # exact-type pass-through, no allocation + +# # --- Numbers +# # Promote Real to Complex when target is Complex. +# coerce_to_T(x::Real, ::Type{C}) where {P,C<:Complex{P}} = C(coerce_to_T(x, P)) +# # General numbers fall back to element coercion. +# coerce_to_T(x::Number, ::Type{T}) where {T} = _coerce_elt_to_T(x, T) +# # Coerce a Complex number to a target Complex type. +# coerce_to_T(x::Complex, ::Type{C}) where {P,C<:Complex{P}} = +# C(coerce_to_T(real(x), P), coerce_to_T(imag(x), P)) +# # Coerce a Complex number to a Real type (drops imaginary part). +# coerce_to_T(x::Complex, ::Type{R}) where {R<:Real} = coerce_to_T(real(x), R) +# # --- Containers +# # Arrays: return the SAME array when element type already matches exactly +# coerce_to_T(A::AbstractArray, ::Type{T}) where {T} = broadcast(y -> coerce_to_T(y, T), A) +# # Tuples / NamedTuples (immutables): unavoidable allocation if types change +# coerce_to_T(t::Tuple, ::Type{T}) where {T} = map(y -> coerce_to_T(y, T), t) +# coerce_to_T(nt::NamedTuple, ::Type{T}) where {T} = +# NamedTuple{keys(nt)}(map(v -> coerce_to_T(v, T), values(nt))) +# # Fallback for other types. # coerce_to_T(x, ::Type{T}) where {T} = _coerce_elt_to_T(x, T) \ No newline at end of file diff --git a/src/validation/Validation.jl b/src/validation/Validation.jl index 679933c6..d2a8c9d9 100644 --- a/src/validation/Validation.jl +++ b/src/validation/Validation.jl @@ -1,853 +1,853 @@ -""" - LineCableModels.Validation - -The [`Validation`](@ref) module implements a trait-driven, three-phase input checking pipeline for component constructors in `LineCableModels`. Inputs are first *sanitized* (arity and shape checks on raw arguments), then *parsed* (proxy values normalized to numeric radii), and finally validated by a generated set of rules. - - -# Overview - -- Centralized constructor input handling: `sanitize` → `parse` → rule application. -- Trait hooks configure per‑type behavior (`has_radii`, `has_temperature`, `required_fields`, `keyword_fields`, `coercive_fields`, etc.). -- Rules are small value objects (`Rule` subtypes) applied to a normalized `NamedTuple`. - -# Dependencies - -$(IMPORTS) - -# Exports - -$(EXPORTS) -""" -module Validation - -# Export public API -export validate!, has_radii, has_temperature, extra_rules, - sanitize, parse, is_radius_input, required_fields, keyword_fields, keyword_defaults, coercive_fields, Finite, Nonneg, Positive, IntegerField, Less, LessEq, IsA, Normalized, OneOf - -# Module-specific dependencies -using ..Commons - -""" -$(TYPEDEF) - -Base abstract type for validation rules. All concrete rule types must subtype [`Rule`](@ref) and provide an `_apply(::Rule, nt, ::Type{T})` method that checks a field in the normalized `NamedTuple` `nt` for the component type `T`. - -$(TYPEDFIELDS) -""" -abstract type Rule end - -""" -$(TYPEDEF) - -Rule that enforces finiteness of a numeric field. - -$(TYPEDFIELDS) -""" -struct Finite <: Rule - "Name of the field to check." - name::Symbol -end - -""" -$(TYPEDEF) - -Rule that enforces a field to be non‑negative (`≥ 0`). - -$(TYPEDFIELDS) -""" -struct Nonneg <: Rule - "Name of the field to check." - name::Symbol -end - -""" -$(TYPEDEF) - -Rule that enforces a field to be strictly positive (`> 0`). - -$(TYPEDFIELDS) -""" -struct Positive <: Rule - "Name of the field to check." - name::Symbol -end - -""" -$(TYPEDEF) - -Rule that enforces a field to be of an integer type. - -$(TYPEDFIELDS) -""" -struct IntegerField <: Rule - "Name of the field to check." - name::Symbol -end - -""" -$(TYPEDEF) - -Rule that enforces a strict ordering constraint `a < b` between two fields. - -$(TYPEDFIELDS) -""" -struct Less <: Rule - "Left‑hand field name." - a::Symbol - "Right‑hand field name." - b::Symbol -end - -""" -$(TYPEDEF) - -Rule that enforces a non‑strict ordering constraint `a ≤ b` between two fields. - -$(TYPEDFIELDS) -""" -struct LessEq <: Rule - "Left‑hand field name." - a::Symbol - "Right‑hand field name." - b::Symbol -end - -""" -$(TYPEDEF) - -Rule that enforces a field to be `isa M` for a specified type parameter `M`. - -$(TYPEDFIELDS) -""" -struct IsA{M} <: Rule - "Name of the field to check." - name::Symbol -end - -""" -$(TYPEDEF) - -Rule that enforces that a field has already been normalized to a numeric value during parsing. Intended to guard that `parse` has executed and removed proxies. - -$(TYPEDFIELDS) -""" -struct Normalized <: Rule - "Name of the field to check." - name::Symbol -end - -""" -$(TYPEDEF) - -Rule that enforces a field to be `in` the set `S`. - -$(TYPEDFIELDS) -""" -struct OneOf{S} <: Rule - name::Symbol - set::S -end - - -""" -$(TYPEDSIGNATURES) - -Returns the simple (unqualified) name of type `T` as a `String`. Utility for constructing diagnostic messages. - -# Arguments - -- `::Type{T}`: Type whose name is requested \\[dimensionless\\]. - -# Returns - -- `String` with the type name \\[dimensionless\\]. - -# Examples - -```julia -name = $(FUNCTIONNAME)(Float64) # "Float64" -``` -""" -@inline _typename(::Type{T}) where {T} = String(nameof(T)) - -""" -$(TYPEDSIGNATURES) - -Returns a compact textual representation of `x` for error messages. - -# Arguments - -- `x`: Value to represent \\[dimensionless\\]. - -# Returns - -- `String` with a compact `repr` \\[dimensionless\\]. - -# Examples - -```julia -s = $(FUNCTIONNAME)(:field) # ":field" -``` -""" -@inline _repr(x) = repr(x; context=:compact => true) - -""" -$(TYPEDSIGNATURES) - -Asserts that `x` is a real (non‑complex) number. Used by rule implementations before performing numeric comparisons. - -# Arguments - -- `field`: Field name used in diagnostics \\[dimensionless\\]. -- `x`: Value to check \\[dimensionless\\]. -- `::Type{T}`: Component type for contextualized messages \\[dimensionless\\]. - -# Returns - -- Nothing. Throws on failure. - -# Errors - -- `ArgumentError` if `x` is not `isa Number` or is a `Complex` value. - -# Examples - -```julia -$(FUNCTIONNAME)(:radius_in, 0.01, SomeType) # ok -``` -""" -@inline function _ensure_real(field::Symbol, x, ::Type{T}) where {T} - if !(x isa Number) || x isa Complex - throw(ArgumentError("[$(_typename(T))] $field must be a real number, got $(typeof(x)): $(_repr(x))")) - end -end - -""" -$(TYPEDSIGNATURES) - -Applies [`Finite`](@ref) to ensure the target field is a finite real number. - -# Arguments - -- `r`: Rule instance \\[dimensionless\\]. -- `nt`: Normalized `NamedTuple` of inputs \\[dimensionless\\]. -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Nothing. Throws on failure. -""" -@inline function _apply(r::Finite, nt, ::Type{T}) where {T} - x = getfield(nt, r.name) - _ensure_real(r.name, x, T) - isfinite(x) || throw(DomainError("[$(_typename(T))] $(r.name) must be finite, got $x")) -end - -""" -$(TYPEDSIGNATURES) - -Applies [`Nonneg`](@ref) to ensure the target field is `≥ 0`. - -# Arguments - -- `r`: Rule instance \\[dimensionless\\]. -- `nt`: Normalized `NamedTuple` of inputs \\[dimensionless\\]. -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Nothing. Throws on failure. -""" -@inline function _apply(r::Nonneg, nt, ::Type{T}) where {T} - x = getfield(nt, r.name) - _ensure_real(r.name, x, T) - x >= 0 || throw(ArgumentError("[$(_typename(T))] $(r.name) must be ≥ 0, got $x")) -end - -""" -$(TYPEDSIGNATURES) - -Applies [`Positive`](@ref) to ensure the target field is `> 0`. - -# Arguments - -- `r`: Rule instance \\[dimensionless\\]. -- `nt`: Normalized `NamedTuple` of inputs \\[dimensionless\\]. -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Nothing. Throws on failure. -""" -@inline function _apply(r::Positive, nt, ::Type{T}) where {T} - x = getfield(nt, r.name) - _ensure_real(r.name, x, T) - x > 0 || throw(ArgumentError("[$(_typename(T))] $(r.name) must be > 0, got $x")) -end - -""" -$(TYPEDSIGNATURES) - -Applies [`IntegerField`](@ref) to ensure the target field is an `Integer`. - -# Arguments - -- `r`: Rule instance \\[dimensionless\\]. -- `nt`: Normalized `NamedTuple` of inputs \\[dimensionless\\]. -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Nothing. Throws on failure. -""" -@inline function _apply(r::IntegerField, nt, ::Type{T}) where {T} - x = getfield(nt, r.name) - x isa Integer || throw(ArgumentError("[$(_typename(T))] $(r.name) must be Integer, got $(typeof(x))")) -end - -""" -$(TYPEDSIGNATURES) - -Applies [`Less`](@ref) to ensure `nt[a] < nt[b]`. - -# Arguments - -- `r`: Rule instance with fields `a` and `b` \\[dimensionless\\]. -- `nt`: Normalized `NamedTuple` \\[dimensionless\\]. -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Nothing. Throws on failure. -""" -@inline function _apply(r::Less, nt, ::Type{T}) where {T} - a = getfield(nt, r.a) - b = getfield(nt, r.b) - _ensure_real(r.a, a, T) - _ensure_real(r.b, b, T) - a < b || throw(ArgumentError("[$(_typename(T))] $(r.a) < $(r.b) violated (got $a ≥ $b)")) -end - -""" -$(TYPEDSIGNATURES) - -Applies [`LessEq`](@ref) to ensure `nt[a] ≤ nt[b]`. - -# Arguments - -- `r`: Rule instance with fields `a` and `b` \\[dimensionless\\]. -- `nt`: Normalized `NamedTuple` \\[dimensionless\\]. -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Nothing. Throws on failure. -""" -@inline function _apply(r::LessEq, nt, ::Type{T}) where {T} - a = getfield(nt, r.a) - b = getfield(nt, r.b) - _ensure_real(r.a, a, T) - _ensure_real(r.b, b, T) - a <= b || throw(ArgumentError("[$(_typename(T))] $(r.a) ≤ $(r.b) violated (got $a > $b)")) -end - -""" -$(TYPEDSIGNATURES) - -Applies [`IsA{M}`](@ref) to ensure a field is of type `M`. - -# Arguments - -- `r`: Rule instance parameterized by `M` \\[dimensionless\\]. -- `nt`: Normalized `NamedTuple` \\[dimensionless\\]. -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Nothing. Throws on failure. -""" -@inline function _apply(r::IsA{M}, nt, ::Type{T}) where {T,M} - x = getfield(nt, r.name) - x isa M || throw(ArgumentError("[$(_typename(T))] $(r.name) must be $(M), got $(typeof(x))")) -end - -""" -$(TYPEDSIGNATURES) - -Applies [`Normalized`](@ref) to ensure the field has been converted to a numeric value during parsing. - -# Arguments - -- `r`: Rule instance \\[dimensionless\\]. -- `nt`: Normalized `NamedTuple` \\[dimensionless\\]. -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Nothing. Throws on failure. -""" -@inline function _apply(r::Normalized, nt, ::Type{T}) where {T} - x = getfield(nt, r.name) - x isa Number || throw(ArgumentError("[$(_typename(T))] $(r.name) must be normalized Number; got $(typeof(x))")) -end - -""" -$(TYPEDSIGNATURES) - -Applies [`OneOf`](@ref) to ensure the target field is contained in a specified set. - -# Arguments - -- `r`: Rule instance with fields `name` and `set` \\[dimensionless\\]. -- `nt`: Normalized `NamedTuple` of inputs \\[dimensionless\\]. -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Nothing. Throws on failure. -""" -@inline function _apply(r::OneOf{S}, nt, ::Type{T}) where {S,T} - x = getfield(nt, r.name) - (x in r.set) || throw(ArgumentError("[$(String(nameof(T)))] $(r.name) must be one of $(collect(r.set)); got $(x)")) -end - -""" -$(TYPEDSIGNATURES) - -Trait hook enabling the annular radii rule bundle on fields `:radius_in` and `:radius_ext` (normalized numbers required, finiteness, non‑negativity, and the ordering constraint `:radius_in` < `:radius_ext`). It does **not** indicate the mere existence of radii; it opts in to the annular/coaxial shell geometry checks. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- `Bool` flag. - -# Examples - -```julia -Validation.has_radii(Tubular) # true -``` -""" -has_radii(::Type) = false # Default = false/empty. Components extend these. - -""" -$(TYPEDSIGNATURES) - -Trait hook indicating whether a component type uses a `:temperature` field subject to finiteness checks. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- `Bool` flag. -""" -has_temperature(::Type) = false - -""" -$(TYPEDSIGNATURES) - -Trait hook providing additional rule instances for a component type. Used to append per‑type constraints after the standard bundles. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Tuple of [`Rule`](@ref) instances. -""" -extra_rules(::Type) = () # per-type extras - -""" -$(TYPEDSIGNATURES) - -Trait hook listing required fields that must be present after positional→named merge in `sanitize`. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Tuple of required field names. -""" -required_fields(::Type) = () - -""" -$(TYPEDSIGNATURES) - -Trait hook listing optional keyword fields considered by `sanitize`. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Tuple of optional keyword field names. -""" -keyword_fields(::Type) = () - -""" -$(TYPEDSIGNATURES) - -Trait hook supplying **default values** for optional keyword fields. - -Return either: -- a `NamedTuple` mapping defaults by name (e.g., `(temperature = T₀,)`), or -- a plain `Tuple` of defaults aligned with `keyword_fields(T)` (same order + length). - -Defaults are applied in `sanitize` **after** positional→named merge and before rule application. -User-provided keywords always override these defaults. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- `NamedTuple` or `Tuple` with default keyword values. -""" -keyword_defaults(::Type) = () - -""" -$(TYPEDSIGNATURES) - -Trait hook listing **coercive** fields: values that participate in numeric promotion and will be converted to the promoted type by the convenience constructor. Defaults to all fields (`required_fields ∪ keyword_fields`). Types may override to exclude integers or categorical fields. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Tuple of field names that are coerced. -""" -coercive_fields(::Type{T}) where {T} = (required_fields(T)..., keyword_fields(T)...) - -""" -$(TYPEDSIGNATURES) - -Trait predicate that defines admissible *raw* radius inputs for a component type during `sanitize`. The default accepts real, non‑complex numbers only. Component code may extend this to allow proxies (e.g., `AbstractCablePart`, `Thickness`, `Diameter`). - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. -- `x`: Candidate value \\[dimensionless\\]. - -# Returns - -- `Bool` indicating acceptance. - -# Examples - -```julia -Validation.is_radius_input(Tubular, 0.01) # true by default -Validation.is_radius_input(Tubular, 1 + 0im) # false (complex) -``` - -# See also - -- [`sanitize`](@ref) -""" -is_radius_input(::Type{T}, x) where {T} = (x isa Number) && !(x isa Complex) - -""" -$(TYPEDSIGNATURES) - -Field‑aware acceptance predicate used by `sanitize` to distinguish inner vs. outer radius policies. The default forwards to [`is_radius_input(::Type{T}, x)`](@ref) when no field‑specific method is defined. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. -- `::Val{F}`: Field tag; typically `Val(:radius_in)` or `Val(:radius_ext)` \\[dimensionless\\]. -- `x`: Candidate value \\[dimensionless\\]. - -# Returns - -- `Bool` indicating acceptance. - -# Examples - -```julia -Validation.is_radius_input(Tubular, Val(:radius_in), 0.01) # true -Validation.is_radius_input(Tubular, Val(:radius_ext), 0.01) # true -``` - -# See also - -- [`sanitize`](@ref) -- [`is_radius_input(::Type{T}, x)`](@ref) -""" -is_radius_input(::Type{T}, ::Val{F}, x) where {T,F} = is_radius_input(T, x) - -""" -$(TYPEDSIGNATURES) - -Default policy for **inner** radius raw inputs: accept real numbers. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. -- `::Val{:radius_in}`: Field tag for the inner radius \\[dimensionless\\]. -- `x::Number`: Candidate value \\[dimensionless\\]. - -# Returns - -- `Bool` indicating acceptance (`true` for real, non‑complex numbers). - -# Examples - -```julia -Validation.is_radius_input(Tubular, Val(:radius_in), 0.0) # true -Validation.is_radius_input(Tubular, Val(:radius_in), 1+0im) # false -``` -""" -is_radius_input(::Type{T}, ::Val{:radius_in}, x::Number) where {T} = (x isa Number) && !(x isa Complex) -is_radius_input(::Type{T}, ::Val{:radius_in}, ::Any) where {T} = false - -""" -$(TYPEDSIGNATURES) - -Default policy for **outer** radius raw inputs (annular shells): accept real numbers. Proxies are rejected at this stage to prevent zero‑thickness stacking. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. -- `::Val{:radius_ext}`: Field tag for the outer radius \\[dimensionless\\]. -- `x::Number`: Candidate value \\[dimensionless\\]. - -# Returns - -- `Bool` indicating acceptance (`true` for real, non‑complex numbers). - -# Examples - -```julia -Validation.is_radius_input(Tubular, Val(:radius_ext), 0.02) # true -``` -""" -is_radius_input(::Type{T}, ::Val{:radius_ext}, x::Number) where {T} = (x isa Number) && !(x isa Complex) -is_radius_input(::Type{T}, ::Val{:radius_ext}, ::Any) where {T} = false - -""" -$(TYPEDSIGNATURES) - -Internal helper that canonicalizes `keyword_defaults(T)` into a `NamedTuple` -keyed by `keyword_fields(T)`. Accepts: - -- `()` → returns an empty `NamedTuple()`. -- `NamedTuple` → returned unchanged. -- `Tuple` → zipped **by position** with `keyword_fields(T)`; lengths must match. - -This function does **not** merge user-provided keywords; callers should perform -`merge(_kwdefaults_nt(T), kwargs)` so that user values take precedence. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- `NamedTuple` mapping optional keyword field names to their default values. - -# Errors - -- If `keyword_defaults(T)` returns a `Tuple` whose length differs from - `length(keyword_fields(T))`. -- If `keyword_defaults(T)` returns a value that is neither `()` nor a - `NamedTuple` nor a `Tuple`. - -# Examples - -```julia -# Suppose: -# keyword_fields(::Type{X}) = (:temperature, :lay_direction) -# keyword_defaults(::Type{X}) = (T₀, 1) - -$(FUNCTIONNAME)(X) # => (temperature = T₀, lay_direction = 1) - -# If defaults are already a NamedTuple: -# keyword_defaults(::Type{Y}) = (temperature = 25.0,) -$(FUNCTIONNAME)(Y) # => (temperature = 25.0,) -```` - -# See also - -* [`keyword_fields`](@ref) -* [`keyword_defaults`](@ref) -* [`sanitize`](@ref) - """ -@inline function _kwdefaults_nt(::Type{T}) where {T} - defs = keyword_defaults(T) - defs === () && return NamedTuple() - if defs isa NamedTuple - return defs - elseif defs isa Tuple - keys = keyword_fields(T) - length(keys) == length(defs) || - Base.error("[$(String(nameof(T)))] keyword_defaults length $(length(defs)) ≠ keyword_fields length $(length(keys))") - return NamedTuple{keys}(defs) - else - Base.error("[$(String(nameof(T)))] keyword_defaults must be NamedTuple or Tuple; got $(typeof(defs))") - end -end - -""" -$(TYPEDSIGNATURES) - -Performs raw input checks and shapes the input into a `NamedTuple` without parsing proxies. Responsibilities: arity validation, positional→named mapping, required field presence, and raw acceptance of radius inputs via `is_radius_input` when `has_radii(T)` is true. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. -- `args::Tuple`: Positional arguments as received by the convenience constructor \\[dimensionless\\]. -- `kwargs::NamedTuple`: Keyword arguments \\[dimensionless\\]. - -# Returns - -- `NamedTuple` with raw (unparsed) fields. - -# Errors - -- `ArgumentError` on invalid arity, excess positional arguments, missing required fields, or rejected raw radius inputs. - -# Notes - -Required arguments must be positional; optional arguments must be passed as keywords. Positional arity must equal `length(required_fields(T))`. - -# Examples - -```julia -nt = $(FUNCTIONNAME)(Tubular, (0.01, 0.02, material), (; temperature = 20.0,)) -``` -""" -function sanitize(::Type{T}, args::Tuple, kwargs::NamedTuple) where {T} - # -- hard arity on required positionals -- - req = required_fields(T) - kw = keyword_fields(T) - nreq = length(req) - na = length(args) - if na != nreq - names = join(string.(req), ", ") - throw(ArgumentError("[$(_typename(T))] expected exactly $nreq positional args ($names); got $na. Optionals must be keywords.")) - end - - # positional -> named - nt_pos = (; (req[i] => args[i] for i = 1:nreq)...) - - # reject unknown keywords (strict) - for k in keys(kwargs) - if !(k in kw) && !(k in req) - throw(ArgumentError("[$(_typename(T))] unknown keyword '$k'. Allowed keywords: $(join(string.(kw), ", ")).")) - end - end - - # user kw override positionals (if any same names) - nt = merge(nt_pos, kwargs) - - # backfill missing optional keywords with trait defaults --- - # defaults first, then user-provided values win - nt = merge(_kwdefaults_nt(T), nt) - - # radii raw acceptance (unchanged) - if has_radii(T) - haskey(nt, :radius_in) || throw(ArgumentError("[$(_typename(T))] missing 'radius_in'.")) - haskey(nt, :radius_ext) || throw(ArgumentError("[$(_typename(T))] missing 'radius_ext'.")) - is_radius_input(T, Val(:radius_in), nt.radius_in) || - throw(ArgumentError("[$(_typename(T))] radius_in not an accepted input: $(typeof(nt.radius_in))")) - is_radius_input(T, Val(:radius_ext), nt.radius_ext) || - throw(ArgumentError("[$(_typename(T))] radius_ext not an accepted input: $(typeof(nt.radius_ext))")) - end - return nt -end - -""" -$(TYPEDSIGNATURES) - -Parses and normalizes raw inputs produced by [`sanitize`](@ref) into the canonical form expected by rules. Default is identity; component code overrides this to resolve proxy radii to numeric values while preserving uncertainty semantics. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. -- `nt::NamedTuple`: Raw inputs from `sanitize` \\[dimensionless\\]. - -# Returns - -- `NamedTuple` with normalized fields (e.g., numeric `:radius_in`, `:radius_ext`). -""" -parse(::Type, nt) = nt - -""" -$(TYPEDSIGNATURES) - -Generates (at compile time, via a `@generated` function) the tuple of rules to apply for component type `T`. The result concatenates standard bundles driven by traits and any rules returned by `extra_rules(T)`. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. - -# Returns - -- Tuple of [`Rule`](@ref) instances to apply in order. -""" -@generated function _rules(::Type{T}) where {T} - :(( - (has_radii(T) ? (Normalized(:radius_in), Normalized(:radius_ext), - Finite(:radius_in), Nonneg(:radius_in), - Finite(:radius_ext), Nonneg(:radius_ext), - Less(:radius_in, :radius_ext)) : ())..., - (has_temperature(T) ? (Finite(:temperature),) : ())..., - extra_rules(T)... - )) -end - -""" -$(TYPEDSIGNATURES) - -Runs the full validation pipeline for a component type: `sanitize` (arity and raw checks), `parse` (proxy normalization), then application of the generated rule set. Intended to be called from convenience constructors. - -# Arguments - -- `::Type{T}`: Component type \\[dimensionless\\]. -- `args...`: Positional arguments \\[dimensionless\\]. -- `kwargs...`: Keyword arguments \\[dimensionless\\]. - -# Returns - -- `NamedTuple` containing normalized fields ready for construction. - -# Errors - -- `ArgumentError` from `sanitize` or rule checks; `DomainError` for finiteness violations. - -# Examples - -```julia -nt = $(FUNCTIONNAME)(Tubular, 0.01, 0.02, material; temperature = 20.0) -# use nt.radius_in, nt.radius_ext, nt.temperature thereafter -``` - -# See also -- [`sanitize`](@ref) -- [`parse`](@ref) -- [`coercive_fields`](@ref) -""" -function validate!(::Type{T}, args...; kwargs...) where {T} - # One validate! to rule them all - - nt0 = sanitize(T, args, (; kwargs...)) - nt1 = parse(T, nt0) - # if has_radii: Normalized ensures numbers post-parse; if not numbers, rules will throw - rules = _rules(T) - @inbounds for i in eachindex(rules) - _apply(rules[i], nt1, T) - end - return nt1 -end - -end # module Validation +""" + LineCableModels.Validation + +The [`Validation`](@ref) module implements a trait-driven, three-phase input checking pipeline for component constructors in `LineCableModels`. Inputs are first *sanitized* (arity and shape checks on raw arguments), then *parsed* (proxy values normalized to numeric radii), and finally validated by a generated set of rules. + + +# Overview + +- Centralized constructor input handling: `sanitize` → `parse` → rule application. +- Trait hooks configure per‑type behavior (`has_radii`, `has_temperature`, `required_fields`, `keyword_fields`, `coercive_fields`, etc.). +- Rules are small value objects (`Rule` subtypes) applied to a normalized `NamedTuple`. + +# Dependencies + +$(IMPORTS) + +# Exports + +$(EXPORTS) +""" +module Validation + +# Export public API +export validate!, has_radii, has_temperature, extra_rules, + sanitize, parse, is_radius_input, required_fields, keyword_fields, keyword_defaults, coercive_fields, Finite, Nonneg, Positive, IntegerField, Less, LessEq, IsA, Normalized, OneOf + +# Module-specific dependencies +using ..Commons + +""" +$(TYPEDEF) + +Base abstract type for validation rules. All concrete rule types must subtype [`Rule`](@ref) and provide an `_apply(::Rule, nt, ::Type{T})` method that checks a field in the normalized `NamedTuple` `nt` for the component type `T`. + +$(TYPEDFIELDS) +""" +abstract type Rule end + +""" +$(TYPEDEF) + +Rule that enforces finiteness of a numeric field. + +$(TYPEDFIELDS) +""" +struct Finite <: Rule + "Name of the field to check." + name::Symbol +end + +""" +$(TYPEDEF) + +Rule that enforces a field to be non‑negative (`≥ 0`). + +$(TYPEDFIELDS) +""" +struct Nonneg <: Rule + "Name of the field to check." + name::Symbol +end + +""" +$(TYPEDEF) + +Rule that enforces a field to be strictly positive (`> 0`). + +$(TYPEDFIELDS) +""" +struct Positive <: Rule + "Name of the field to check." + name::Symbol +end + +""" +$(TYPEDEF) + +Rule that enforces a field to be of an integer type. + +$(TYPEDFIELDS) +""" +struct IntegerField <: Rule + "Name of the field to check." + name::Symbol +end + +""" +$(TYPEDEF) + +Rule that enforces a strict ordering constraint `a < b` between two fields. + +$(TYPEDFIELDS) +""" +struct Less <: Rule + "Left‑hand field name." + a::Symbol + "Right‑hand field name." + b::Symbol +end + +""" +$(TYPEDEF) + +Rule that enforces a non‑strict ordering constraint `a ≤ b` between two fields. + +$(TYPEDFIELDS) +""" +struct LessEq <: Rule + "Left‑hand field name." + a::Symbol + "Right‑hand field name." + b::Symbol +end + +""" +$(TYPEDEF) + +Rule that enforces a field to be `isa M` for a specified type parameter `M`. + +$(TYPEDFIELDS) +""" +struct IsA{M} <: Rule + "Name of the field to check." + name::Symbol +end + +""" +$(TYPEDEF) + +Rule that enforces that a field has already been normalized to a numeric value during parsing. Intended to guard that `parse` has executed and removed proxies. + +$(TYPEDFIELDS) +""" +struct Normalized <: Rule + "Name of the field to check." + name::Symbol +end + +""" +$(TYPEDEF) + +Rule that enforces a field to be `in` the set `S`. + +$(TYPEDFIELDS) +""" +struct OneOf{S} <: Rule + name::Symbol + set::S +end + + +""" +$(TYPEDSIGNATURES) + +Returns the simple (unqualified) name of type `T` as a `String`. Utility for constructing diagnostic messages. + +# Arguments + +- `::Type{T}`: Type whose name is requested \\[dimensionless\\]. + +# Returns + +- `String` with the type name \\[dimensionless\\]. + +# Examples + +```julia +name = $(FUNCTIONNAME)(Float64) # "Float64" +``` +""" +@inline _typename(::Type{T}) where {T} = String(nameof(T)) + +""" +$(TYPEDSIGNATURES) + +Returns a compact textual representation of `x` for error messages. + +# Arguments + +- `x`: Value to represent \\[dimensionless\\]. + +# Returns + +- `String` with a compact `repr` \\[dimensionless\\]. + +# Examples + +```julia +s = $(FUNCTIONNAME)(:field) # ":field" +``` +""" +@inline _repr(x) = repr(x; context=:compact => true) + +""" +$(TYPEDSIGNATURES) + +Asserts that `x` is a real (non‑complex) number. Used by rule implementations before performing numeric comparisons. + +# Arguments + +- `field`: Field name used in diagnostics \\[dimensionless\\]. +- `x`: Value to check \\[dimensionless\\]. +- `::Type{T}`: Component type for contextualized messages \\[dimensionless\\]. + +# Returns + +- Nothing. Throws on failure. + +# Errors + +- `ArgumentError` if `x` is not `isa Number` or is a `Complex` value. + +# Examples + +```julia +$(FUNCTIONNAME)(:radius_in, 0.01, SomeType) # ok +``` +""" +@inline function _ensure_real(field::Symbol, x, ::Type{T}) where {T} + if !(x isa Number) || x isa Complex + throw(ArgumentError("[$(_typename(T))] $field must be a real number, got $(typeof(x)): $(_repr(x))")) + end +end + +""" +$(TYPEDSIGNATURES) + +Applies [`Finite`](@ref) to ensure the target field is a finite real number. + +# Arguments + +- `r`: Rule instance \\[dimensionless\\]. +- `nt`: Normalized `NamedTuple` of inputs \\[dimensionless\\]. +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Nothing. Throws on failure. +""" +@inline function _apply(r::Finite, nt, ::Type{T}) where {T} + x = getfield(nt, r.name) + _ensure_real(r.name, x, T) + isfinite(x) || throw(DomainError("[$(_typename(T))] $(r.name) must be finite, got $x")) +end + +""" +$(TYPEDSIGNATURES) + +Applies [`Nonneg`](@ref) to ensure the target field is `≥ 0`. + +# Arguments + +- `r`: Rule instance \\[dimensionless\\]. +- `nt`: Normalized `NamedTuple` of inputs \\[dimensionless\\]. +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Nothing. Throws on failure. +""" +@inline function _apply(r::Nonneg, nt, ::Type{T}) where {T} + x = getfield(nt, r.name) + _ensure_real(r.name, x, T) + x >= 0 || throw(ArgumentError("[$(_typename(T))] $(r.name) must be ≥ 0, got $x")) +end + +""" +$(TYPEDSIGNATURES) + +Applies [`Positive`](@ref) to ensure the target field is `> 0`. + +# Arguments + +- `r`: Rule instance \\[dimensionless\\]. +- `nt`: Normalized `NamedTuple` of inputs \\[dimensionless\\]. +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Nothing. Throws on failure. +""" +@inline function _apply(r::Positive, nt, ::Type{T}) where {T} + x = getfield(nt, r.name) + _ensure_real(r.name, x, T) + x > 0 || throw(ArgumentError("[$(_typename(T))] $(r.name) must be > 0, got $x")) +end + +""" +$(TYPEDSIGNATURES) + +Applies [`IntegerField`](@ref) to ensure the target field is an `Integer`. + +# Arguments + +- `r`: Rule instance \\[dimensionless\\]. +- `nt`: Normalized `NamedTuple` of inputs \\[dimensionless\\]. +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Nothing. Throws on failure. +""" +@inline function _apply(r::IntegerField, nt, ::Type{T}) where {T} + x = getfield(nt, r.name) + x isa Integer || throw(ArgumentError("[$(_typename(T))] $(r.name) must be Integer, got $(typeof(x))")) +end + +""" +$(TYPEDSIGNATURES) + +Applies [`Less`](@ref) to ensure `nt[a] < nt[b]`. + +# Arguments + +- `r`: Rule instance with fields `a` and `b` \\[dimensionless\\]. +- `nt`: Normalized `NamedTuple` \\[dimensionless\\]. +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Nothing. Throws on failure. +""" +@inline function _apply(r::Less, nt, ::Type{T}) where {T} + a = getfield(nt, r.a) + b = getfield(nt, r.b) + _ensure_real(r.a, a, T) + _ensure_real(r.b, b, T) + a < b || throw(ArgumentError("[$(_typename(T))] $(r.a) < $(r.b) violated (got $a ≥ $b)")) +end + +""" +$(TYPEDSIGNATURES) + +Applies [`LessEq`](@ref) to ensure `nt[a] ≤ nt[b]`. + +# Arguments + +- `r`: Rule instance with fields `a` and `b` \\[dimensionless\\]. +- `nt`: Normalized `NamedTuple` \\[dimensionless\\]. +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Nothing. Throws on failure. +""" +@inline function _apply(r::LessEq, nt, ::Type{T}) where {T} + a = getfield(nt, r.a) + b = getfield(nt, r.b) + _ensure_real(r.a, a, T) + _ensure_real(r.b, b, T) + a <= b || throw(ArgumentError("[$(_typename(T))] $(r.a) ≤ $(r.b) violated (got $a > $b)")) +end + +""" +$(TYPEDSIGNATURES) + +Applies [`IsA{M}`](@ref) to ensure a field is of type `M`. + +# Arguments + +- `r`: Rule instance parameterized by `M` \\[dimensionless\\]. +- `nt`: Normalized `NamedTuple` \\[dimensionless\\]. +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Nothing. Throws on failure. +""" +@inline function _apply(r::IsA{M}, nt, ::Type{T}) where {T,M} + x = getfield(nt, r.name) + x isa M || throw(ArgumentError("[$(_typename(T))] $(r.name) must be $(M), got $(typeof(x))")) +end + +""" +$(TYPEDSIGNATURES) + +Applies [`Normalized`](@ref) to ensure the field has been converted to a numeric value during parsing. + +# Arguments + +- `r`: Rule instance \\[dimensionless\\]. +- `nt`: Normalized `NamedTuple` \\[dimensionless\\]. +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Nothing. Throws on failure. +""" +@inline function _apply(r::Normalized, nt, ::Type{T}) where {T} + x = getfield(nt, r.name) + x isa Number || throw(ArgumentError("[$(_typename(T))] $(r.name) must be normalized Number; got $(typeof(x))")) +end + +""" +$(TYPEDSIGNATURES) + +Applies [`OneOf`](@ref) to ensure the target field is contained in a specified set. + +# Arguments + +- `r`: Rule instance with fields `name` and `set` \\[dimensionless\\]. +- `nt`: Normalized `NamedTuple` of inputs \\[dimensionless\\]. +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Nothing. Throws on failure. +""" +@inline function _apply(r::OneOf{S}, nt, ::Type{T}) where {S,T} + x = getfield(nt, r.name) + (x in r.set) || throw(ArgumentError("[$(String(nameof(T)))] $(r.name) must be one of $(collect(r.set)); got $(x)")) +end + +""" +$(TYPEDSIGNATURES) + +Trait hook enabling the annular radii rule bundle on fields `:radius_in` and `:radius_ext` (normalized numbers required, finiteness, non‑negativity, and the ordering constraint `:radius_in` < `:radius_ext`). It does **not** indicate the mere existence of radii; it opts in to the annular/coaxial shell geometry checks. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- `Bool` flag. + +# Examples + +```julia +Validation.has_radii(Tubular) # true +``` +""" +has_radii(::Type) = false # Default = false/empty. Components extend these. + +""" +$(TYPEDSIGNATURES) + +Trait hook indicating whether a component type uses a `:temperature` field subject to finiteness checks. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- `Bool` flag. +""" +has_temperature(::Type) = false + +""" +$(TYPEDSIGNATURES) + +Trait hook providing additional rule instances for a component type. Used to append per‑type constraints after the standard bundles. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Tuple of [`Rule`](@ref) instances. +""" +extra_rules(::Type) = () # per-type extras + +""" +$(TYPEDSIGNATURES) + +Trait hook listing required fields that must be present after positional→named merge in `sanitize`. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Tuple of required field names. +""" +required_fields(::Type) = () + +""" +$(TYPEDSIGNATURES) + +Trait hook listing optional keyword fields considered by `sanitize`. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Tuple of optional keyword field names. +""" +keyword_fields(::Type) = () + +""" +$(TYPEDSIGNATURES) + +Trait hook supplying **default values** for optional keyword fields. + +Return either: +- a `NamedTuple` mapping defaults by name (e.g., `(temperature = T₀,)`), or +- a plain `Tuple` of defaults aligned with `keyword_fields(T)` (same order + length). + +Defaults are applied in `sanitize` **after** positional→named merge and before rule application. +User-provided keywords always override these defaults. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- `NamedTuple` or `Tuple` with default keyword values. +""" +keyword_defaults(::Type) = () + +""" +$(TYPEDSIGNATURES) + +Trait hook listing **coercive** fields: values that participate in numeric promotion and will be converted to the promoted type by the convenience constructor. Defaults to all fields (`required_fields ∪ keyword_fields`). Types may override to exclude integers or categorical fields. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Tuple of field names that are coerced. +""" +coercive_fields(::Type{T}) where {T} = (required_fields(T)..., keyword_fields(T)...) + +""" +$(TYPEDSIGNATURES) + +Trait predicate that defines admissible *raw* radius inputs for a component type during `sanitize`. The default accepts real, non‑complex numbers only. Component code may extend this to allow proxies (e.g., `AbstractCablePart`, `Thickness`, `Diameter`). + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. +- `x`: Candidate value \\[dimensionless\\]. + +# Returns + +- `Bool` indicating acceptance. + +# Examples + +```julia +Validation.is_radius_input(Tubular, 0.01) # true by default +Validation.is_radius_input(Tubular, 1 + 0im) # false (complex) +``` + +# See also + +- [`sanitize`](@ref) +""" +is_radius_input(::Type{T}, x) where {T} = (x isa Number) && !(x isa Complex) + +""" +$(TYPEDSIGNATURES) + +Field‑aware acceptance predicate used by `sanitize` to distinguish inner vs. outer radius policies. The default forwards to [`is_radius_input(::Type{T}, x)`](@ref) when no field‑specific method is defined. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. +- `::Val{F}`: Field tag; typically `Val(:radius_in)` or `Val(:radius_ext)` \\[dimensionless\\]. +- `x`: Candidate value \\[dimensionless\\]. + +# Returns + +- `Bool` indicating acceptance. + +# Examples + +```julia +Validation.is_radius_input(Tubular, Val(:radius_in), 0.01) # true +Validation.is_radius_input(Tubular, Val(:radius_ext), 0.01) # true +``` + +# See also + +- [`sanitize`](@ref) +- [`is_radius_input(::Type{T}, x)`](@ref) +""" +is_radius_input(::Type{T}, ::Val{F}, x) where {T,F} = is_radius_input(T, x) + +""" +$(TYPEDSIGNATURES) + +Default policy for **inner** radius raw inputs: accept real numbers. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. +- `::Val{:radius_in}`: Field tag for the inner radius \\[dimensionless\\]. +- `x::Number`: Candidate value \\[dimensionless\\]. + +# Returns + +- `Bool` indicating acceptance (`true` for real, non‑complex numbers). + +# Examples + +```julia +Validation.is_radius_input(Tubular, Val(:radius_in), 0.0) # true +Validation.is_radius_input(Tubular, Val(:radius_in), 1+0im) # false +``` +""" +is_radius_input(::Type{T}, ::Val{:radius_in}, x::Number) where {T} = (x isa Number) && !(x isa Complex) +is_radius_input(::Type{T}, ::Val{:radius_in}, ::Any) where {T} = false + +""" +$(TYPEDSIGNATURES) + +Default policy for **outer** radius raw inputs (annular shells): accept real numbers. Proxies are rejected at this stage to prevent zero‑thickness stacking. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. +- `::Val{:radius_ext}`: Field tag for the outer radius \\[dimensionless\\]. +- `x::Number`: Candidate value \\[dimensionless\\]. + +# Returns + +- `Bool` indicating acceptance (`true` for real, non‑complex numbers). + +# Examples + +```julia +Validation.is_radius_input(Tubular, Val(:radius_ext), 0.02) # true +``` +""" +is_radius_input(::Type{T}, ::Val{:radius_ext}, x::Number) where {T} = (x isa Number) && !(x isa Complex) +is_radius_input(::Type{T}, ::Val{:radius_ext}, ::Any) where {T} = false + +""" +$(TYPEDSIGNATURES) + +Internal helper that canonicalizes `keyword_defaults(T)` into a `NamedTuple` +keyed by `keyword_fields(T)`. Accepts: + +- `()` → returns an empty `NamedTuple()`. +- `NamedTuple` → returned unchanged. +- `Tuple` → zipped **by position** with `keyword_fields(T)`; lengths must match. + +This function does **not** merge user-provided keywords; callers should perform +`merge(_kwdefaults_nt(T), kwargs)` so that user values take precedence. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- `NamedTuple` mapping optional keyword field names to their default values. + +# Errors + +- If `keyword_defaults(T)` returns a `Tuple` whose length differs from + `length(keyword_fields(T))`. +- If `keyword_defaults(T)` returns a value that is neither `()` nor a + `NamedTuple` nor a `Tuple`. + +# Examples + +```julia +# Suppose: +# keyword_fields(::Type{X}) = (:temperature, :lay_direction) +# keyword_defaults(::Type{X}) = (T₀, 1) + +$(FUNCTIONNAME)(X) # => (temperature = T₀, lay_direction = 1) + +# If defaults are already a NamedTuple: +# keyword_defaults(::Type{Y}) = (temperature = 25.0,) +$(FUNCTIONNAME)(Y) # => (temperature = 25.0,) +```` + +# See also + +* [`keyword_fields`](@ref) +* [`keyword_defaults`](@ref) +* [`sanitize`](@ref) + """ +@inline function _kwdefaults_nt(::Type{T}) where {T} + defs = keyword_defaults(T) + defs === () && return NamedTuple() + if defs isa NamedTuple + return defs + elseif defs isa Tuple + keys = keyword_fields(T) + length(keys) == length(defs) || + Base.error("[$(String(nameof(T)))] keyword_defaults length $(length(defs)) ≠ keyword_fields length $(length(keys))") + return NamedTuple{keys}(defs) + else + Base.error("[$(String(nameof(T)))] keyword_defaults must be NamedTuple or Tuple; got $(typeof(defs))") + end +end + +""" +$(TYPEDSIGNATURES) + +Performs raw input checks and shapes the input into a `NamedTuple` without parsing proxies. Responsibilities: arity validation, positional→named mapping, required field presence, and raw acceptance of radius inputs via `is_radius_input` when `has_radii(T)` is true. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. +- `args::Tuple`: Positional arguments as received by the convenience constructor \\[dimensionless\\]. +- `kwargs::NamedTuple`: Keyword arguments \\[dimensionless\\]. + +# Returns + +- `NamedTuple` with raw (unparsed) fields. + +# Errors + +- `ArgumentError` on invalid arity, excess positional arguments, missing required fields, or rejected raw radius inputs. + +# Notes + +Required arguments must be positional; optional arguments must be passed as keywords. Positional arity must equal `length(required_fields(T))`. + +# Examples + +```julia +nt = $(FUNCTIONNAME)(Tubular, (0.01, 0.02, material), (; temperature = 20.0,)) +``` +""" +function sanitize(::Type{T}, args::Tuple, kwargs::NamedTuple) where {T} + # -- hard arity on required positionals -- + req = required_fields(T) + kw = keyword_fields(T) + nreq = length(req) + na = length(args) + if na != nreq + names = join(string.(req), ", ") + throw(ArgumentError("[$(_typename(T))] expected exactly $nreq positional args ($names); got $na. Optionals must be keywords.")) + end + + # positional -> named + nt_pos = (; (req[i] => args[i] for i = 1:nreq)...) + + # reject unknown keywords (strict) + for k in keys(kwargs) + if !(k in kw) && !(k in req) + throw(ArgumentError("[$(_typename(T))] unknown keyword '$k'. Allowed keywords: $(join(string.(kw), ", ")).")) + end + end + + # user kw override positionals (if any same names) + nt = merge(nt_pos, kwargs) + + # backfill missing optional keywords with trait defaults --- + # defaults first, then user-provided values win + nt = merge(_kwdefaults_nt(T), nt) + + # radii raw acceptance (unchanged) + if has_radii(T) + haskey(nt, :radius_in) || throw(ArgumentError("[$(_typename(T))] missing 'radius_in'.")) + haskey(nt, :radius_ext) || throw(ArgumentError("[$(_typename(T))] missing 'radius_ext'.")) + is_radius_input(T, Val(:radius_in), nt.radius_in) || + throw(ArgumentError("[$(_typename(T))] radius_in not an accepted input: $(typeof(nt.radius_in))")) + is_radius_input(T, Val(:radius_ext), nt.radius_ext) || + throw(ArgumentError("[$(_typename(T))] radius_ext not an accepted input: $(typeof(nt.radius_ext))")) + end + return nt +end + +""" +$(TYPEDSIGNATURES) + +Parses and normalizes raw inputs produced by [`sanitize`](@ref) into the canonical form expected by rules. Default is identity; component code overrides this to resolve proxy radii to numeric values while preserving uncertainty semantics. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. +- `nt::NamedTuple`: Raw inputs from `sanitize` \\[dimensionless\\]. + +# Returns + +- `NamedTuple` with normalized fields (e.g., numeric `:radius_in`, `:radius_ext`). +""" +parse(::Type, nt) = nt + +""" +$(TYPEDSIGNATURES) + +Generates (at compile time, via a `@generated` function) the tuple of rules to apply for component type `T`. The result concatenates standard bundles driven by traits and any rules returned by `extra_rules(T)`. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. + +# Returns + +- Tuple of [`Rule`](@ref) instances to apply in order. +""" +@generated function _rules(::Type{T}) where {T} + :(( + (has_radii(T) ? (Normalized(:radius_in), Normalized(:radius_ext), + Finite(:radius_in), Nonneg(:radius_in), + Finite(:radius_ext), Nonneg(:radius_ext), + Less(:radius_in, :radius_ext)) : ())..., + (has_temperature(T) ? (Finite(:temperature),) : ())..., + extra_rules(T)... + )) +end + +""" +$(TYPEDSIGNATURES) + +Runs the full validation pipeline for a component type: `sanitize` (arity and raw checks), `parse` (proxy normalization), then application of the generated rule set. Intended to be called from convenience constructors. + +# Arguments + +- `::Type{T}`: Component type \\[dimensionless\\]. +- `args...`: Positional arguments \\[dimensionless\\]. +- `kwargs...`: Keyword arguments \\[dimensionless\\]. + +# Returns + +- `NamedTuple` containing normalized fields ready for construction. + +# Errors + +- `ArgumentError` from `sanitize` or rule checks; `DomainError` for finiteness violations. + +# Examples + +```julia +nt = $(FUNCTIONNAME)(Tubular, 0.01, 0.02, material; temperature = 20.0) +# use nt.radius_in, nt.radius_ext, nt.temperature thereafter +``` + +# See also +- [`sanitize`](@ref) +- [`parse`](@ref) +- [`coercive_fields`](@ref) +""" +function validate!(::Type{T}, args...; kwargs...) where {T} + # One validate! to rule them all + + nt0 = sanitize(T, args, (; kwargs...)) + nt1 = parse(T, nt0) + # if has_radii: Normalized ensures numbers post-parse; if not numbers, rules will throw + rules = _rules(T) + @inbounds for i in eachindex(rules) + _apply(rules[i], nt1, T) + end + return nt1 +end + +end # module Validation diff --git a/test/aqua.jl b/test/aqua.jl index cda8ad5d..42f53ed0 100644 --- a/test/aqua.jl +++ b/test/aqua.jl @@ -1,5 +1,5 @@ -@testitem "Aqua tests" tags=[:skipci] begin - using Aqua - Aqua.test_all(LineCableModels) -end - +@testitem "Aqua tests" tags=[:skipci] begin + using Aqua + Aqua.test_all(LineCableModels) +end + diff --git a/test/baseparams.jl b/test/baseparams.jl index e2c8cdf7..28c3ef62 100644 --- a/test/baseparams.jl +++ b/test/baseparams.jl @@ -1,368 +1,368 @@ -@testitem "BaseParams module" setup = [defaults, defs_materials] begin - @testset "Temperature correction" begin - alpha = 0.004 - T0 = 20.0 - # Correction factor should be 1 at reference temperature - @test calc_temperature_correction(alpha, T0, T0) ≈ 1.0 atol = TEST_TOL - # Test T > T0 - @test calc_temperature_correction(alpha, 30.0, T0) ≈ (1 + alpha * (30.0 - T0)) atol = TEST_TOL - # Test T < T0 - @test calc_temperature_correction(alpha, 10.0, T0) ≈ (1 + alpha * (10.0 - T0)) atol = TEST_TOL - # No correction if alpha is zero - @test calc_temperature_correction(0.0, 50.0, T0) ≈ 1.0 atol = TEST_TOL - end - - @testset "Parallel impedance calculations" begin - # Parallel equivalent of two equal resistors - @test calc_parallel_equivalent(10.0, 10.0) ≈ 5.0 atol = TEST_TOL - # Parallel equivalent of two different resistors - @test calc_parallel_equivalent(10.0, 5.0) ≈ (10.0 * 5.0) / (10.0 + 5.0) atol = - TEST_TOL - # Adding infinite resistance changes nothing - @test calc_parallel_equivalent(10.0, Inf) ≈ 10.0 atol = TEST_TOL - # Adding zero resistance results in zero (short circuit) - @test calc_parallel_equivalent(10.0, 0.0) ≈ 0.0 atol = TEST_TOL - - # Complex numbers (impedances) - Z1 = 3.0 + 4.0im - Z2 = 8.0 - 6.0im - Zeq_expected = (Z1 * Z2) / (Z1 + Z2) - # Parallel equivalent of complex impedances - @test calc_parallel_equivalent(Z1, Z2) ≈ Zeq_expected atol = TEST_TOL - # Parallel equivalent of two equal complex impedances - @test calc_parallel_equivalent(Z1, Z1) ≈ Z1 / 2 atol = TEST_TOL - end - - @testset "Equivalent temperature coefficient" begin - alpha1, R1 = 0.004, 10.0 - alpha2, R2 = 0.003, 5.0 - expected_alpha = (alpha1 * R2 + alpha2 * R1) / (R1 + R2) - @test calc_equivalent_alpha(alpha1, R1, alpha2, R2) ≈ expected_alpha atol = TEST_TOL - # Equivalent alpha of identical conductors - @test calc_equivalent_alpha(alpha1, R1, alpha1, R1) ≈ alpha1 atol = TEST_TOL - # Check symmetry - @test calc_equivalent_alpha(alpha1, R1, alpha2, R2) ≈ - calc_equivalent_alpha(alpha2, R2, alpha1, R1) atol = TEST_TOL - end - - @testset "Resistance calculations" begin - # Using Copper properties - rho = copper_props.rho - alpha = copper_props.alpha - T0 = copper_props.T0 - T = T0 # Test at reference temperature first - - # calc_tubular_resistance - r_in, r_ext = 0.01, 0.02 - area_tube = π * (r_ext^2 - r_in^2) - # Tubular resistance at T0 - @test calc_tubular_resistance(r_in, r_ext, rho, alpha, T0, T) ≈ rho / area_tube atol = - TEST_TOL - # Solid conductor - area_solid = π * r_ext^2 - # Solid conductor resistance (r_in=0) - @test calc_tubular_resistance(0.0, r_ext, rho, alpha, T0, T) ≈ rho / area_solid atol = - TEST_TOL - # Temperature dependence - T_hot = 70.0 - k = calc_temperature_correction(alpha, T_hot, T0) - # Tubular resistance temperature dependence - @test calc_tubular_resistance(r_in, r_ext, rho, alpha, T0, T_hot) ≈ - (rho / area_tube) * k atol = TEST_TOL - # Thin tube limit (resistance should increase) - check relative magnitude - r_in_thin = r_ext * 0.999 - # Thin tube has higher resistance - @test calc_tubular_resistance(r_in_thin, r_ext, rho, alpha, T0, T) > - calc_tubular_resistance(r_in, r_ext, rho, alpha, T0, T) - - # calc_strip_resistance - thickness, width = 0.002, 0.05 - area_strip = thickness * width - # Strip resistance at T0 - @test calc_strip_resistance(thickness, width, rho, alpha, T0, T) ≈ rho / area_strip atol = - TEST_TOL - # Strip resistance temperature dependence - @test calc_strip_resistance(thickness, width, rho, alpha, T0, T_hot) ≈ - (rho / area_strip) * k atol = TEST_TOL - end - - @testset "Helical parameters correction" begin - r_in, r_ext = 0.01, 0.015 - mean_diam_expected = r_in + r_ext # 0.025 - lay_ratio = 12.0 - pitch_expected = lay_ratio * mean_diam_expected # 12.0 * 0.025 = 0.3 - - mean_diam, pitch, overlength = calc_helical_params(r_in, r_ext, lay_ratio) - @test mean_diam ≈ mean_diam_expected atol = TEST_TOL - @test pitch ≈ pitch_expected atol = TEST_TOL - @test overlength ≈ sqrt(1 + (π * mean_diam_expected / pitch_expected)^2) atol = - TEST_TOL - # Overlength factor must be > 1 for finite lay ratio - @test overlength > 1.0 - - # Edge case: No twist (infinite pitch length) - mean_diam_no, pitch_no, overlength_no = calc_helical_params(r_in, r_ext, 0.0) - # Note: lay_ratio=0 implies pitch=0 in the code, which makes overlength=1 - @test mean_diam_no ≈ mean_diam_expected atol = TEST_TOL - @test pitch_no == 0.0 - # Overlength factor must be 1 for zero lay ratio (infinite pitch) - @test overlength_no ≈ 1.0 atol = TEST_TOL - end - - @testset "GMR calculations & consistency" begin - r_in, r_ext = 0.01, 0.02 - mu_r = 1.0 # Non-magnetic - - # calc_tubular_gmr - gmr_tube = calc_tubular_gmr(r_ext, r_in, mu_r) - # GMR of tube should be less than outer radius - @test gmr_tube < r_ext - # GMR must be positive - @test gmr_tube > 0 - - # Solid conductor GMR - gmr_solid_expected = r_ext * exp(-mu_r / 4.0) - gmr_solid_calc = calc_tubular_gmr(r_ext, 0.0, mu_r) - # Solid conductor GMR (analytical) - @test gmr_solid_calc ≈ gmr_solid_expected atol = TEST_TOL - - # Thin shell GMR - gmr_shell_calc = calc_tubular_gmr(r_ext, r_ext * (1 - 1e-12), mu_r) # Approx thin shell - # Thin shell GMR approaches outer radius # Relax tolerance slightly - @test gmr_shell_calc ≈ r_ext atol = 1e-5 - - # Magnetic material - mu_r_mag = 100.0 - gmr_solid_mag = calc_tubular_gmr(r_ext, 0.0, mu_r_mag) - gmr_solid_mag_expected = r_ext * exp(-mu_r_mag / 4.0) - # Solid conductor GMR with mu_r > 1 - @test gmr_solid_mag ≈ gmr_solid_mag_expected atol = TEST_TOL - # Higher mu_r should decrease GMR - @test gmr_solid_mag < gmr_solid_calc - - # Error handling - @test_throws ArgumentError calc_tubular_gmr(r_in, r_ext, mu_r) # Should throw error if r_ext < r_in - - # calc_equivalent_mu (inverse consistency) - @test calc_equivalent_mu(gmr_tube, r_ext, r_in) ≈ mu_r atol = TEST_TOL # Inverse check: mu_r from tubular GMR - @test calc_equivalent_mu(gmr_solid_calc, r_ext, 0.0) ≈ mu_r atol = TEST_TOL # Inverse check: mu_r from solid GMR - @test calc_equivalent_mu(gmr_solid_mag, r_ext, 0.0) ≈ mu_r_mag atol = TEST_TOL # Inverse check: magnetic mu_r from solid GMR - @test_throws ArgumentError calc_equivalent_mu(gmr_tube, r_in, r_ext) # Should throw error if r_ext < r_in - - # calc_wirearray_gmr - wire_rad = 0.001 - num_wires = 7 - layout_rad = 0.005 # Center-to-center radius - gmr_array = calc_wirearray_gmr(layout_rad, num_wires, wire_rad, mu_r) - # Single wire case should match solid wire GMR - @test gmr_array > 0 - - gmr_single_wire_array = calc_wirearray_gmr(0.0, 1, wire_rad, mu_r) # Layout radius irrelevant for N=1 - gmr_single_wire_solid = calc_tubular_gmr(wire_rad, 0.0, mu_r) - # GMR of 1-wire array matches solid wire GMR - @test gmr_single_wire_array ≈ gmr_single_wire_solid atol = TEST_TOL - - end - - @testset "GMD and equivalent GMR" begin - # Need some cable parts - part1_solid = Tubular(0.0, 0.01, copper_props) # Solid conductor r=1cm - part2_tubular = Tubular(0.015, 0.02, copper_props) # Tubular conductor, separate - part3_wirearray = WireArray(0.03, 0.002, 7, 10.0, aluminum_props) # Wire array, separate - - # calc_gmd - # Case 1: Two separate solid/tubular conductors (distance between centers) - # Place part2 at (d, 0) relative to part1 at (0,0) - d = 0.1 # 10 cm separation - # GMD calculation for simple geometries relies on center-to-center distance if not wire arrays - # This test might be trivial for Tubular/Tubular if code assumes center-to-center - # Let's test Tubular vs WireArray where sub-elements exist - gmd_1_3 = calc_gmd(part1_solid, part3_wirearray) # Should be approx layout_radius of part3 (0.03 + 0.002) if part1 is at center - # GMD between central solid and wire array approx layout radius - @test gmd_1_3 ≈ (0.03 + 0.002) atol = 1e-4 - - # Case 2: Concentric Tubular Conductors (test based on comment in code) - part_inner = Tubular(0.01, 0.02, copper_props) - part_outer = Tubular(0.02, 0.03, copper_props) # Directly outside inner part - # If truly concentric, d_ij = 0 for internal logic, should return max(r_ext1, r_ext2) - gmd_concentric = calc_gmd(part_inner, part_outer) - # GMD of concentric tubular conductors - @test gmd_concentric ≈ part_outer.radius_ext atol = TEST_TOL - - # calc_equivalent_gmr - # Create a conductor group to test adding layers - core = ConductorGroup(part1_solid) - layer2 = Tubular(core.radius_ext, 0.015, aluminum_props) # Add tubular layer outside - beta = core.cross_section / (core.cross_section + layer2.cross_section) - gmd_core_layer2 = calc_gmd(core.layers[end], layer2) # GMD between solid core and new layer - - gmr_eq_expected = - (core.gmr^(beta^2)) * (layer2.gmr^((1 - beta)^2)) * - (gmd_core_layer2^(2 * beta * (1 - beta))) - gmr_eq_calc = calc_equivalent_gmr(core, layer2) # Test the function directly - @test gmr_eq_calc ≈ gmr_eq_expected atol = TEST_TOL - - # Test adding a WireArray layer - layer3_wa = WireArray(layer2.radius_ext, 0.001, 12, 15.0, copper_props) - # Need to update core equivalent properties first before calculating next step - core.gmr = gmr_eq_calc # Update core GMR based on previous step - core.cross_section += layer2.cross_section # Update core area - push!(core.layers, layer2) # Add layer for subsequent GMD calculation - - beta2 = core.cross_section / (core.cross_section + layer3_wa.cross_section) - gmd_core_layer3 = calc_gmd(core.layers[end], layer3_wa) # GMD between tubular layer2 and wire array layer3 - - gmr_eq2_expected = - (core.gmr^(beta2^2)) * (layer3_wa.gmr^((1 - beta2)^2)) * - (gmd_core_layer3^(2 * beta2 * (1 - beta2))) - gmr_eq2_calc = calc_equivalent_gmr(core, layer3_wa) - @test gmr_eq2_calc ≈ gmr_eq2_expected atol = TEST_TOL - end - - - @testset "Inductance calculations" begin - # calc_tubular_inductance - r_in, r_ext = 0.01, 0.02 - mu_r = 1.0 - L_expected = mu_r * μ₀ / (2 * π) * log(r_ext / r_in) - @test calc_tubular_inductance(r_in, r_ext, mu_r) ≈ L_expected atol = TEST_TOL - @test calc_tubular_inductance(r_in, r_ext, 2.0 * mu_r) ≈ 2.0 * L_expected atol = - TEST_TOL # Check mu_r scaling - # Internal inductance of solid conductor is infinite in this simple model - @test calc_tubular_inductance(0.0, r_ext, mu_r) == Inf - - # calc_inductance_trifoil - Requires benchmark data or simplified checks - # This is complex. A simple check could be ensuring L > 0 for typical inputs. - r_in_co, r_ext_co = 0.01, 0.015 - r_in_scr, r_ext_scr = 0.02, 0.022 - S = 0.1 - L_trifoil = - calc_inductance_trifoil(r_in_co, r_ext_co, copper_props.rho, copper_props.mu_r, - r_in_scr, r_ext_scr, copper_props.rho, copper_props.mu_r, S) - # Trifoil inductance should be positive - @test L_trifoil > 0 - # Could test sensitivity: increasing S should generally decrease L - L_trifoil_S2 = - calc_inductance_trifoil(r_in_co, r_ext_co, copper_props.rho, copper_props.mu_r, - r_in_scr, r_ext_scr, copper_props.rho, copper_props.mu_r, 2 * S) - # Increasing separation S should decrease L - @test L_trifoil_S2 < L_trifoil - - end - - @testset "Capacitance & conductance" begin - r_in, r_ext = 0.01, 0.02 - eps_r = insulator_props.eps_r - rho_ins = insulator_props.rho - - # calc_shunt_capacitance - C_expected = 2 * π * ε₀ * eps_r / log(r_ext / r_in) - @test calc_shunt_capacitance(r_in, r_ext, eps_r) ≈ C_expected atol = TEST_TOL - # Increasing r_ext decreases C - @test calc_shunt_capacitance(r_in, r_ext * 10, eps_r) < C_expected - # Decreasing r_in decreases C - @test calc_shunt_capacitance(r_in / 10, r_ext, eps_r) < C_expected - # If r_in -> r_ext, log -> 0, C -> Inf. Test approach? - # Capacitance -> Inf as r_in approaches r_ext - @test isinf(calc_shunt_capacitance(r_ext, r_ext, eps_r)) - - # calc_shunt_conductance - G_expected = 2 * π * (1 / rho_ins) / log(r_ext / r_in) - @test calc_shunt_conductance(r_in, r_ext, rho_ins) ≈ G_expected atol = TEST_TOL - # Lower rho increases G - @test calc_shunt_conductance(r_in, r_ext, rho_ins / 10) ≈ 10 * G_expected atol = - TEST_TOL - # Infinite rho (perfect insulator) gives zero G - @test calc_shunt_conductance(r_in, r_ext, Inf) ≈ 0.0 atol = TEST_TOL - # Conductance -> Inf as r_in approaches r_ext - @test isinf(calc_shunt_conductance(r_ext, r_ext, rho_ins)) - end - - @testset "Equivalent dielectric properties consistency" begin - r_in, r_ext = 0.01, 0.02 - eps_r = insulator_props.eps_r - rho_ins = insulator_props.rho - C_eq = calc_shunt_capacitance(r_in, r_ext, eps_r) - G_eq = calc_shunt_conductance(r_in, r_ext, rho_ins) - - # calc_equivalent_eps - # Inverse check: eps_r from C_eq - @test calc_equivalent_eps(C_eq, r_ext, r_in) ≈ eps_r atol = TEST_TOL - - # calc_sigma_lossfact & inverse check - sigma_eq = calc_sigma_lossfact(G_eq, r_in, r_ext) - # Check sigma_eq calculation - @test sigma_eq ≈ 1 / rho_ins atol = TEST_TOL - # Conductance from sigma - G_from_sigma = 2 * π * sigma_eq / log(r_ext / r_in) - # Inverse check: G_eq from sigma_eq - @test G_from_sigma ≈ G_eq atol = TEST_TOL - - # calc_equivalent_lossfact - f = 50.0 - ω = 2 * π * f - tand_expected = G_eq / (ω * C_eq) - @test calc_equivalent_lossfact(G_eq, C_eq, ω) ≈ tand_expected atol = TEST_TOL - end - - @testset "Equivalent resistivity consistency" begin - r_in, r_ext = 0.01, 0.02 - rho = copper_props.rho - alpha = copper_props.alpha - T0 = copper_props.T0 - - R_tube = calc_tubular_resistance(r_in, r_ext, rho, alpha, T0, T0) - rho_eq = calc_equivalent_rho(R_tube, r_ext, r_in) - # Inverse check: rho from R_tube - @test rho_eq ≈ rho atol = TEST_TOL - - R_solid = calc_tubular_resistance(0.0, r_ext, rho, alpha, T0, T0) - rho_eq_solid = calc_equivalent_rho(R_solid, r_ext, 0.0) - # Inverse check: rho from R_solid - @test rho_eq_solid ≈ rho atol = TEST_TOL - end - - @testset "Solenoid correction consistency" begin - num_turns = 10.0 # turns/m - r_con_ext = 0.01 - r_ins_ext = 0.015 - - mu_r_corr = calc_solenoid_correction(num_turns, r_con_ext, r_ins_ext) - # Correction factor should be > 1 for non-zero turns - @test mu_r_corr > 1.0 - - # No twist (num_turns = NaN as per code comment) - # Correction factor is 1 if num_turns is NaN - @test calc_solenoid_correction(NaN, r_con_ext, r_ins_ext) ≈ 1.0 atol = TEST_TOL - # Zero turns - # Correction factor is 1 if num_turns is - @test calc_solenoid_correction(0.0, r_con_ext, r_ins_ext) ≈ 1.0 atol = TEST_TOL - - # Edge case: r_con_ext == r_ins_ext (zero thickness insulator) - # This leads to log(1) = 0 in denominator. Should return 1 or NaN/Inf? - # Let's test the behavior. Assuming it might result in NaN due to 0/0 or X/0. - # Correction factor is likely NaN if radii are equal (0/0 form) - # Or maybe it should default to 1? Depends on desired behavior. - # If the function should handle this, add a check inside it. - @test isnan(calc_solenoid_correction(num_turns, r_con_ext, r_con_ext)) - - end - - @testset "Basic uncertainty propagation" begin - using Measurements - r_in_m = (0.01 ± 0.001) - r_ext_m = (0.02 ± 0.001) - rho_m = (1.7241e-8 ± 0.001e-8) - R_m = calc_tubular_resistance(r_in_m, r_ext_m, rho_m, (0.0 ± 0.0), (20.0 ± 0.0), (20.0 ± 0.0)) - @test Measurements.value(R_m) ≈ calc_tubular_resistance( - Measurements.value(r_in_m), - Measurements.value(r_ext_m), - Measurements.value(rho_m), - (0.0), - (20.0), - (20.0), - ) atol = - TEST_TOL - @test Measurements.uncertainty(R_m) > 0 - end -end +@testitem "BaseParams module" setup = [defaults, defs_materials] begin + @testset "Temperature correction" begin + alpha = 0.004 + T0 = 20.0 + # Correction factor should be 1 at reference temperature + @test calc_temperature_correction(alpha, T0, T0) ≈ 1.0 atol = TEST_TOL + # Test T > T0 + @test calc_temperature_correction(alpha, 30.0, T0) ≈ (1 + alpha * (30.0 - T0)) atol = TEST_TOL + # Test T < T0 + @test calc_temperature_correction(alpha, 10.0, T0) ≈ (1 + alpha * (10.0 - T0)) atol = TEST_TOL + # No correction if alpha is zero + @test calc_temperature_correction(0.0, 50.0, T0) ≈ 1.0 atol = TEST_TOL + end + + @testset "Parallel impedance calculations" begin + # Parallel equivalent of two equal resistors + @test calc_parallel_equivalent(10.0, 10.0) ≈ 5.0 atol = TEST_TOL + # Parallel equivalent of two different resistors + @test calc_parallel_equivalent(10.0, 5.0) ≈ (10.0 * 5.0) / (10.0 + 5.0) atol = + TEST_TOL + # Adding infinite resistance changes nothing + @test calc_parallel_equivalent(10.0, Inf) ≈ 10.0 atol = TEST_TOL + # Adding zero resistance results in zero (short circuit) + @test calc_parallel_equivalent(10.0, 0.0) ≈ 0.0 atol = TEST_TOL + + # Complex numbers (impedances) + Z1 = 3.0 + 4.0im + Z2 = 8.0 - 6.0im + Zeq_expected = (Z1 * Z2) / (Z1 + Z2) + # Parallel equivalent of complex impedances + @test calc_parallel_equivalent(Z1, Z2) ≈ Zeq_expected atol = TEST_TOL + # Parallel equivalent of two equal complex impedances + @test calc_parallel_equivalent(Z1, Z1) ≈ Z1 / 2 atol = TEST_TOL + end + + @testset "Equivalent temperature coefficient" begin + alpha1, R1 = 0.004, 10.0 + alpha2, R2 = 0.003, 5.0 + expected_alpha = (alpha1 * R2 + alpha2 * R1) / (R1 + R2) + @test calc_equivalent_alpha(alpha1, R1, alpha2, R2) ≈ expected_alpha atol = TEST_TOL + # Equivalent alpha of identical conductors + @test calc_equivalent_alpha(alpha1, R1, alpha1, R1) ≈ alpha1 atol = TEST_TOL + # Check symmetry + @test calc_equivalent_alpha(alpha1, R1, alpha2, R2) ≈ + calc_equivalent_alpha(alpha2, R2, alpha1, R1) atol = TEST_TOL + end + + @testset "Resistance calculations" begin + # Using Copper properties + rho = copper_props.rho + alpha = copper_props.alpha + T0 = copper_props.T0 + T = T0 # Test at reference temperature first + + # calc_tubular_resistance + r_in, r_ext = 0.01, 0.02 + area_tube = π * (r_ext^2 - r_in^2) + # Tubular resistance at T0 + @test calc_tubular_resistance(r_in, r_ext, rho, alpha, T0, T) ≈ rho / area_tube atol = + TEST_TOL + # Solid conductor + area_solid = π * r_ext^2 + # Solid conductor resistance (r_in=0) + @test calc_tubular_resistance(0.0, r_ext, rho, alpha, T0, T) ≈ rho / area_solid atol = + TEST_TOL + # Temperature dependence + T_hot = 70.0 + k = calc_temperature_correction(alpha, T_hot, T0) + # Tubular resistance temperature dependence + @test calc_tubular_resistance(r_in, r_ext, rho, alpha, T0, T_hot) ≈ + (rho / area_tube) * k atol = TEST_TOL + # Thin tube limit (resistance should increase) - check relative magnitude + r_in_thin = r_ext * 0.999 + # Thin tube has higher resistance + @test calc_tubular_resistance(r_in_thin, r_ext, rho, alpha, T0, T) > + calc_tubular_resistance(r_in, r_ext, rho, alpha, T0, T) + + # calc_strip_resistance + thickness, width = 0.002, 0.05 + area_strip = thickness * width + # Strip resistance at T0 + @test calc_strip_resistance(thickness, width, rho, alpha, T0, T) ≈ rho / area_strip atol = + TEST_TOL + # Strip resistance temperature dependence + @test calc_strip_resistance(thickness, width, rho, alpha, T0, T_hot) ≈ + (rho / area_strip) * k atol = TEST_TOL + end + + @testset "Helical parameters correction" begin + r_in, r_ext = 0.01, 0.015 + mean_diam_expected = r_in + r_ext # 0.025 + lay_ratio = 12.0 + pitch_expected = lay_ratio * mean_diam_expected # 12.0 * 0.025 = 0.3 + + mean_diam, pitch, overlength = calc_helical_params(r_in, r_ext, lay_ratio) + @test mean_diam ≈ mean_diam_expected atol = TEST_TOL + @test pitch ≈ pitch_expected atol = TEST_TOL + @test overlength ≈ sqrt(1 + (π * mean_diam_expected / pitch_expected)^2) atol = + TEST_TOL + # Overlength factor must be > 1 for finite lay ratio + @test overlength > 1.0 + + # Edge case: No twist (infinite pitch length) + mean_diam_no, pitch_no, overlength_no = calc_helical_params(r_in, r_ext, 0.0) + # Note: lay_ratio=0 implies pitch=0 in the code, which makes overlength=1 + @test mean_diam_no ≈ mean_diam_expected atol = TEST_TOL + @test pitch_no == 0.0 + # Overlength factor must be 1 for zero lay ratio (infinite pitch) + @test overlength_no ≈ 1.0 atol = TEST_TOL + end + + @testset "GMR calculations & consistency" begin + r_in, r_ext = 0.01, 0.02 + mu_r = 1.0 # Non-magnetic + + # calc_tubular_gmr + gmr_tube = calc_tubular_gmr(r_ext, r_in, mu_r) + # GMR of tube should be less than outer radius + @test gmr_tube < r_ext + # GMR must be positive + @test gmr_tube > 0 + + # Solid conductor GMR + gmr_solid_expected = r_ext * exp(-mu_r / 4.0) + gmr_solid_calc = calc_tubular_gmr(r_ext, 0.0, mu_r) + # Solid conductor GMR (analytical) + @test gmr_solid_calc ≈ gmr_solid_expected atol = TEST_TOL + + # Thin shell GMR + gmr_shell_calc = calc_tubular_gmr(r_ext, r_ext * (1 - 1e-12), mu_r) # Approx thin shell + # Thin shell GMR approaches outer radius # Relax tolerance slightly + @test gmr_shell_calc ≈ r_ext atol = 1e-5 + + # Magnetic material + mu_r_mag = 100.0 + gmr_solid_mag = calc_tubular_gmr(r_ext, 0.0, mu_r_mag) + gmr_solid_mag_expected = r_ext * exp(-mu_r_mag / 4.0) + # Solid conductor GMR with mu_r > 1 + @test gmr_solid_mag ≈ gmr_solid_mag_expected atol = TEST_TOL + # Higher mu_r should decrease GMR + @test gmr_solid_mag < gmr_solid_calc + + # Error handling + @test_throws ArgumentError calc_tubular_gmr(r_in, r_ext, mu_r) # Should throw error if r_ext < r_in + + # calc_equivalent_mu (inverse consistency) + @test calc_equivalent_mu(gmr_tube, r_ext, r_in) ≈ mu_r atol = TEST_TOL # Inverse check: mu_r from tubular GMR + @test calc_equivalent_mu(gmr_solid_calc, r_ext, 0.0) ≈ mu_r atol = TEST_TOL # Inverse check: mu_r from solid GMR + @test calc_equivalent_mu(gmr_solid_mag, r_ext, 0.0) ≈ mu_r_mag atol = TEST_TOL # Inverse check: magnetic mu_r from solid GMR + @test_throws ArgumentError calc_equivalent_mu(gmr_tube, r_in, r_ext) # Should throw error if r_ext < r_in + + # calc_wirearray_gmr + wire_rad = 0.001 + num_wires = 7 + layout_rad = 0.005 # Center-to-center radius + gmr_array = calc_wirearray_gmr(layout_rad, num_wires, wire_rad, mu_r) + # Single wire case should match solid wire GMR + @test gmr_array > 0 + + gmr_single_wire_array = calc_wirearray_gmr(0.0, 1, wire_rad, mu_r) # Layout radius irrelevant for N=1 + gmr_single_wire_solid = calc_tubular_gmr(wire_rad, 0.0, mu_r) + # GMR of 1-wire array matches solid wire GMR + @test gmr_single_wire_array ≈ gmr_single_wire_solid atol = TEST_TOL + + end + + @testset "GMD and equivalent GMR" begin + # Need some cable parts + part1_solid = Tubular(0.0, 0.01, copper_props) # Solid conductor r=1cm + part2_tubular = Tubular(0.015, 0.02, copper_props) # Tubular conductor, separate + part3_wirearray = WireArray(0.03, 0.002, 7, 10.0, aluminum_props) # Wire array, separate + + # calc_gmd + # Case 1: Two separate solid/tubular conductors (distance between centers) + # Place part2 at (d, 0) relative to part1 at (0,0) + d = 0.1 # 10 cm separation + # GMD calculation for simple geometries relies on center-to-center distance if not wire arrays + # This test might be trivial for Tubular/Tubular if code assumes center-to-center + # Let's test Tubular vs WireArray where sub-elements exist + gmd_1_3 = calc_gmd(part1_solid, part3_wirearray) # Should be approx layout_radius of part3 (0.03 + 0.002) if part1 is at center + # GMD between central solid and wire array approx layout radius + @test gmd_1_3 ≈ (0.03 + 0.002) atol = 1e-4 + + # Case 2: Concentric Tubular Conductors (test based on comment in code) + part_inner = Tubular(0.01, 0.02, copper_props) + part_outer = Tubular(0.02, 0.03, copper_props) # Directly outside inner part + # If truly concentric, d_ij = 0 for internal logic, should return max(r_ext1, r_ext2) + gmd_concentric = calc_gmd(part_inner, part_outer) + # GMD of concentric tubular conductors + @test gmd_concentric ≈ part_outer.radius_ext atol = TEST_TOL + + # calc_equivalent_gmr + # Create a conductor group to test adding layers + core = ConductorGroup(part1_solid) + layer2 = Tubular(core.radius_ext, 0.015, aluminum_props) # Add tubular layer outside + beta = core.cross_section / (core.cross_section + layer2.cross_section) + gmd_core_layer2 = calc_gmd(core.layers[end], layer2) # GMD between solid core and new layer + + gmr_eq_expected = + (core.gmr^(beta^2)) * (layer2.gmr^((1 - beta)^2)) * + (gmd_core_layer2^(2 * beta * (1 - beta))) + gmr_eq_calc = calc_equivalent_gmr(core, layer2) # Test the function directly + @test gmr_eq_calc ≈ gmr_eq_expected atol = TEST_TOL + + # Test adding a WireArray layer + layer3_wa = WireArray(layer2.radius_ext, 0.001, 12, 15.0, copper_props) + # Need to update core equivalent properties first before calculating next step + core.gmr = gmr_eq_calc # Update core GMR based on previous step + core.cross_section += layer2.cross_section # Update core area + push!(core.layers, layer2) # Add layer for subsequent GMD calculation + + beta2 = core.cross_section / (core.cross_section + layer3_wa.cross_section) + gmd_core_layer3 = calc_gmd(core.layers[end], layer3_wa) # GMD between tubular layer2 and wire array layer3 + + gmr_eq2_expected = + (core.gmr^(beta2^2)) * (layer3_wa.gmr^((1 - beta2)^2)) * + (gmd_core_layer3^(2 * beta2 * (1 - beta2))) + gmr_eq2_calc = calc_equivalent_gmr(core, layer3_wa) + @test gmr_eq2_calc ≈ gmr_eq2_expected atol = TEST_TOL + end + + + @testset "Inductance calculations" begin + # calc_tubular_inductance + r_in, r_ext = 0.01, 0.02 + mu_r = 1.0 + L_expected = mu_r * μ₀ / (2 * π) * log(r_ext / r_in) + @test calc_tubular_inductance(r_in, r_ext, mu_r) ≈ L_expected atol = TEST_TOL + @test calc_tubular_inductance(r_in, r_ext, 2.0 * mu_r) ≈ 2.0 * L_expected atol = + TEST_TOL # Check mu_r scaling + # Internal inductance of solid conductor is infinite in this simple model + @test calc_tubular_inductance(0.0, r_ext, mu_r) == Inf + + # calc_inductance_trifoil - Requires benchmark data or simplified checks + # This is complex. A simple check could be ensuring L > 0 for typical inputs. + r_in_co, r_ext_co = 0.01, 0.015 + r_in_scr, r_ext_scr = 0.02, 0.022 + S = 0.1 + L_trifoil = + calc_inductance_trifoil(r_in_co, r_ext_co, copper_props.rho, copper_props.mu_r, + r_in_scr, r_ext_scr, copper_props.rho, copper_props.mu_r, S) + # Trifoil inductance should be positive + @test L_trifoil > 0 + # Could test sensitivity: increasing S should generally decrease L + L_trifoil_S2 = + calc_inductance_trifoil(r_in_co, r_ext_co, copper_props.rho, copper_props.mu_r, + r_in_scr, r_ext_scr, copper_props.rho, copper_props.mu_r, 2 * S) + # Increasing separation S should decrease L + @test L_trifoil_S2 < L_trifoil + + end + + @testset "Capacitance & conductance" begin + r_in, r_ext = 0.01, 0.02 + eps_r = insulator_props.eps_r + rho_ins = insulator_props.rho + + # calc_shunt_capacitance + C_expected = 2 * π * ε₀ * eps_r / log(r_ext / r_in) + @test calc_shunt_capacitance(r_in, r_ext, eps_r) ≈ C_expected atol = TEST_TOL + # Increasing r_ext decreases C + @test calc_shunt_capacitance(r_in, r_ext * 10, eps_r) < C_expected + # Decreasing r_in decreases C + @test calc_shunt_capacitance(r_in / 10, r_ext, eps_r) < C_expected + # If r_in -> r_ext, log -> 0, C -> Inf. Test approach? + # Capacitance -> Inf as r_in approaches r_ext + @test isinf(calc_shunt_capacitance(r_ext, r_ext, eps_r)) + + # calc_shunt_conductance + G_expected = 2 * π * (1 / rho_ins) / log(r_ext / r_in) + @test calc_shunt_conductance(r_in, r_ext, rho_ins) ≈ G_expected atol = TEST_TOL + # Lower rho increases G + @test calc_shunt_conductance(r_in, r_ext, rho_ins / 10) ≈ 10 * G_expected atol = + TEST_TOL + # Infinite rho (perfect insulator) gives zero G + @test calc_shunt_conductance(r_in, r_ext, Inf) ≈ 0.0 atol = TEST_TOL + # Conductance -> Inf as r_in approaches r_ext + @test isinf(calc_shunt_conductance(r_ext, r_ext, rho_ins)) + end + + @testset "Equivalent dielectric properties consistency" begin + r_in, r_ext = 0.01, 0.02 + eps_r = insulator_props.eps_r + rho_ins = insulator_props.rho + C_eq = calc_shunt_capacitance(r_in, r_ext, eps_r) + G_eq = calc_shunt_conductance(r_in, r_ext, rho_ins) + + # calc_equivalent_eps + # Inverse check: eps_r from C_eq + @test calc_equivalent_eps(C_eq, r_ext, r_in) ≈ eps_r atol = TEST_TOL + + # calc_sigma_lossfact & inverse check + sigma_eq = calc_sigma_lossfact(G_eq, r_in, r_ext) + # Check sigma_eq calculation + @test sigma_eq ≈ 1 / rho_ins atol = TEST_TOL + # Conductance from sigma + G_from_sigma = 2 * π * sigma_eq / log(r_ext / r_in) + # Inverse check: G_eq from sigma_eq + @test G_from_sigma ≈ G_eq atol = TEST_TOL + + # calc_equivalent_lossfact + f = 50.0 + ω = 2 * π * f + tand_expected = G_eq / (ω * C_eq) + @test calc_equivalent_lossfact(G_eq, C_eq, ω) ≈ tand_expected atol = TEST_TOL + end + + @testset "Equivalent resistivity consistency" begin + r_in, r_ext = 0.01, 0.02 + rho = copper_props.rho + alpha = copper_props.alpha + T0 = copper_props.T0 + + R_tube = calc_tubular_resistance(r_in, r_ext, rho, alpha, T0, T0) + rho_eq = calc_equivalent_rho(R_tube, r_ext, r_in) + # Inverse check: rho from R_tube + @test rho_eq ≈ rho atol = TEST_TOL + + R_solid = calc_tubular_resistance(0.0, r_ext, rho, alpha, T0, T0) + rho_eq_solid = calc_equivalent_rho(R_solid, r_ext, 0.0) + # Inverse check: rho from R_solid + @test rho_eq_solid ≈ rho atol = TEST_TOL + end + + @testset "Solenoid correction consistency" begin + num_turns = 10.0 # turns/m + r_con_ext = 0.01 + r_ins_ext = 0.015 + + mu_r_corr = calc_solenoid_correction(num_turns, r_con_ext, r_ins_ext) + # Correction factor should be > 1 for non-zero turns + @test mu_r_corr > 1.0 + + # No twist (num_turns = NaN as per code comment) + # Correction factor is 1 if num_turns is NaN + @test calc_solenoid_correction(NaN, r_con_ext, r_ins_ext) ≈ 1.0 atol = TEST_TOL + # Zero turns + # Correction factor is 1 if num_turns is + @test calc_solenoid_correction(0.0, r_con_ext, r_ins_ext) ≈ 1.0 atol = TEST_TOL + + # Edge case: r_con_ext == r_ins_ext (zero thickness insulator) + # This leads to log(1) = 0 in denominator. Should return 1 or NaN/Inf? + # Let's test the behavior. Assuming it might result in NaN due to 0/0 or X/0. + # Correction factor is likely NaN if radii are equal (0/0 form) + # Or maybe it should default to 1? Depends on desired behavior. + # If the function should handle this, add a check inside it. + @test isnan(calc_solenoid_correction(num_turns, r_con_ext, r_con_ext)) + + end + + @testset "Basic uncertainty propagation" begin + using Measurements + r_in_m = (0.01 ± 0.001) + r_ext_m = (0.02 ± 0.001) + rho_m = (1.7241e-8 ± 0.001e-8) + R_m = calc_tubular_resistance(r_in_m, r_ext_m, rho_m, (0.0 ± 0.0), (20.0 ± 0.0), (20.0 ± 0.0)) + @test Measurements.value(R_m) ≈ calc_tubular_resistance( + Measurements.value(r_in_m), + Measurements.value(r_ext_m), + Measurements.value(rho_m), + (0.0), + (20.0), + (20.0), + ) atol = + TEST_TOL + @test Measurements.uncertainty(R_m) > 0 + end +end diff --git a/test/cable_test.json b/test/cable_test.json index 0a75ce9a..bec3adae 100644 --- a/test/cable_test.json +++ b/test/cable_test.json @@ -1,310 +1,326 @@ -{ - "__julia_type__": "LineCableModels.DataModel.CablesLibrary", - "data": { - "test_cable": { - "cable_id": "test_cable", - "nominal_data": { - "U": 30, - "screen_cross_section": 35, - "capacitance": 0.39, - "__julia_type__": "LineCableModels.DataModel.NominalData", - "U0": 18, - "conductor_cross_section": 1000, - "armor_cross_section": null, - "resistance": 0.0291, - "inductance": 0.3, - "designation_code": "NA2XS(FL)2Y" - }, - "__julia_type__": "LineCableModels.DataModel.CableDesign", - "components": [ - { - "insulator_group": { - "__julia_type__": "LineCableModels.DataModel.InsulatorGroup", - "layers": [ - { - "material_props": { - "T0": 20, - "alpha": 0, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 32.3, - "mu_r": 1, - "rho": 5300 - }, - "radius_in": 0.02115, - "__julia_type__": "LineCableModels.DataModel.Semicon", - "radius_ext": 0.02145, - "temperature": 20 - }, - { - "material_props": { - "T0": 20, - "alpha": 0, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 1000, - "mu_r": 1, - "rho": 1000 - }, - "radius_in": 0.02145, - "__julia_type__": "LineCableModels.DataModel.Semicon", - "radius_ext": 0.02205, - "temperature": 20 - }, - { - "material_props": { - "T0": 20, - "alpha": 0, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 2.3, - "mu_r": 1, - "rho": 197000000000000 - }, - "radius_in": 0.02205, - "__julia_type__": "LineCableModels.DataModel.Insulator", - "radius_ext": 0.03005, - "temperature": 20 - }, - { - "material_props": { - "T0": 20, - "alpha": 0, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 1000, - "mu_r": 1, - "rho": 500 - }, - "radius_in": 0.03005, - "__julia_type__": "LineCableModels.DataModel.Semicon", - "radius_ext": 0.030350000000000002, - "temperature": 20 - }, - { - "material_props": { - "T0": 20, - "alpha": 0, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 32.3, - "mu_r": 1, - "rho": 5300 - }, - "radius_in": 0.030350000000000002, - "__julia_type__": "LineCableModels.DataModel.Semicon", - "radius_ext": 0.030650000000000004, - "temperature": 20 - } - ] - }, - "__julia_type__": "LineCableModels.DataModel.CableComponent", - "id": "core", - "conductor_group": { - "__julia_type__": "LineCableModels.DataModel.ConductorGroup", - "layers": [ - { - "lay_ratio": 0, - "material_props": { - "T0": 20, - "alpha": 0.00429, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 1, - "mu_r": 1.000022, - "rho": 2.8264e-8 - }, - "lay_direction": 1, - "radius_in": 0, - "__julia_type__": "LineCableModels.DataModel.WireArray", - "radius_wire": 0.00235, - "num_wires": 1, - "temperature": 20 - }, - { - "lay_ratio": 15, - "material_props": { - "T0": 20, - "alpha": 0.00429, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 1, - "mu_r": 1.000022, - "rho": 2.8264e-8 - }, - "lay_direction": 1, - "radius_in": 0.00235, - "__julia_type__": "LineCableModels.DataModel.WireArray", - "radius_wire": 0.00235, - "num_wires": 6, - "temperature": 20 - }, - { - "lay_ratio": 13.5, - "material_props": { - "T0": 20, - "alpha": 0.00429, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 1, - "mu_r": 1.000022, - "rho": 2.8264e-8 - }, - "lay_direction": 1, - "radius_in": 0.007050000000000001, - "__julia_type__": "LineCableModels.DataModel.WireArray", - "radius_wire": 0.00235, - "num_wires": 12, - "temperature": 20 - }, - { - "lay_ratio": 12.5, - "material_props": { - "T0": 20, - "alpha": 0.00429, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 1, - "mu_r": 1.000022, - "rho": 2.8264e-8 - }, - "lay_direction": 1, - "radius_in": 0.01175, - "__julia_type__": "LineCableModels.DataModel.WireArray", - "radius_wire": 0.00235, - "num_wires": 18, - "temperature": 20 - }, - { - "lay_ratio": 11, - "material_props": { - "T0": 20, - "alpha": 0.00429, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 1, - "mu_r": 1.000022, - "rho": 2.8264e-8 - }, - "lay_direction": 1, - "radius_in": 0.01645, - "__julia_type__": "LineCableModels.DataModel.WireArray", - "radius_wire": 0.00235, - "num_wires": 24, - "temperature": 20 - } - ] - } - }, - { - "insulator_group": { - "__julia_type__": "LineCableModels.DataModel.InsulatorGroup", - "layers": [ - { - "material_props": { - "T0": 20, - "alpha": 0, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 32.3, - "mu_r": 1, - "rho": 5300 - }, - "radius_in": 0.031700000000000006, - "__julia_type__": "LineCableModels.DataModel.Semicon", - "radius_ext": 0.03200000000000001, - "temperature": 20 - } - ] - }, - "__julia_type__": "LineCableModels.DataModel.CableComponent", - "id": "sheath", - "conductor_group": { - "__julia_type__": "LineCableModels.DataModel.ConductorGroup", - "layers": [ - { - "lay_ratio": 10, - "material_props": { - "T0": 20, - "alpha": 0.00393, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 1, - "mu_r": 0.999994, - "rho": 1.7241e-8 - }, - "lay_direction": 1, - "radius_in": 0.030650000000000004, - "__julia_type__": "LineCableModels.DataModel.WireArray", - "radius_wire": 0.000475, - "num_wires": 49, - "temperature": 20 - }, - { - "lay_ratio": 10, - "material_props": { - "T0": 20, - "alpha": 0.00393, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 1, - "mu_r": 0.999994, - "rho": 1.7241e-8 - }, - "lay_direction": 1, - "radius_in": 0.0316, - "__julia_type__": "LineCableModels.DataModel.Strip", - "width": 0.01, - "radius_ext": 0.031700000000000006, - "temperature": 20 - } - ] - } - }, - { - "insulator_group": { - "__julia_type__": "LineCableModels.DataModel.InsulatorGroup", - "layers": [ - { - "material_props": { - "T0": 20, - "alpha": 0, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 2.3, - "mu_r": 1, - "rho": 197000000000000 - }, - "radius_in": 0.032150000000000005, - "__julia_type__": "LineCableModels.DataModel.Insulator", - "radius_ext": 0.032200000000000006, - "temperature": 20 - }, - { - "material_props": { - "T0": 20, - "alpha": 0, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 2.3, - "mu_r": 1, - "rho": 197000000000000 - }, - "radius_in": 0.032200000000000006, - "__julia_type__": "LineCableModels.DataModel.Insulator", - "radius_ext": 0.034600000000000006, - "temperature": 20 - } - ] - }, - "__julia_type__": "LineCableModels.DataModel.CableComponent", - "id": "jacket", - "conductor_group": { - "__julia_type__": "LineCableModels.DataModel.ConductorGroup", - "layers": [ - { - "material_props": { - "T0": 20, - "alpha": 0.00429, - "__julia_type__": "LineCableModels.Materials.Material", - "eps_r": 1, - "mu_r": 1.000022, - "rho": 2.8264e-8 - }, - "radius_in": 0.03200000000000001, - "__julia_type__": "LineCableModels.DataModel.Tubular", - "radius_ext": 0.032150000000000005, - "temperature": 20 - } - ] - } - } - ] - } - } +{ + "__julia_type__": "LineCableModels.DataModel.CablesLibrary", + "data": { + "test_cable": { + "cable_id": "test_cable", + "nominal_data": { + "U": 30, + "screen_cross_section": 35, + "capacitance": 0.39, + "__julia_type__": "LineCableModels.DataModel.NominalData", + "U0": 18, + "conductor_cross_section": 1000, + "armor_cross_section": null, + "resistance": 0.0291, + "inductance": 0.3, + "designation_code": "NA2XS(FL)2Y" + }, + "__julia_type__": "LineCableModels.DataModel.CableDesign", + "components": [ + { + "insulator_group": { + "__julia_type__": "LineCableModels.DataModel.InsulatorGroup", + "layers": [ + { + "material_props": { + "T0": 20, + "alpha": 0, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 32.3, + "mu_r": 1, + "rho": 5300, + "kappa": 148 + }, + "radius_in": 0.02115, + "__julia_type__": "LineCableModels.DataModel.Semicon", + "radius_ext": 0.02145, + "temperature": 20 + }, + { + "material_props": { + "T0": 20, + "alpha": 0, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 1000, + "mu_r": 1, + "rho": 1000, + "kappa": 148.0 + }, + "radius_in": 0.02145, + "__julia_type__": "LineCableModels.DataModel.Semicon", + "radius_ext": 0.02205, + "temperature": 20 + }, + { + "material_props": { + "T0": 20, + "alpha": 0, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 2.3, + "mu_r": 1, + "rho": 197000000000000, + "kappa": 0.3 + }, + "radius_in": 0.02205, + "__julia_type__": "LineCableModels.DataModel.Insulator", + "radius_ext": 0.03005, + "temperature": 20 + }, + { + "material_props": { + "T0": 20, + "alpha": 0, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 1000, + "mu_r": 1, + "rho": 500, + "kappa": 148.0 + }, + "radius_in": 0.03005, + "__julia_type__": "LineCableModels.DataModel.Semicon", + "radius_ext": 0.030350000000000002, + "temperature": 20 + }, + { + "material_props": { + "T0": 20, + "alpha": 0, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 32.3, + "mu_r": 1, + "rho": 5300, + "kappa": 148 + }, + "radius_in": 0.030350000000000002, + "__julia_type__": "LineCableModels.DataModel.Semicon", + "radius_ext": 0.030650000000000004, + "temperature": 20 + } + ] + }, + "__julia_type__": "LineCableModels.DataModel.CableComponent", + "id": "core", + "conductor_group": { + "__julia_type__": "LineCableModels.DataModel.ConductorGroup", + "layers": [ + { + "lay_ratio": 0, + "material_props": { + "T0": 20, + "alpha": 0.00429, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 1, + "mu_r": 1.000022, + "rho": 2.8264e-8, + "kappa": 237.0 + }, + "lay_direction": 1, + "radius_in": 0, + "__julia_type__": "LineCableModels.DataModel.WireArray", + "radius_wire": 0.00235, + "num_wires": 1, + "temperature": 20 + }, + { + "lay_ratio": 15, + "material_props": { + "T0": 20, + "alpha": 0.00429, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 1, + "mu_r": 1.000022, + "rho": 2.8264e-8, + "kappa": 237.0 + }, + "lay_direction": 1, + "radius_in": 0.00235, + "__julia_type__": "LineCableModels.DataModel.WireArray", + "radius_wire": 0.00235, + "num_wires": 6, + "temperature": 20 + }, + { + "lay_ratio": 13.5, + "material_props": { + "T0": 20, + "alpha": 0.00429, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 1, + "mu_r": 1.000022, + "rho": 2.8264e-8, + "kappa": 237.0 + }, + "lay_direction": 1, + "radius_in": 0.007050000000000001, + "__julia_type__": "LineCableModels.DataModel.WireArray", + "radius_wire": 0.00235, + "num_wires": 12, + "temperature": 20 + }, + { + "lay_ratio": 12.5, + "material_props": { + "T0": 20, + "alpha": 0.00429, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 1, + "mu_r": 1.000022, + "rho": 2.8264e-8, + "kappa": 237.0 + }, + "lay_direction": 1, + "radius_in": 0.01175, + "__julia_type__": "LineCableModels.DataModel.WireArray", + "radius_wire": 0.00235, + "num_wires": 18, + "temperature": 20 + }, + { + "lay_ratio": 11, + "material_props": { + "T0": 20, + "alpha": 0.00429, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 1, + "mu_r": 1.000022, + "rho": 2.8264e-8, + "kappa": 237.0 + }, + "lay_direction": 1, + "radius_in": 0.01645, + "__julia_type__": "LineCableModels.DataModel.WireArray", + "radius_wire": 0.00235, + "num_wires": 24, + "temperature": 20 + } + ] + } + }, + { + "insulator_group": { + "__julia_type__": "LineCableModels.DataModel.InsulatorGroup", + "layers": [ + { + "material_props": { + "T0": 20, + "alpha": 0, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 32.3, + "mu_r": 1, + "rho": 5300, + "kappa": 148 + }, + "radius_in": 0.031700000000000006, + "__julia_type__": "LineCableModels.DataModel.Semicon", + "radius_ext": 0.03200000000000001, + "temperature": 20 + } + ] + }, + "__julia_type__": "LineCableModels.DataModel.CableComponent", + "id": "sheath", + "conductor_group": { + "__julia_type__": "LineCableModels.DataModel.ConductorGroup", + "layers": [ + { + "lay_ratio": 10, + "material_props": { + "T0": 20, + "alpha": 0.00393, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 1, + "mu_r": 0.999994, + "rho": 1.7241e-8, + "kappa": 401.0 + }, + "lay_direction": 1, + "radius_in": 0.030650000000000004, + "__julia_type__": "LineCableModels.DataModel.WireArray", + "radius_wire": 0.000475, + "num_wires": 49, + "temperature": 20 + }, + { + "lay_ratio": 10, + "material_props": { + "T0": 20, + "alpha": 0.00393, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 1, + "mu_r": 0.999994, + "rho": 1.7241e-8, + "kappa": 401.0 + }, + "lay_direction": 1, + "radius_in": 0.0316, + "__julia_type__": "LineCableModels.DataModel.Strip", + "width": 0.01, + "radius_ext": 0.031700000000000006, + "temperature": 20 + } + ] + } + }, + { + "insulator_group": { + "__julia_type__": "LineCableModels.DataModel.InsulatorGroup", + "layers": [ + { + "material_props": { + "T0": 20, + "alpha": 0, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 2.3, + "mu_r": 1, + "rho": 197000000000000, + "kappa": 0.3 + }, + "radius_in": 0.032150000000000005, + "__julia_type__": "LineCableModels.DataModel.Insulator", + "radius_ext": 0.032200000000000006, + "temperature": 20 + }, + { + "material_props": { + "T0": 20, + "alpha": 0, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 2.3, + "mu_r": 1, + "rho": 197000000000000, + "kappa": 0.3 + }, + "radius_in": 0.032200000000000006, + "__julia_type__": "LineCableModels.DataModel.Insulator", + "radius_ext": 0.034600000000000006, + "temperature": 20 + } + ] + }, + "__julia_type__": "LineCableModels.DataModel.CableComponent", + "id": "jacket", + "conductor_group": { + "__julia_type__": "LineCableModels.DataModel.ConductorGroup", + "layers": [ + { + "material_props": { + "T0": 20, + "alpha": 0.00429, + "__julia_type__": "LineCableModels.Materials.Material", + "eps_r": 1, + "mu_r": 1.000022, + "rho": 2.8264e-8, + "kappa": 237.0 + }, + "radius_in": 0.03200000000000001, + "__julia_type__": "LineCableModels.DataModel.Tubular", + "radius_ext": 0.032150000000000005, + "temperature": 20 + } + ] + } + } + ] + } + } } \ No newline at end of file diff --git a/test/datamodel.jl b/test/datamodel.jl index 206ee3be..957f7d03 100644 --- a/test/datamodel.jl +++ b/test/datamodel.jl @@ -1,777 +1,777 @@ -@testsnippet deps_datamodel begin - using Plots - using EzXML - using Makie: Makie, Figure, Axis -end - -@testitem "DataModel module" setup = [defaults, deps_datamodel] begin - - println("\nSetting up materials and dimensions for DataModel test...") - materials = MaterialsLibrary(add_defaults = true) - @test haskey(materials, "aluminum") - @test haskey(materials, "copper") - @test haskey(materials, "polyacrylate") - @test haskey(materials, "semicon1") - @test haskey(materials, "semicon2") - @test haskey(materials, "pe") - - initial_default_count = length(materials) - @test initial_default_count > 5 # Should have several defaults - - materials_empty = MaterialsLibrary(add_defaults = false) - @test isempty(materials_empty) - - # Add a custom material for removal tests - mat_remove_test = Material(1e-5, 5.0, 1.0, 20.0, 0.05) - add!(materials, "remove_me", mat_remove_test) - @test length(materials) == initial_default_count + 1 - @test haskey(materials, "remove_me") - - println(" Testing delete!...") - delete!(materials, "remove_me") - @test !haskey(materials, "remove_me") - @test length(materials) == initial_default_count - - # Test removing non-existent (should throw KeyError based on source) - @test_throws KeyError delete!( - materials, - "does_not_exist", - ) - # Verify count didn't change - @test length(materials) == initial_default_count - - println(" Testing DataFrame...") - # Use the empty DB + one material for simpler checking - mat_list_test = Material(9e9, 9.0, 9.0, 99.0, 0.9) - add!(materials_empty, "list_test_mat", mat_list_test) - df_listed = DataFrame(materials_empty) - - @test df_listed isa DataFrame - @test names(df_listed) == ["name", "rho", "eps_r", "mu_r", "T0", "alpha"] # Check column names - @test nrow(df_listed) == 1 - @test df_listed[1, :name] == "list_test_mat" - @test df_listed[1, :rho] == 9e9 - @test df_listed[1, :eps_r] == 9.0 - @test df_listed[1, :mu_r] == 9.0 - @test df_listed[1, :T0] == 99.0 - @test df_listed[1, :alpha] == 0.9 - - println(" Testing save/load cycle for MaterialsLibrary...") - mktempdir(joinpath(@__DIR__)) do tmpdir - output_file = joinpath(tmpdir, "materials_library_test.json") - println(" Saving to: ", output_file) - - # Save the db that had defaults + 'remove_me' (before removal) - # Let's re-add it for a more comprehensive save file - db_to_save = MaterialsLibrary(add_defaults = true) - mat_temp = Material(1e-5, 5.0, 1.0, 20.0, 0.05) - add!(db_to_save, "temp_mat", mat_temp) - num_expected = length(db_to_save) - - save(db_to_save, file_name = output_file) - @test isfile(output_file) - @test filesize(output_file) > 0 - - # Load into a NEW, EMPTY library - materials_from_json = MaterialsLibrary(add_defaults = false) - load!(materials_from_json, file_name = output_file) - - # Verify loaded content - @test length(materials_from_json) == num_expected - @test haskey(materials_from_json, "temp_mat") - @test haskey(materials_from_json, "copper") # Check a default also loaded - loaded_temp_mat = get(materials_from_json, "temp_mat") - @test loaded_temp_mat.rho == mat_temp.rho - @test loaded_temp_mat.eps_r == mat_temp.eps_r - - println(" Save/load cycle completed.") - println("Materials Library tests completed.") - end # Temp dir cleanup - - # Cable dimensions from tutorial - num_co_wires = 61 - num_sc_wires = 49 - d_core = 38.1e-3 - d_w = 4.7e-3 - t_sc_in = 0.6e-3 - t_ins = 8e-3 - t_sc_out = 0.3e-3 - d_ws = 0.95e-3 - t_cut = 0.1e-3 - w_cut = 10e-3 - t_wbt = 0.3e-3 - t_sct = 0.3e-3 # Semiconductive tape thickness - t_alt = 0.15e-3 - t_pet = 0.05e-3 - t_jac = 2.4e-3 - - # Nominal data for final comparison - datasheet_info = NominalData( - designation_code = "NA2XS(FL)2Y", - U0 = 18.0, # Phase-to-ground voltage [kV] - U = 30.0, # Phase-to-phase voltage [kV] - conductor_cross_section = 1000.0, # [mm²] - screen_cross_section = 35.0, # [mm²] - resistance = 0.0291, # DC resistance [Ω/km] - capacitance = 0.39, # Capacitance [μF/km] - inductance = 0.3, # Inductance in trifoil [mH/km] - ) - @test datasheet_info.resistance > 0 - @test datasheet_info.capacitance > 0 - @test datasheet_info.inductance > 0 - - function calculate_rlc( - design::CableDesign; - rho_e::Float64 = 100.0, - default_S_factor::Float64 = 2.0, - ) - # Get components - core_comp = design.components[findfirst(c -> c.id == "core", design.components)] - sheath_comp = - design.components[findfirst(c -> c.id == "sheath", design.components)] - last_comp = design.components[end] # Usually jacket - - if isnothing(core_comp) || isnothing(sheath_comp) - error( - "Required 'core' or 'sheath' component not found in design for RLC calculation.", - ) - end - - # Resistance (from effective core conductor group resistance) - R = core_comp.conductor_group.resistance * 1e3 # Ω/m to Ω/km - - # Inductance (Trifoil) - # Use outermost radius for separation calculation - corrected access path - outermost_radius = last_comp.insulator_group.radius_ext - S = default_S_factor * outermost_radius # Approx center-to-center distance [m] - - L = - calc_inductance_trifoil( - core_comp.conductor_group.radius_in, - core_comp.conductor_group.radius_ext, - core_comp.conductor_props.rho, core_comp.conductor_props.mu_r, - sheath_comp.conductor_group.radius_in, - sheath_comp.conductor_group.radius_ext, sheath_comp.conductor_props.rho, - sheath_comp.conductor_props.mu_r, - S, rho_e = rho_e, - ) * 1e6 # H/m to mH/km - - # Capacitance - C = - calc_shunt_capacitance( - core_comp.conductor_group.radius_ext, - core_comp.insulator_group.radius_ext, - core_comp.insulator_props.eps_r, - ) * 1e6 * 1e3 # F/m to μF/km - - return R, L, C - end - - println("Constructing core conductor group...") - material_alu = get(materials, "aluminum") - core = ConductorGroup(WireArray(0.0, Diameter(d_w), 1, 0.0, material_alu)) - @test core isa ConductorGroup - @test length(core.layers) == 1 - @test core.radius_in == 0 - @test core.radius_ext ≈ d_w / 2.0 - @test core.resistance > 0 - @test core.gmr > 0 - - add!(core, WireArray, Diameter(d_w), 6, 15.0, material_alu) - @test length(core.layers) == 2 - @test core.radius_ext ≈ (d_w / 2.0) * 3 # Approximation for 1+6 wires - @test core.resistance > 0 # Resistance should decrease - - add!(core, WireArray, Diameter(d_w), 12, 13.5, material_alu) - @test length(core.layers) == 3 - @test core.radius_ext ≈ (d_w / 2.0) * 5 # Approximation for 1+6+12 wires - - add!(core, WireArray, Diameter(d_w), 18, 12.5, material_alu) - @test length(core.layers) == 4 - @test core.radius_ext ≈ (d_w / 2.0) * 7 # Approximation - - add!(core, WireArray, Diameter(d_w), 24, 11.0, material_alu) - @test length(core.layers) == 5 - @test core.radius_ext ≈ (d_w / 2.0) * 9 # Approximation - # Check final calculated radius against nominal diameter - # Note: constructor uses internal calculations, may differ slightly from d_core/2 - @test core.radius_ext ≈ d_core / 2.0 rtol = 0.1 # Allow 10% tolerance for geometric approximation vs nominal - final_core_radius = core.radius_ext # Store for later use - final_core_resistance = core.resistance # Store for later use - - println("Constructing main insulation group...") - # Inner semiconductive tape - material_sc_tape = get(materials, "polyacrylate") - main_insu = InsulatorGroup(Semicon(core, Thickness(t_sct), material_sc_tape)) - @test main_insu isa InsulatorGroup - @test length(main_insu.layers) == 1 - @test main_insu.radius_in ≈ final_core_radius - @test main_insu.radius_ext ≈ final_core_radius + t_sct - - # Inner semiconductor - material_sc1 = get(materials, "semicon1") - add!(main_insu, Semicon, Thickness(t_sc_in), material_sc1) - @test length(main_insu.layers) == 2 - @test main_insu.radius_ext ≈ final_core_radius + t_sct + t_sc_in - - # Main insulation (XLPE) - material_pe = get(materials, "pe") - add!(main_insu, Insulator, Thickness(t_ins), material_pe) - @test length(main_insu.layers) == 3 - @test main_insu.radius_ext ≈ final_core_radius + t_sct + t_sc_in + t_ins - - # Outer semiconductor - material_sc2 = get(materials, "semicon2") - add!(main_insu, Semicon, Thickness(t_sc_out), material_sc2) - @test length(main_insu.layers) == 4 - @test main_insu.radius_ext ≈ final_core_radius + t_sct + t_sc_in + t_ins + t_sc_out - - # Outer semiconductive tape - add!(main_insu, Semicon, Thickness(t_sct), material_sc_tape) - @test length(main_insu.layers) == 5 - @test main_insu.radius_ext ≈ - final_core_radius + t_sct + t_sc_in + t_ins + t_sc_out + t_sct - @test main_insu.shunt_capacitance > 0 - @test main_insu.shunt_conductance >= 0 - final_insu_radius = main_insu.radius_ext # Store for later use - - println("Creating core cable component...") - core_cc = CableComponent("core", core, main_insu) - @test core_cc isa CableComponent - @test core_cc.id == "core" - @test core_cc.conductor_group === core - @test core_cc.insulator_group === main_insu - @test core_cc.conductor_props isa Material # Check effective props were created - @test core_cc.insulator_props isa Material - - println("Initializing CableDesign...") - cable_id = "tutorial2_test" - cable_design = CableDesign(cable_id, core_cc, nominal_data = datasheet_info) - @test cable_design isa CableDesign - @test length(cable_design.components) == 1 - @test cable_design.components[1] === core_cc - @test cable_design.nominal_data === datasheet_info - - println("Constructing sheath group...") - # Wire screens - lay_ratio_screen = 10.0 - material_cu = get(materials, "copper") - screen_con = ConductorGroup( - WireArray( - main_insu, - Diameter(d_ws), - num_sc_wires, - lay_ratio_screen, - material_cu, - ), - ) - @test screen_con isa ConductorGroup - @test screen_con.radius_in ≈ final_insu_radius - @test screen_con.radius_ext ≈ final_insu_radius + d_ws # Approx radius of single layer of wires - - # Copper tape - add!( - screen_con, - Strip, - Thickness(t_cut), - w_cut, - lay_ratio_screen, - material_cu, - ) - @test screen_con.radius_ext ≈ final_insu_radius + d_ws + t_cut - final_screen_con_radius = screen_con.radius_ext - - # Water blocking tape - material_wbt = get(materials, "polyacrylate") # Assuming same as sc tape - screen_insu = InsulatorGroup(Semicon(screen_con, Thickness(t_wbt), material_wbt)) - @test screen_insu.radius_ext ≈ final_screen_con_radius + t_wbt - final_screen_insu_radius = screen_insu.radius_ext - - # Sheath Cable Component & Add to Design - sheath_cc = CableComponent("sheath", screen_con, screen_insu) - @test sheath_cc isa CableComponent - add!(cable_design, sheath_cc) - @test length(cable_design.components) == 2 - @test cable_design.components[2] === sheath_cc - - println("Constructing jacket group...") - # Aluminum foil - material_alu = get(materials, "aluminum") # Re-get just in case - jacket_con = ConductorGroup(Tubular(screen_insu, Thickness(t_alt), material_alu)) - @test jacket_con.radius_ext ≈ final_screen_insu_radius + t_alt - final_jacket_con_radius = jacket_con.radius_ext - - # PE layer after foil - material_pe = get(materials, "pe") # Re-get just in case - jacket_insu = InsulatorGroup(Insulator(jacket_con, Thickness(t_pet), material_pe)) - @test jacket_insu.radius_ext ≈ final_jacket_con_radius + t_pet - - # PE jacket - add!(jacket_insu, Insulator, Thickness(t_jac), material_pe) - @test jacket_insu.radius_ext ≈ final_jacket_con_radius + t_pet + t_jac - final_jacket_insu_radius = jacket_insu.radius_ext - - # Add Jacket Component to Design (using alternative signature) - add!(cable_design, "jacket", jacket_con, jacket_insu) - @test length(cable_design.components) == 3 - @test cable_design.components[3].id == "jacket" - # Check overall radius - @test cable_design.components[3].insulator_group.radius_ext ≈ - final_jacket_insu_radius - - println("Checking DataFrame...") - @test DataFrame(cable_design, :baseparams) isa DataFrame - @test DataFrame(cable_design, :components) isa DataFrame - @test DataFrame(cable_design, :detailed) isa DataFrame - - println("Validating calculated RLC against nominal values (rtol=6%)...") - - # Get components for calculation (assuming they are named consistently) - cable_core = - cable_design.components[findfirst(c -> c.id == "core", cable_design.components)] - cable_sheath = - cable_design.components[findfirst( - c -> c.id == "sheath", - cable_design.components, - )] # Note: Tutorial used 'cable_shield' variable name - cable_jacket = - cable_design.components[findfirst( - c -> c.id == "jacket", - cable_design.components, - )] - - @test cable_core !== nothing - @test cable_sheath !== nothing - @test cable_jacket !== nothing - - (R_orig, L_orig, C_orig) = calculate_rlc(cable_design) - println(" Original design RLC = ($R_orig, $L_orig, $C_orig)") - @test R_orig ≈ datasheet_info.resistance rtol = 0.06 - @test L_orig ≈ datasheet_info.inductance rtol = 0.06 - @test C_orig ≈ datasheet_info.capacitance rtol = 0.06 - - println("\nTesting CableDesign reconstruction...") - new_components = [] - for original_component in cable_design.components - println(" Reconstructing component: $(original_component.id)") - - # Extract effective properties and dimensions - eff_cond_props = original_component.conductor_props - eff_ins_props = original_component.insulator_props - r_in_cond = original_component.conductor_group.radius_in - r_ext_cond = original_component.conductor_group.radius_ext - r_in_ins = original_component.insulator_group.radius_in - r_ext_ins = original_component.insulator_group.radius_ext - - # Sanity check dimensions - @test r_ext_cond ≈ r_in_ins atol = 1e-9 # Inner radius of insulator must match outer of conductor - - # Create simplified Tubular conductor using effective properties - # Note: We must provide a material object, which are the effective props here - equiv_conductor = Tubular(r_in_cond, r_ext_cond, eff_cond_props) - # Wrap it in a ConductorGroup (which recalculates R, L based on the Tubular part) - equiv_cond_group = ConductorGroup(equiv_conductor) - - # Create simplified Insulator using effective properties - equiv_insulator = Insulator(r_in_ins, r_ext_ins, eff_ins_props) - # Wrap it in an InsulatorGroup (which recalculates C, G based on the Insulator part) - equiv_ins_group = InsulatorGroup(equiv_insulator) - - # Create the new, equivalent CableComponent - equiv_component = CableComponent( - original_component.id, - equiv_cond_group, - equiv_ins_group, - ) - - # Check if the recalculated R/L/C/G of the simple groups match the effective props closely. Note: This tests the self-consistency of the effective property calculations and the Tubular/Insulator constructors. Tolerance might need adjustment. - @test equiv_cond_group.resistance ≈ - calc_tubular_resistance( - r_in_cond, - r_ext_cond, - eff_cond_props.rho, - 0.0, - 20.0, - 20.0, - ) rtol = - 1e-6 - @test equiv_ins_group.shunt_capacitance ≈ - calc_shunt_capacitance(r_in_ins, r_ext_ins, eff_ins_props.eps_r) rtol = - 1e-6 - # GMR/Inductance and Conductance checks could also be added here - - push!(new_components, equiv_component) - end - - # Assemble the new CableDesign from the equivalent components - @test length(new_components) == length(cable_design.components) - equiv_cable_design = CableDesign( - cable_design.cable_id * "_equiv", - new_components[1], # Initialize with the first equivalent component - nominal_data = datasheet_info, # Keep same nominal data for reference - ) - - # Add remaining equivalent components - if length(new_components) > 1 - for i in eachindex(new_components)[2:end] - add!(equiv_cable_design, new_components[i]) - end - end - @test length(equiv_cable_design.components) == length(new_components) - println(" Equivalent cable design assembled.") - - println(" Calculating RLC for equivalent design...") - - (R_equiv, L_equiv, C_equiv) = calculate_rlc(equiv_cable_design) - - println(" Original R, L, C = ", R_orig, ", ", L_orig, ", ", C_orig) - println(" Equivalent R, L, C = ", R_equiv, ", ", L_equiv, ", ", C_equiv) - - # Use a tight tolerance because they *should* be mathematically equivalent if the model is self-consistent - rtol_equiv = 1e-6 - # Resistance mismatch in equivalent model? - @test R_equiv ≈ R_orig rtol = rtol_equiv - # Inductance mismatch in equivalent model? - @test L_equiv ≈ L_orig rtol = rtol_equiv - # Capacitance mismatch in equivalent model? - @test C_equiv ≈ C_orig rtol = rtol_equiv - - println(" Effective properties reconstruction test passed.") - - println("\nTesting CablesLibrary methods...") - library = CablesLibrary() - add!(library, cable_design) - - initial_count = length(library) - test_cable_id = cable_design.cable_id # Should be "tutorial2_test" - @test initial_count >= 1 - @test haskey(library, test_cable_id) - - println(" Testing delete!...") - delete!(library, test_cable_id) - @test !haskey(library, test_cable_id) - @test length(library) == initial_count - 1 - - # Test removing non-existent (should throw error) - @test_throws KeyError delete!(library, "non_existent_cable_id_123") - @test length(library) == initial_count - 1 # Count remains unchanged - - - println("\nTesting JSON Save/Load and RLC consistency...") - - add!(library, cable_design) - @test length(library) == initial_count # Should be back to original count - @test haskey(library, test_cable_id) - - mktempdir(joinpath(@__DIR__)) do tmpdir # Create a temporary directory for the test file - output_file = joinpath(tmpdir, "cables_library_test.json") - println(" Saving library to: ", output_file) - - # Test saving - @test isfile(save(library, file_name = output_file)) - @test filesize(output_file) > 0 # Check if file is not empty - - # Test loading into a new library - loaded_library = CablesLibrary() - load!(loaded_library, file_name = output_file) - @test length(loaded_library) == length(library) - - # Retrieve the reloaded design - reloaded_design = get(loaded_library, cable_design.cable_id) - println("Reloaded components:") - for comp in reloaded_design.components - println(" ID: ", repr(comp.id), " Type: ", typeof(comp)) # Use repr to see if ID is empty or weird - end - @test reloaded_design isa CableDesign - @test reloaded_design.cable_id == cable_design.cable_id - @test length(reloaded_design.components) == length(cable_design.components) - # Optionally, add more granular checks on reloaded components/layers if needed - - println(" Calculating RLC for reloaded design...") - (R_reload, L_reload, C_reload) = calculate_rlc(reloaded_design) - println(" Reloaded Design RLC = ($R_reload, $L_reload, $C_reload)") - println(" Original Design RLC = ($R_orig, $L_orig, $C_orig)") # Print original for comparison - - # Use a very tight tolerance - should be almost identical if serialization is good - rtol_serial = 1e-9 - # Resistance mismatch after JSON reload? - @test R_reload ≈ R_orig rtol = rtol_serial - # Inductance mismatch after JSON reload? - @test L_reload ≈ L_orig rtol = rtol_serial - # Capacitance mismatch after JSON reload? - @test C_reload ≈ C_orig rtol = rtol_serial - - println(" JSON save/load test passed.") - end # mktempdir ensures cleanup - - - println(" Setting up CableSystem...") - f_pscad = 10.0 .^ range(0, stop = 6, length = 10) # Frequency range - earth_params_pscad = EarthModel(f_pscad, 100.0, 10.0, 1.0) # 100 Ω·m, εr=10, μr=1 - - # Use outermost radius for trifoil calculation spacing - r = cable_design.components[end].insulator_group.radius_ext - - s = 2r + 0.01 - - x0, y0 = 0.0, -1.0 # System center 1 m underground - xa, ya, xb, yb, xc, yc = trifoil_formation(x0, y0, s) - - d_ab = hypot(xa - xb, ya - yb) - d_bc = hypot(xb - xc, yb - yc) - d_ca = hypot(xc - xa, yc - ya) - @assert min(d_ab, d_bc, d_ca) ≥ 2r - - cable_system_id = "tutorial2_pscad_test" - cablepos = - CablePosition(cable_design, xa, ya, Dict("core" => 1, "sheath" => 0, "jacket" => 0)) - cable_system = - LineCableSystem(cable_system_id, 1000.0, cablepos) - add!( - cable_system, - cable_design, - xb, - yb, - Dict("core" => 2, "sheath" => 0, "jacket" => 0), - ) - add!( - cable_system, - cable_design, - xc, - yc, - Dict("core" => 3, "sheath" => 0, "jacket" => 0), - ) - @test cable_system.num_cables == 3 - @test cable_system.num_phases == 3 - - mktempdir(joinpath(@__DIR__)) do tmpdir - output_file = joinpath(tmpdir, "tutorial2_export_test.pscx") - println(" Exporting PSCAD file to: ", output_file) - - # Run export and use returned path (exporter may prefix basename with system_id) - result_path = - export_data(:pscad, cable_system, earth_params_pscad, file_name = output_file) - - # Basic file checks (use returned path) - @test result_path != nothing - @test isfile(result_path) - @test filesize(result_path) > 200 - - # Basic XML content checks - xml_content = read(result_path, String) - - @test occursin("", xml_content) # Check for closing root tag - - println(" Performing XML structure checks via XPath...") - local xml_doc - try - xml_doc = readxml(result_path) - catch parse_err - println("Failed to parse generated XML: $(parse_err)") - println("Skipping XPath validation due to parsing error.") - return # Exit testset early - end - - # 3. Check Root Element and Attributes - project_node = root(xml_doc) - @test nodename(project_node) == "project" - @test haskey(project_node, "name") - @test project_node["name"] == cable_system.system_id - # Check for expected version if needed - @test project_node["version"] == "5.0.2" - - # 4. Check Count of Cable Definitions - # Finds all 'User' components representing a coaxial cable definition - cable_coax_nodes = findall("//User[@name='master:Cable_Coax']", project_node) - @test length(cable_coax_nodes) == length(cable_system.cables) # Should be 3 - - # 5. Check Data within the First Cable Definition (CABNUM=1) - # Construct XPath to find the within the first Cable_Coax User component - # This is a bit complex: find User where name='master:Cable_Coax' AND which has a child param CABNUM=1 - xpath_cable1_params = "//User[@name='master:Cable_Coax'][paramlist/param[@name='CABNUM' and @value='1']]/paramlist" - params_cable1_node = findfirst(xpath_cable1_params, project_node) - @test !isnothing(params_cable1_node) - - if !isnothing(params_cable1_node) - # Helper to get a specific param value from the paramlist node - function get_param_value(paramlist_node, param_name) - p_node = findfirst("param[@name='$(param_name)']", paramlist_node) - return isnothing(p_node) ? nothing : p_node["value"] - end - - # Check component names exported - @test get_param_value(params_cable1_node, "CONNAM1") == "Core" # Matches cable_system.cables[1].design_data.components[1].id ? - @test get_param_value(params_cable1_node, "CONNAM2") == "Sheath" # Matches cable_system.cables[1].design_data.components[2].id ? - @test get_param_value(params_cable1_node, "CONNAM3") == "Jacket" # Matches cable_system.cables[1].design_data.components[3].id ? - - # Check X position - x_val_str = get_param_value(params_cable1_node, "X") - @test !isnothing(x_val_str) - if !isnothing(x_val_str) - parsed_x = parse(Float64, x_val_str) - expected_x = cable_system.cables[1].horz # Get horz from the first cable in the system - println( - " Checking first cable horz: XML='$(x_val_str)', Expected='$(expected_x)'", - ) - @test parsed_x ≈ expected_x rtol = 1e-6 - end - - # Check Y position (in PSCAD Y is oriented downwards) - y_val_str = get_param_value(params_cable1_node, "Y") - @test !isnothing(y_val_str) - if !isnothing(y_val_str) - parsed_y = abs(parse(Float64, y_val_str)) - expected_y = abs(cable_system.cables[1].vert) - println( - " Checking first cable vert: XML='$(y_val_str)', Expected='$(expected_y)' (May differ due to PSCAD coord system)", - ) - # Don't assert exact equality - @test isapprox(parsed_y, expected_y, rtol = 1e-4) - end - - - # Check an effective property, e.g., Core conductor effective resistivity (RHOC) - rhoc_val_str = get_param_value(params_cable1_node, "RHOC") - @test !isnothing(rhoc_val_str) - if !isnothing(rhoc_val_str) - parsed_rhoc = parse(Float64, rhoc_val_str) - # Get effective rho from the first component (core) of the first cable design - expected_rhoc = - cable_system.cables[1].design_data.components[1].conductor_props.rho - println( - " Checking first cable RHOC: XML='$(rhoc_val_str)', Expected='$(expected_rhoc)'", - ) - # Use a slightly looser tolerance for calculated effective properties - @test parsed_rhoc ≈ expected_rhoc rtol = 1e-4 - end - - # Check an effective dielectric property, e.g., Main insulation Epsilon_r (EPS1) - eps1_val_str = get_param_value(params_cable1_node, "EPS1") - @test !isnothing(eps1_val_str) - if !isnothing(eps1_val_str) - parsed_eps1 = parse(Float64, eps1_val_str) - # Get effective eps_r from the first component (core) insulator props - expected_eps1 = - cable_system.cables[1].design_data.components[1].insulator_props.eps_r - println( - " Checking first cable EPS1: XML='$(eps1_val_str)', Expected='$(expected_eps1)'", - ) - @test parsed_eps1 ≈ expected_eps1 rtol = 1e-4 - end - - end - - # 6. Check Ground Parameters (Example) - ground_params = - findfirst("//User[@name='master:Line_Ground']/paramlist", project_node) - @test !isnothing(ground_params) - - println(" XML structure checks via XPath passed.") - - println(" PSCAD export basic checks passed.") - end # mktempdir cleanup - - println("\nTesting plotting functions...") - - println(" Testing preview...") - # Reuse the fully constructed cable_design - fig, ax = preview( - cable_design, - display_plot = false, - display_legend = true, - ) - @test fig isa Makie.Figure - @test ax isa Makie.Axis - fig, ax = preview( - cable_design, - display_plot = false, - display_legend = false, - ) - @test fig isa Makie.Figure - @test ax isa Makie.Axis - - println(" Testing preview...") - # Reuse the fully constructed cable_system - fig, ax = preview(cable_system, zoom_factor = 0.5, display_plot = false) - @test fig isa Makie.Figure - @test ax isa Makie.Axis - - - println(" Plotting functions executed without errors.") - - println("\nTesting DataFrame generation...") - - # Reuse the fully constructed cable_design - println(" Testing DataFrame...") - df_core = DataFrame(cable_design, :baseparams) - @test df_core isa DataFrame - @test names(df_core) == ["parameter", "computed", "nominal", "percent_diff"] || - names(df_core) == [ - "parameter", - "computed", - "nominal", - "percent_diff", - "lower", - "upper", - "in_range?", - ] # Allow for uncertainty columns - @test nrow(df_core) == 3 - - df_comp = DataFrame(cable_design, :components) - @test df_comp isa DataFrame - # Expected columns: "property", "core", "sheath", "jacket" (based on tutorial build) - @test names(df_comp) == ["property", "core", "sheath", "jacket"] - @test nrow(df_comp) > 5 # Should have several properties - - df_detail = DataFrame(cable_design, :detailed) - @test df_detail isa DataFrame - @test "property" in names(df_detail) - # Check if columns were generated for layers, e.g., "core, cond. layer 1" - @test occursin("core, cond. layer 1", join(names(df_detail))) - @test occursin("jacket, ins. layer 1", join(names(df_detail))) - @test nrow(df_detail) > 10 # Should have many properties - - # Test invalid format - @test_throws ErrorException DataFrame(cable_design, :invalid_format) - - println(" Testing DataFrame...") - # Reuse the fully constructed cable_system - df_sys = DataFrame(cable_system) - @test df_sys isa DataFrame - @test names(df_sys) == ["cable_id", "horz", "vert", "phase_mapping"] - @test nrow(df_sys) == 3 # Because we added 3 cables - - println(" DataFrame functions executed successfully.") - - println("\nTesting Base.show methods...") - - # Reuse objects created earlier in the test file - # List of objects that have custom text/plain show methods in DataModel - # Add more as needed (e.g., specific part types if they have custom shows) - objects_to_show = [ - core, # ConductorGroup - main_insu, # InsulatorGroup - core_cc, # CableComponent - cable_design, # CableDesign - cable_system, # LineCableSystem - materials, - # Add an example of a basic part if desired and has a show method - Tubular(0.0, 0.01, get(materials, "aluminum")), - ] - - mime = MIME"text/plain"() - - for obj in objects_to_show - println(" Testing show for: $(typeof(obj))") - obj_repr = sprint(show, mime, obj) - @test obj_repr isa String - @test length(obj_repr) > 10 # Check that it produced some reasonable output - end - - println(" Custom show methods executed without errors.") - - println("\nDataModel test completed.") - -end +@testsnippet deps_datamodel begin + using Plots + using EzXML + using Makie: Makie, Figure, Axis +end + +@testitem "DataModel module" setup = [defaults, deps_datamodel] begin + + println("\nSetting up materials and dimensions for DataModel test...") + materials = MaterialsLibrary(add_defaults = true) + @test haskey(materials, "aluminum") + @test haskey(materials, "copper") + @test haskey(materials, "polyacrylate") + @test haskey(materials, "semicon1") + @test haskey(materials, "semicon2") + @test haskey(materials, "pe") + + initial_default_count = length(materials) + @test initial_default_count > 5 # Should have several defaults + + materials_empty = MaterialsLibrary(add_defaults = false) + @test isempty(materials_empty) + + # Add a custom material for removal tests + mat_remove_test = Material(1e-5, 5.0, 1.0, 20.0, 0.05, 100.0) + add!(materials, "remove_me", mat_remove_test) + @test length(materials) == initial_default_count + 1 + @test haskey(materials, "remove_me") + + println(" Testing delete!...") + delete!(materials, "remove_me") + @test !haskey(materials, "remove_me") + @test length(materials) == initial_default_count + + # Test removing non-existent (should throw KeyError based on source) + @test_throws KeyError delete!( + materials, + "does_not_exist", + ) + # Verify count didn't change + @test length(materials) == initial_default_count + + println(" Testing DataFrame...") + # Use the empty DB + one material for simpler checking + mat_list_test = Material(9e9, 9.0, 9.0, 99.0, 0.9, 100.0) + add!(materials_empty, "list_test_mat", mat_list_test) + df_listed = DataFrame(materials_empty) + + @test df_listed isa DataFrame + @test names(df_listed) == ["name", "rho", "eps_r", "mu_r", "T0", "alpha", "kappa"] # Check column names + @test nrow(df_listed) == 1 + @test df_listed[1, :name] == "list_test_mat" + @test df_listed[1, :rho] == 9e9 + @test df_listed[1, :eps_r] == 9.0 + @test df_listed[1, :mu_r] == 9.0 + @test df_listed[1, :T0] == 99.0 + @test df_listed[1, :alpha] == 0.9 + + println(" Testing save/load cycle for MaterialsLibrary...") + mktempdir(joinpath(@__DIR__)) do tmpdir + output_file = joinpath(tmpdir, "materials_library_test.json") + println(" Saving to: ", output_file) + + # Save the db that had defaults + 'remove_me' (before removal) + # Let's re-add it for a more comprehensive save file + db_to_save = MaterialsLibrary(add_defaults = true) + mat_temp = Material(1e-5, 5.0, 1.0, 20.0, 0.05, 100.0) + add!(db_to_save, "temp_mat", mat_temp) + num_expected = length(db_to_save) + + save(db_to_save, file_name = output_file) + @test isfile(output_file) + @test filesize(output_file) > 0 + + # Load into a NEW, EMPTY library + materials_from_json = MaterialsLibrary(add_defaults = false) + load!(materials_from_json, file_name = output_file) + + # Verify loaded content + @test length(materials_from_json) == num_expected + @test haskey(materials_from_json, "temp_mat") + @test haskey(materials_from_json, "copper") # Check a default also loaded + loaded_temp_mat = get(materials_from_json, "temp_mat") + @test loaded_temp_mat.rho == mat_temp.rho + @test loaded_temp_mat.eps_r == mat_temp.eps_r + + println(" Save/load cycle completed.") + println("Materials Library tests completed.") + end # Temp dir cleanup + + # Cable dimensions from tutorial + num_co_wires = 61 + num_sc_wires = 49 + d_core = 38.1e-3 + d_w = 4.7e-3 + t_sc_in = 0.6e-3 + t_ins = 8e-3 + t_sc_out = 0.3e-3 + d_ws = 0.95e-3 + t_cut = 0.1e-3 + w_cut = 10e-3 + t_wbt = 0.3e-3 + t_sct = 0.3e-3 # Semiconductive tape thickness + t_alt = 0.15e-3 + t_pet = 0.05e-3 + t_jac = 2.4e-3 + + # Nominal data for final comparison + datasheet_info = NominalData( + designation_code = "NA2XS(FL)2Y", + U0 = 18.0, # Phase-to-ground voltage [kV] + U = 30.0, # Phase-to-phase voltage [kV] + conductor_cross_section = 1000.0, # [mm²] + screen_cross_section = 35.0, # [mm²] + resistance = 0.0291, # DC resistance [Ω/km] + capacitance = 0.39, # Capacitance [μF/km] + inductance = 0.3, # Inductance in trifoil [mH/km] + ) + @test datasheet_info.resistance > 0 + @test datasheet_info.capacitance > 0 + @test datasheet_info.inductance > 0 + + function calculate_rlc( + design::CableDesign; + rho_e::Float64 = 100.0, + default_S_factor::Float64 = 2.0, + ) + # Get components + core_comp = design.components[findfirst(c -> c.id == "core", design.components)] + sheath_comp = + design.components[findfirst(c -> c.id == "sheath", design.components)] + last_comp = design.components[end] # Usually jacket + + if isnothing(core_comp) || isnothing(sheath_comp) + error( + "Required 'core' or 'sheath' component not found in design for RLC calculation.", + ) + end + + # Resistance (from effective core conductor group resistance) + R = core_comp.conductor_group.resistance * 1e3 # Ω/m to Ω/km + + # Inductance (Trifoil) + # Use outermost radius for separation calculation - corrected access path + outermost_radius = last_comp.insulator_group.radius_ext + S = default_S_factor * outermost_radius # Approx center-to-center distance [m] + + L = + calc_inductance_trifoil( + core_comp.conductor_group.radius_in, + core_comp.conductor_group.radius_ext, + core_comp.conductor_props.rho, core_comp.conductor_props.mu_r, + sheath_comp.conductor_group.radius_in, + sheath_comp.conductor_group.radius_ext, sheath_comp.conductor_props.rho, + sheath_comp.conductor_props.mu_r, + S, rho_e = rho_e, + ) * 1e6 # H/m to mH/km + + # Capacitance + C = + calc_shunt_capacitance( + core_comp.conductor_group.radius_ext, + core_comp.insulator_group.radius_ext, + core_comp.insulator_props.eps_r, + ) * 1e6 * 1e3 # F/m to μF/km + + return R, L, C + end + + println("Constructing core conductor group...") + material_alu = get(materials, "aluminum") + core = ConductorGroup(WireArray(0.0, Diameter(d_w), 1, 0.0, material_alu)) + @test core isa ConductorGroup + @test length(core.layers) == 1 + @test core.radius_in == 0 + @test core.radius_ext ≈ d_w / 2.0 + @test core.resistance > 0 + @test core.gmr > 0 + + add!(core, WireArray, Diameter(d_w), 6, 15.0, material_alu) + @test length(core.layers) == 2 + @test core.radius_ext ≈ (d_w / 2.0) * 3 # Approximation for 1+6 wires + @test core.resistance > 0 # Resistance should decrease + + add!(core, WireArray, Diameter(d_w), 12, 13.5, material_alu) + @test length(core.layers) == 3 + @test core.radius_ext ≈ (d_w / 2.0) * 5 # Approximation for 1+6+12 wires + + add!(core, WireArray, Diameter(d_w), 18, 12.5, material_alu) + @test length(core.layers) == 4 + @test core.radius_ext ≈ (d_w / 2.0) * 7 # Approximation + + add!(core, WireArray, Diameter(d_w), 24, 11.0, material_alu) + @test length(core.layers) == 5 + @test core.radius_ext ≈ (d_w / 2.0) * 9 # Approximation + # Check final calculated radius against nominal diameter + # Note: constructor uses internal calculations, may differ slightly from d_core/2 + @test core.radius_ext ≈ d_core / 2.0 rtol = 0.1 # Allow 10% tolerance for geometric approximation vs nominal + final_core_radius = core.radius_ext # Store for later use + final_core_resistance = core.resistance # Store for later use + + println("Constructing main insulation group...") + # Inner semiconductive tape + material_sc_tape = get(materials, "polyacrylate") + main_insu = InsulatorGroup(Semicon(core, Thickness(t_sct), material_sc_tape)) + @test main_insu isa InsulatorGroup + @test length(main_insu.layers) == 1 + @test main_insu.radius_in ≈ final_core_radius + @test main_insu.radius_ext ≈ final_core_radius + t_sct + + # Inner semiconductor + material_sc1 = get(materials, "semicon1") + add!(main_insu, Semicon, Thickness(t_sc_in), material_sc1) + @test length(main_insu.layers) == 2 + @test main_insu.radius_ext ≈ final_core_radius + t_sct + t_sc_in + + # Main insulation (XLPE) + material_pe = get(materials, "pe") + add!(main_insu, Insulator, Thickness(t_ins), material_pe) + @test length(main_insu.layers) == 3 + @test main_insu.radius_ext ≈ final_core_radius + t_sct + t_sc_in + t_ins + + # Outer semiconductor + material_sc2 = get(materials, "semicon2") + add!(main_insu, Semicon, Thickness(t_sc_out), material_sc2) + @test length(main_insu.layers) == 4 + @test main_insu.radius_ext ≈ final_core_radius + t_sct + t_sc_in + t_ins + t_sc_out + + # Outer semiconductive tape + add!(main_insu, Semicon, Thickness(t_sct), material_sc_tape) + @test length(main_insu.layers) == 5 + @test main_insu.radius_ext ≈ + final_core_radius + t_sct + t_sc_in + t_ins + t_sc_out + t_sct + @test main_insu.shunt_capacitance > 0 + @test main_insu.shunt_conductance >= 0 + final_insu_radius = main_insu.radius_ext # Store for later use + + println("Creating core cable component...") + core_cc = CableComponent("core", core, main_insu) + @test core_cc isa CableComponent + @test core_cc.id == "core" + @test core_cc.conductor_group === core + @test core_cc.insulator_group === main_insu + @test core_cc.conductor_props isa Material # Check effective props were created + @test core_cc.insulator_props isa Material + + println("Initializing CableDesign...") + cable_id = "tutorial2_test" + cable_design = CableDesign(cable_id, core_cc, nominal_data = datasheet_info) + @test cable_design isa CableDesign + @test length(cable_design.components) == 1 + @test cable_design.components[1] === core_cc + @test cable_design.nominal_data === datasheet_info + + println("Constructing sheath group...") + # Wire screens + lay_ratio_screen = 10.0 + material_cu = get(materials, "copper") + screen_con = ConductorGroup( + WireArray( + main_insu, + Diameter(d_ws), + num_sc_wires, + lay_ratio_screen, + material_cu, + ), + ) + @test screen_con isa ConductorGroup + @test screen_con.radius_in ≈ final_insu_radius + @test screen_con.radius_ext ≈ final_insu_radius + d_ws # Approx radius of single layer of wires + + # Copper tape + add!( + screen_con, + Strip, + Thickness(t_cut), + w_cut, + lay_ratio_screen, + material_cu, + ) + @test screen_con.radius_ext ≈ final_insu_radius + d_ws + t_cut + final_screen_con_radius = screen_con.radius_ext + + # Water blocking tape + material_wbt = get(materials, "polyacrylate") # Assuming same as sc tape + screen_insu = InsulatorGroup(Semicon(screen_con, Thickness(t_wbt), material_wbt)) + @test screen_insu.radius_ext ≈ final_screen_con_radius + t_wbt + final_screen_insu_radius = screen_insu.radius_ext + + # Sheath Cable Component & Add to Design + sheath_cc = CableComponent("sheath", screen_con, screen_insu) + @test sheath_cc isa CableComponent + add!(cable_design, sheath_cc) + @test length(cable_design.components) == 2 + @test cable_design.components[2] === sheath_cc + + println("Constructing jacket group...") + # Aluminum foil + material_alu = get(materials, "aluminum") # Re-get just in case + jacket_con = ConductorGroup(Tubular(screen_insu, Thickness(t_alt), material_alu)) + @test jacket_con.radius_ext ≈ final_screen_insu_radius + t_alt + final_jacket_con_radius = jacket_con.radius_ext + + # PE layer after foil + material_pe = get(materials, "pe") # Re-get just in case + jacket_insu = InsulatorGroup(Insulator(jacket_con, Thickness(t_pet), material_pe)) + @test jacket_insu.radius_ext ≈ final_jacket_con_radius + t_pet + + # PE jacket + add!(jacket_insu, Insulator, Thickness(t_jac), material_pe) + @test jacket_insu.radius_ext ≈ final_jacket_con_radius + t_pet + t_jac + final_jacket_insu_radius = jacket_insu.radius_ext + + # Add Jacket Component to Design (using alternative signature) + add!(cable_design, "jacket", jacket_con, jacket_insu) + @test length(cable_design.components) == 3 + @test cable_design.components[3].id == "jacket" + # Check overall radius + @test cable_design.components[3].insulator_group.radius_ext ≈ + final_jacket_insu_radius + + println("Checking DataFrame...") + @test DataFrame(cable_design, :baseparams) isa DataFrame + @test DataFrame(cable_design, :components) isa DataFrame + @test DataFrame(cable_design, :detailed) isa DataFrame + + println("Validating calculated RLC against nominal values (rtol=6%)...") + + # Get components for calculation (assuming they are named consistently) + cable_core = + cable_design.components[findfirst(c -> c.id == "core", cable_design.components)] + cable_sheath = + cable_design.components[findfirst( + c -> c.id == "sheath", + cable_design.components, + )] # Note: Tutorial used 'cable_shield' variable name + cable_jacket = + cable_design.components[findfirst( + c -> c.id == "jacket", + cable_design.components, + )] + + @test cable_core !== nothing + @test cable_sheath !== nothing + @test cable_jacket !== nothing + + (R_orig, L_orig, C_orig) = calculate_rlc(cable_design) + println(" Original design RLC = ($R_orig, $L_orig, $C_orig)") + @test R_orig ≈ datasheet_info.resistance rtol = 0.06 + @test L_orig ≈ datasheet_info.inductance rtol = 0.06 + @test C_orig ≈ datasheet_info.capacitance rtol = 0.06 + + println("\nTesting CableDesign reconstruction...") + new_components = [] + for original_component in cable_design.components + println(" Reconstructing component: $(original_component.id)") + + # Extract effective properties and dimensions + eff_cond_props = original_component.conductor_props + eff_ins_props = original_component.insulator_props + r_in_cond = original_component.conductor_group.radius_in + r_ext_cond = original_component.conductor_group.radius_ext + r_in_ins = original_component.insulator_group.radius_in + r_ext_ins = original_component.insulator_group.radius_ext + + # Sanity check dimensions + @test r_ext_cond ≈ r_in_ins atol = 1e-9 # Inner radius of insulator must match outer of conductor + + # Create simplified Tubular conductor using effective properties + # Note: We must provide a material object, which are the effective props here + equiv_conductor = Tubular(r_in_cond, r_ext_cond, eff_cond_props) + # Wrap it in a ConductorGroup (which recalculates R, L based on the Tubular part) + equiv_cond_group = ConductorGroup(equiv_conductor) + + # Create simplified Insulator using effective properties + equiv_insulator = Insulator(r_in_ins, r_ext_ins, eff_ins_props) + # Wrap it in an InsulatorGroup (which recalculates C, G based on the Insulator part) + equiv_ins_group = InsulatorGroup(equiv_insulator) + + # Create the new, equivalent CableComponent + equiv_component = CableComponent( + original_component.id, + equiv_cond_group, + equiv_ins_group, + ) + + # Check if the recalculated R/L/C/G of the simple groups match the effective props closely. Note: This tests the self-consistency of the effective property calculations and the Tubular/Insulator constructors. Tolerance might need adjustment. + @test equiv_cond_group.resistance ≈ + calc_tubular_resistance( + r_in_cond, + r_ext_cond, + eff_cond_props.rho, + 0.0, + 20.0, + 20.0, + ) rtol = + 1e-6 + @test equiv_ins_group.shunt_capacitance ≈ + calc_shunt_capacitance(r_in_ins, r_ext_ins, eff_ins_props.eps_r) rtol = + 1e-6 + # GMR/Inductance and Conductance checks could also be added here + + push!(new_components, equiv_component) + end + + # Assemble the new CableDesign from the equivalent components + @test length(new_components) == length(cable_design.components) + equiv_cable_design = CableDesign( + cable_design.cable_id * "_equiv", + new_components[1], # Initialize with the first equivalent component + nominal_data = datasheet_info, # Keep same nominal data for reference + ) + + # Add remaining equivalent components + if length(new_components) > 1 + for i in eachindex(new_components)[2:end] + add!(equiv_cable_design, new_components[i]) + end + end + @test length(equiv_cable_design.components) == length(new_components) + println(" Equivalent cable design assembled.") + + println(" Calculating RLC for equivalent design...") + + (R_equiv, L_equiv, C_equiv) = calculate_rlc(equiv_cable_design) + + println(" Original R, L, C = ", R_orig, ", ", L_orig, ", ", C_orig) + println(" Equivalent R, L, C = ", R_equiv, ", ", L_equiv, ", ", C_equiv) + + # Use a tight tolerance because they *should* be mathematically equivalent if the model is self-consistent + rtol_equiv = 1e-6 + # Resistance mismatch in equivalent model? + @test R_equiv ≈ R_orig rtol = rtol_equiv + # Inductance mismatch in equivalent model? + @test L_equiv ≈ L_orig rtol = rtol_equiv + # Capacitance mismatch in equivalent model? + @test C_equiv ≈ C_orig rtol = rtol_equiv + + println(" Effective properties reconstruction test passed.") + + println("\nTesting CablesLibrary methods...") + library = CablesLibrary() + add!(library, cable_design) + + initial_count = length(library) + test_cable_id = cable_design.cable_id # Should be "tutorial2_test" + @test initial_count >= 1 + @test haskey(library, test_cable_id) + + println(" Testing delete!...") + delete!(library, test_cable_id) + @test !haskey(library, test_cable_id) + @test length(library) == initial_count - 1 + + # Test removing non-existent (should throw error) + @test_throws KeyError delete!(library, "non_existent_cable_id_123") + @test length(library) == initial_count - 1 # Count remains unchanged + + + println("\nTesting JSON Save/Load and RLC consistency...") + + add!(library, cable_design) + @test length(library) == initial_count # Should be back to original count + @test haskey(library, test_cable_id) + + mktempdir(joinpath(@__DIR__)) do tmpdir # Create a temporary directory for the test file + output_file = joinpath(tmpdir, "cables_library_test.json") + println(" Saving library to: ", output_file) + + # Test saving + @test isfile(save(library, file_name = output_file)) + @test filesize(output_file) > 0 # Check if file is not empty + + # Test loading into a new library + loaded_library = CablesLibrary() + load!(loaded_library, file_name = output_file) + @test length(loaded_library) == length(library) + + # Retrieve the reloaded design + reloaded_design = get(loaded_library, cable_design.cable_id) + println("Reloaded components:") + for comp in reloaded_design.components + println(" ID: ", repr(comp.id), " Type: ", typeof(comp)) # Use repr to see if ID is empty or weird + end + @test reloaded_design isa CableDesign + @test reloaded_design.cable_id == cable_design.cable_id + @test length(reloaded_design.components) == length(cable_design.components) + # Optionally, add more granular checks on reloaded components/layers if needed + + println(" Calculating RLC for reloaded design...") + (R_reload, L_reload, C_reload) = calculate_rlc(reloaded_design) + println(" Reloaded Design RLC = ($R_reload, $L_reload, $C_reload)") + println(" Original Design RLC = ($R_orig, $L_orig, $C_orig)") # Print original for comparison + + # Use a very tight tolerance - should be almost identical if serialization is good + rtol_serial = 1e-9 + # Resistance mismatch after JSON reload? + @test R_reload ≈ R_orig rtol = rtol_serial + # Inductance mismatch after JSON reload? + @test L_reload ≈ L_orig rtol = rtol_serial + # Capacitance mismatch after JSON reload? + @test C_reload ≈ C_orig rtol = rtol_serial + + println(" JSON save/load test passed.") + end # mktempdir ensures cleanup + + + println(" Setting up CableSystem...") + f_pscad = 10.0 .^ range(0, stop = 6, length = 10) # Frequency range + earth_params_pscad = EarthModel(f_pscad, 100.0, 10.0, 1.0, 1.0) # 100 Ω·m, εr=10, μr=1, κ=1.0 + + # Use outermost radius for trifoil calculation spacing + r = cable_design.components[end].insulator_group.radius_ext + + s = 2r + 0.01 + + x0, y0 = 0.0, -1.0 # System center 1 m underground + xa, ya, xb, yb, xc, yc = trifoil_formation(x0, y0, s) + + d_ab = hypot(xa - xb, ya - yb) + d_bc = hypot(xb - xc, yb - yc) + d_ca = hypot(xc - xa, yc - ya) + @assert min(d_ab, d_bc, d_ca) ≥ 2r + + cable_system_id = "tutorial2_pscad_test" + cablepos = + CablePosition(cable_design, xa, ya, Dict("core" => 1, "sheath" => 0, "jacket" => 0)) + cable_system = + LineCableSystem(cable_system_id, 1000.0, cablepos) + add!( + cable_system, + cable_design, + xb, + yb, + Dict("core" => 2, "sheath" => 0, "jacket" => 0), + ) + add!( + cable_system, + cable_design, + xc, + yc, + Dict("core" => 3, "sheath" => 0, "jacket" => 0), + ) + @test cable_system.num_cables == 3 + @test cable_system.num_phases == 3 + + mktempdir(joinpath(@__DIR__)) do tmpdir + output_file = joinpath(tmpdir, "tutorial2_export_test.pscx") + println(" Exporting PSCAD file to: ", output_file) + + # Run export and use returned path (exporter may prefix basename with system_id) + result_path = + export_data(:pscad, cable_system, earth_params_pscad, file_name = output_file) + + # Basic file checks (use returned path) + @test result_path != nothing + @test isfile(result_path) + @test filesize(result_path) > 200 + + # Basic XML content checks + xml_content = read(result_path, String) + + @test occursin("", xml_content) # Check for closing root tag + + println(" Performing XML structure checks via XPath...") + local xml_doc + try + xml_doc = readxml(result_path) + catch parse_err + println("Failed to parse generated XML: $(parse_err)") + println("Skipping XPath validation due to parsing error.") + return # Exit testset early + end + + # 3. Check Root Element and Attributes + project_node = root(xml_doc) + @test nodename(project_node) == "project" + @test haskey(project_node, "name") + @test project_node["name"] == cable_system.system_id + # Check for expected version if needed + @test project_node["version"] == "5.0.2" + + # 4. Check Count of Cable Definitions + # Finds all 'User' components representing a coaxial cable definition + cable_coax_nodes = findall("//User[@name='master:Cable_Coax']", project_node) + @test length(cable_coax_nodes) == length(cable_system.cables) # Should be 3 + + # 5. Check Data within the First Cable Definition (CABNUM=1) + # Construct XPath to find the within the first Cable_Coax User component + # This is a bit complex: find User where name='master:Cable_Coax' AND which has a child param CABNUM=1 + xpath_cable1_params = "//User[@name='master:Cable_Coax'][paramlist/param[@name='CABNUM' and @value='1']]/paramlist" + params_cable1_node = findfirst(xpath_cable1_params, project_node) + @test !isnothing(params_cable1_node) + + if !isnothing(params_cable1_node) + # Helper to get a specific param value from the paramlist node + function get_param_value(paramlist_node, param_name) + p_node = findfirst("param[@name='$(param_name)']", paramlist_node) + return isnothing(p_node) ? nothing : p_node["value"] + end + + # Check component names exported + @test get_param_value(params_cable1_node, "CONNAM1") == "Core" # Matches cable_system.cables[1].design_data.components[1].id ? + @test get_param_value(params_cable1_node, "CONNAM2") == "Sheath" # Matches cable_system.cables[1].design_data.components[2].id ? + @test get_param_value(params_cable1_node, "CONNAM3") == "Jacket" # Matches cable_system.cables[1].design_data.components[3].id ? + + # Check X position + x_val_str = get_param_value(params_cable1_node, "X") + @test !isnothing(x_val_str) + if !isnothing(x_val_str) + parsed_x = parse(Float64, x_val_str) + expected_x = cable_system.cables[1].horz # Get horz from the first cable in the system + println( + " Checking first cable horz: XML='$(x_val_str)', Expected='$(expected_x)'", + ) + @test parsed_x ≈ expected_x rtol = 1e-6 + end + + # Check Y position (in PSCAD Y is oriented downwards) + y_val_str = get_param_value(params_cable1_node, "Y") + @test !isnothing(y_val_str) + if !isnothing(y_val_str) + parsed_y = abs(parse(Float64, y_val_str)) + expected_y = abs(cable_system.cables[1].vert) + println( + " Checking first cable vert: XML='$(y_val_str)', Expected='$(expected_y)' (May differ due to PSCAD coord system)", + ) + # Don't assert exact equality + @test isapprox(parsed_y, expected_y, rtol = 1e-4) + end + + + # Check an effective property, e.g., Core conductor effective resistivity (RHOC) + rhoc_val_str = get_param_value(params_cable1_node, "RHOC") + @test !isnothing(rhoc_val_str) + if !isnothing(rhoc_val_str) + parsed_rhoc = parse(Float64, rhoc_val_str) + # Get effective rho from the first component (core) of the first cable design + expected_rhoc = + cable_system.cables[1].design_data.components[1].conductor_props.rho + println( + " Checking first cable RHOC: XML='$(rhoc_val_str)', Expected='$(expected_rhoc)'", + ) + # Use a slightly looser tolerance for calculated effective properties + @test parsed_rhoc ≈ expected_rhoc rtol = 1e-4 + end + + # Check an effective dielectric property, e.g., Main insulation Epsilon_r (EPS1) + eps1_val_str = get_param_value(params_cable1_node, "EPS1") + @test !isnothing(eps1_val_str) + if !isnothing(eps1_val_str) + parsed_eps1 = parse(Float64, eps1_val_str) + # Get effective eps_r from the first component (core) insulator props + expected_eps1 = + cable_system.cables[1].design_data.components[1].insulator_props.eps_r + println( + " Checking first cable EPS1: XML='$(eps1_val_str)', Expected='$(expected_eps1)'", + ) + @test parsed_eps1 ≈ expected_eps1 rtol = 1e-4 + end + + end + + # 6. Check Ground Parameters (Example) + ground_params = + findfirst("//User[@name='master:Line_Ground']/paramlist", project_node) + @test !isnothing(ground_params) + + println(" XML structure checks via XPath passed.") + + println(" PSCAD export basic checks passed.") + end # mktempdir cleanup + + println("\nTesting plotting functions...") + + println(" Testing preview...") + # Reuse the fully constructed cable_design + fig, ax = preview( + cable_design, + display_plot = false, + display_legend = true, + ) + @test fig isa Makie.Figure + @test ax isa Makie.Axis + fig, ax = preview( + cable_design, + display_plot = false, + display_legend = false, + ) + @test fig isa Makie.Figure + @test ax isa Makie.Axis + + println(" Testing preview...") + # Reuse the fully constructed cable_system + fig, ax = preview(cable_system, zoom_factor = 0.5, display_plot = false) + @test fig isa Makie.Figure + @test ax isa Makie.Axis + + + println(" Plotting functions executed without errors.") + + println("\nTesting DataFrame generation...") + + # Reuse the fully constructed cable_design + println(" Testing DataFrame...") + df_core = DataFrame(cable_design, :baseparams) + @test df_core isa DataFrame + @test names(df_core) == ["parameter", "computed", "nominal", "percent_diff"] || + names(df_core) == [ + "parameter", + "computed", + "nominal", + "percent_diff", + "lower", + "upper", + "in_range?", + ] # Allow for uncertainty columns + @test nrow(df_core) == 3 + + df_comp = DataFrame(cable_design, :components) + @test df_comp isa DataFrame + # Expected columns: "property", "core", "sheath", "jacket" (based on tutorial build) + @test names(df_comp) == ["property", "core", "sheath", "jacket"] + @test nrow(df_comp) > 5 # Should have several properties + + df_detail = DataFrame(cable_design, :detailed) + @test df_detail isa DataFrame + @test "property" in names(df_detail) + # Check if columns were generated for layers, e.g., "core, cond. layer 1" + @test occursin("core, cond. layer 1", join(names(df_detail))) + @test occursin("jacket, ins. layer 1", join(names(df_detail))) + @test nrow(df_detail) > 10 # Should have many properties + + # Test invalid format + @test_throws ErrorException DataFrame(cable_design, :invalid_format) + + println(" Testing DataFrame...") + # Reuse the fully constructed cable_system + df_sys = DataFrame(cable_system) + @test df_sys isa DataFrame + @test names(df_sys) == ["cable_id", "horz", "vert", "phase_mapping"] + @test nrow(df_sys) == 3 # Because we added 3 cables + + println(" DataFrame functions executed successfully.") + + println("\nTesting Base.show methods...") + + # Reuse objects created earlier in the test file + # List of objects that have custom text/plain show methods in DataModel + # Add more as needed (e.g., specific part types if they have custom shows) + objects_to_show = [ + core, # ConductorGroup + main_insu, # InsulatorGroup + core_cc, # CableComponent + cable_design, # CableDesign + cable_system, # LineCableSystem + materials, + # Add an example of a basic part if desired and has a show method + Tubular(0.0, 0.01, get(materials, "aluminum")), + ] + + mime = MIME"text/plain"() + + for obj in objects_to_show + println(" Testing show for: $(typeof(obj))") + obj_repr = sprint(show, mime, obj) + @test obj_repr isa String + @test length(obj_repr) > 10 # Check that it produced some reasonable output + end + + println(" Custom show methods executed without errors.") + + println("\nDataModel test completed.") + +end diff --git a/test/earthprops.jl b/test/earthprops.jl index 0e2d49c8..decf54c7 100644 --- a/test/earthprops.jl +++ b/test/earthprops.jl @@ -1,370 +1,390 @@ -@testsnippet defs_earthprops begin - # Access internal components for testing - const LCM = LineCableModels - const EP = LCM.EarthProps -end - -@testitem "EarthProps module" setup = [defaults, defs_earthprops] begin - - @testset "FDEM Formulations" begin - @testset "CPEarth" begin - cp_formulation = EP.CPEarth() - @test cp_formulation isa EP.AbstractFDEMFormulation - @test LCM.Commons.get_description(cp_formulation) == "Constant properties (CP)" - end - end - - @testset "CPEarth numerical tests" begin - frequencies = [50.0, 60.0, 1000.0] - base_rho_g = 100.0 - base_epsr_g = 10.0 - base_mur_g = 1.0 - formulation = EP.CPEarth() - - @testset "Float64 inputs" begin - rho, epsilon, mu = formulation(frequencies, base_rho_g, base_epsr_g, base_mur_g) - - @test length(rho) == length(frequencies) - @test all(r -> r == base_rho_g, rho) - - @test length(epsilon) == length(frequencies) - @test all(e -> isapprox(e, LCM.Commons.ε₀ * base_epsr_g), epsilon) - - @test length(mu) == length(frequencies) - @test all(m -> isapprox(m, LCM.Commons.μ₀ * base_mur_g), mu) - end - - @testset "Measurement inputs" begin - rho_m = 100.0 ± 5.0 - epsr_m = 10.0 ± 0.5 - mur_m = 1.0 ± 0.01 - - rho, epsilon, mu = formulation(frequencies, rho_m, epsr_m, mur_m) - - @test length(rho) == length(frequencies) - @test all(r -> r == rho_m, rho) - - @test length(epsilon) == length(frequencies) - @test all(e -> e.val ≈ (LCM.Commons.ε₀ * epsr_m).val, epsilon) - @test all(e -> e.err ≈ (LCM.Commons.ε₀ * epsr_m).err, epsilon) - - @test length(mu) == length(frequencies) - @test all(m -> m.val ≈ (LCM.Commons.μ₀ * mur_m).val, mu) - @test all(m -> m.err ≈ (LCM.Commons.μ₀ * mur_m).err, mu) - end - end - - @testset "EarthLayer Constructor" begin - frequencies = [50.0, 60.0] - base_rho_g = 100.0 - base_epsr_g = 10.0 - base_mur_g = 1.0 - t = 5.0 - formulation = EP.CPEarth() - - layer = EP.EarthLayer(frequencies, base_rho_g, base_epsr_g, base_mur_g, t, formulation) - - @test layer.base_rho_g == base_rho_g - @test layer.base_epsr_g == base_epsr_g - @test layer.base_mur_g == base_mur_g - @test layer.t == t - @test length(layer.rho_g) == length(frequencies) - @test all(layer.rho_g .== base_rho_g) - end - - @testset "EarthModel Constructor" begin - frequencies = [50.0, 60.0] - rho_g = 100.0 - epsr_g = 10.0 - mur_g = 1.0 - - @testset "Homogeneous Model" begin - model = EarthModel(frequencies, rho_g, epsr_g, mur_g) - @test length(model.layers) == 2 # Air + 1 earth layer - @test model.vertical_layers == false - @test model.freq_dependence isa EP.CPEarth - @test isinf(model.layers[1].t) # Air layer - @test isinf(model.layers[2].t) # Homogeneous earth - @test model.layers[2].base_rho_g == rho_g - end - - @testset "Finite Thickness Layer" begin - model = EarthModel(frequencies, rho_g, epsr_g, mur_g, t=20.0) - @test length(model.layers) == 2 - @test model.layers[2].t == 20.0 - end - - @testset "Vertical Layers" begin - model = EarthModel(frequencies, rho_g, epsr_g, mur_g, vertical_layers=true) - @test model.vertical_layers == true - end - - @testset "Input Validation" begin - @test_throws AssertionError EarthModel([-50.0], rho_g, epsr_g, mur_g) - @test_throws AssertionError EarthModel(frequencies, -100.0, epsr_g, mur_g) - @test_throws AssertionError EarthModel(frequencies, rho_g, -10.0, mur_g) - @test_throws AssertionError EarthModel(frequencies, rho_g, epsr_g, -1.0) - @test_throws AssertionError EarthModel(frequencies, rho_g, epsr_g, mur_g, t=-5.0) - end - end - - @testset "add! for EarthModel" begin - frequencies = [50.0, 60.0] - - @testset "Horizontal Layering" begin - model = EarthModel(frequencies, 100.0, 10.0, 1.0, t=20.0) - @test length(model.layers) == 2 - - add!(model, frequencies, 200.0, 15.0, 1.0, t=50.0) - @test length(model.layers) == 3 - @test model.layers[3].base_rho_g == 200.0 - @test model.layers[3].t == 50.0 - - add!(model, frequencies, 500.0, 20.0, 1.0, t=Inf) - @test length(model.layers) == 4 - @test isinf(model.layers[4].t) - - # Test invalid addition - @test_throws ErrorException add!(model, frequencies, 1000.0, 25.0, 1.0, t=Inf) - end - - @testset "Input Validation in add!" begin - model = EarthModel(frequencies, 100.0, 10.0, 1.0, t=20.0) - @test_throws AssertionError add!(model, [-50.0], 200.0, 15.0, 1.0) - @test_throws AssertionError add!(model, frequencies, -200.0, 15.0, 1.0) - @test_throws AssertionError add!(model, frequencies, 200.0, -15.0, 1.0) - @test_throws AssertionError add!(model, frequencies, 200.0, 15.0, -1.0) - @test_throws AssertionError add!(model, frequencies, 200.0, 15.0, 1.0, t=-5.0) - end - end - - @testset "Consecutive Infinite Layer Checks" begin - frequencies = [50.0] - @testset "Horizontal Model" begin - model = EarthModel(frequencies, 100.0, 10.0, 1.0, t=Inf) - # It's an error to add any layer after an infinite one in a horizontal model - @test_throws ErrorException add!(model, frequencies, 200.0, 15.0, 1.0, t=10.0) - @test_throws ErrorException add!(model, frequencies, 200.0, 15.0, 1.0, t=Inf) - end - - @testset "Vertical Model" begin - # Setup: model with two earth layers, the second being infinite - model = EarthModel(frequencies, 100.0, 10.0, 1.0, t=Inf, vertical_layers=true) - add!(model, frequencies, 150.0, 12.0, 1.0, t=20.0) # Add one more layer - add!(model, frequencies, 150.0, 12.0, 1.0, t=Inf) # Add one more layer, now Inf - - # It's an error to add another infinite layer - @test_throws ErrorException add!(model, frequencies, 200.0, 15.0, 1.0, t=Inf) - - # It should not be posssible to add a finite layer after an infinite one - @test_throws ErrorException add!(model, frequencies, 300.0, 20.0, 1.0, t=5.0) - @test length(model.layers) == 4 - @test model.layers[4].base_rho_g == 150.0 - @test isinf(model.layers[4].t) # The last layer should still be infinite - end - end - - @testset "show method for EarthModel" begin - frequencies = [50.0] - # Homogeneous model - model_homo = EarthModel(frequencies, 100.0, 10.0, 1.0) - s_homo = sprint(show, "text/plain", model_homo) - @test contains(s_homo, "EarthModel with 1 horizontal earth layer (homogeneous)") - @test contains(s_homo, "└─ Layer 2: [rho_g=100.0, epsr_g=10.0, mur_g=1.0, t=Inf]") - - # Multilayer horizontal model - model_multi_h = EarthModel(frequencies, 100.0, 10.0, 1.0, t=20.0) - add!(model_multi_h, frequencies, 200.0, 15.0, 1.0, t=Inf) - s_multi_h = sprint(show, "text/plain", model_multi_h) - @test contains(s_multi_h, "EarthModel with 2 horizontal earth layers (multilayer)") - @test contains(s_multi_h, "├─ Layer 2: [rho_g=100.0, epsr_g=10.0, mur_g=1.0, t=20.0]") - @test contains(s_multi_h, "└─ Layer 3: [rho_g=200.0, epsr_g=15.0, mur_g=1.0, t=Inf]") - - # Multilayer vertical model - model_multi_v = EarthModel(frequencies, 100.0, 10.0, 1.0, t=Inf, vertical_layers=true) - add!(model_multi_v, frequencies, 200.0, 15.0, 1.0, t=30.0) - s_multi_v = sprint(show, "text/plain", model_multi_v) - @test contains(s_multi_v, "EarthModel with 2 vertical earth layers (multilayer)") - @test contains(s_multi_v, "├─ Layer 2: [rho_g=100.0, epsr_g=10.0, mur_g=1.0, t=Inf]") - @test contains(s_multi_v, "└─ Layer 3: [rho_g=200.0, epsr_g=15.0, mur_g=1.0, t=30.0]") - end - - @testset "DataFrame for EarthModel" begin - frequencies = [50.0] - model = EarthModel(frequencies, 100.0, 10.0, 1.0, t=20.0) - add!(model, frequencies, 200.0, 15.0, 1.0, t=Inf) - - df = DataFrame(model) - @test df isa DataFrame - @test names(df) == ["rho_g", "epsr_g", "mur_g", "thickness"] - @test nrow(df) == 3 - - # Air layer - @test isinf(df.rho_g[1]) - @test df.epsr_g[1] == 1.0 - @test df.mur_g[1] == 1.0 - @test isinf(df.thickness[1]) - - # First earth layer - @test df.rho_g[2] == 100.0 - @test df.epsr_g[2] == 10.0 - @test df.thickness[2] == 20.0 - - # Second earth layer - @test df.rho_g[3] == 200.0 - @test df.epsr_g[3] == 15.0 - @test isinf(df.thickness[3]) - end - - @testset "show for EarthModel" begin - frequencies = [50.0, 60.0] - - @testset "Homogeneous Horizontal" begin - model = EarthModel(frequencies, 100.0, 10.0, 1.0) - str_repr = sprint(show, "text/plain", model) - @test occursin("EarthModel with 1 horizontal earth layer (homogeneous) and 2 frequency samples", str_repr) - @test occursin("└─ Layer 2:", str_repr) - @test occursin("t=Inf", str_repr) - @test occursin("Frequency-dependent model: Constant properties (CP)", str_repr) - end - - @testset "Multilayer Horizontal" begin - model = EarthModel(frequencies, 100.0, 10.0, 1.0, t=20.0) - add!(model, frequencies, 200.0, 15.0, 1.0, t=Inf) - str_repr = sprint(show, "text/plain", model) - @test occursin("EarthModel with 2 horizontal earth layers (multilayer) and 2 frequency samples", str_repr) - @test occursin("├─ Layer 2:", str_repr) - @test occursin("└─ Layer 3:", str_repr) - @test occursin("t=20", str_repr) - end - - @testset "Multilayer Vertical" begin - model = EarthModel(frequencies, 100.0, 10.0, 1.0, t=Inf, vertical_layers=true) - add!(model, frequencies, 200.0, 15.0, 1.0, t=5.0) - str_repr = sprint(show, "text/plain", model) - @test occursin("EarthModel with 2 vertical earth layers (multilayer) and 2 frequency samples", str_repr) - @test occursin("t=5", str_repr) - end - end - - cp = CPEarth() - - freqF = [1e3, 1e4, 1e5] # Vector{Float64} - freqM = measurement.(freqF, 0.1) # Vector{Measurement} - ρF, εF, μF = 100.0, 10.0, 1.0 - ρM, εM, μM = measurement.((ρF, εF, μF), 0.1) - - cases = ( - (freqF, ρF, εF, μF), - (freqM, ρM, εM, μM), - (freqF, ρM, εM, μM), # <- the one that used to blow up - (freqM, ρF, εM, μF), - (freqF, ρF, εM, μM), - (freqM, ρM, εF, μF), - ) - - promT(ρ, ε, μ) = promote_type(typeof(ρ), typeof(ε), typeof(μ)) - - @testset "CPEarth measurify promotion" begin - for (fr, ρ, ε, μ) in cases - ρv, εv, μv = cp(fr, ρ, ε, μ) - - # element types follow the COMMON promoted scalar type T - Texp = promT(ρ, ε, μ) - @test eltype(ρv) === Texp - @test eltype(εv) === Texp - @test eltype(μv) === Texp - - # values still match the right physics - @test all(≈(ρ), ρv) - @test all(≈(ε₀ * ε), εv) - @test all(≈(μ₀ * μ), μv) - end - end - - @testset "Mixed-type constructors & add!" begin - freqsF = [50.0, 60.0, 1000.0] # Vector{Float64} - freqsM = measurement.(freqsF, 0.1) # Vector{Measurement} - ρF, εF, μF = 100.0, 10.0, 1.0 - ρM, εM, μM = measurement.((ρF, εF, μF), 0.2) - - # --- EarthLayer: Float freqs + Measurement scalars (used to recurse) --- - @testset "EarthLayer: freqs Float64, scalars Measurement" begin - layer = EP.EarthLayer(freqsF, ρM, εM, μM, 5.0, EP.CPEarth()) - # Base params keep types (after macro coercion they must share one T) - Texp = promT(ρM, εM, μM) # Measurement{Float64} - @test layer.base_rho_g isa Texp - @test layer.base_epsr_g isa Texp - @test layer.base_mur_g isa Texp - # Frequency-dependent arrays adopt that same T - @test eltype(layer.rho_g) === Texp - @test length(layer.rho_g) == length(freqsF) - @test all(≈(ρM), layer.rho_g) - end - - # --- EarthModel: scalars Measurement, freqs Float64 (lift container) --- - @testset "EarthModel: freqs Float64, scalars Measurement" begin - model = EarthModel(freqsF, ρM, εM, μM; t=20.0) - @test model.freq_dependence isa EP.CPEarth - @test length(model.layers) == 2 - # Layer 2 (earth) carries Measurement T - earth = model.layers[2] - Texp = promT(ρM, εM, μM) - @test earth.base_rho_g isa Texp - @test eltype(earth.rho_g) === Texp - @test all(≈(ρM), earth.rho_g) - end - - # --- EarthModel: scalars Float64, freqs Measurement --- - @testset "EarthModel: freqs Measurement, scalars Float64" begin - model = EarthModel(freqsM, ρF, εF, μF; t=10.0) - @test length(model.layers) == 2 - earth = model.layers[2] - Texp = eltype(freqsM) - @test earth.base_rho_g isa Texp - @test eltype(earth.rho_g) === Texp - @test all(≈(ρF), earth.rho_g) - end - - # --- add!: Measurement model, add Float64 layers (promote to Measurement) --- - @testset "add!: model T=Measurement, add Float64" begin - modelM = EarthModel(freqsF, ρM, εM, μM; t=10.0) - @test eltype(modelM.layers) <: EP.EarthLayer # sanity - # Add deterministic layer; should be coerced to Measurement with zero-σ - add!(modelM, freqsF, 200.0, 15.0, 1.0; t=30.0) - bottom_layer = last(modelM.layers) - @test bottom_layer.base_rho_g isa typeof(ρM) - @test value(bottom_layer.base_rho_g) ≈ 200.0 - @test uncertainty(bottom_layer.base_rho_g) ≈ 0.0 - @test eltype(bottom_layer.rho_g) === typeof(ρM) - @test all(x -> value(x) ≈ 200.0 && uncertainty(x) ≈ 0.0, bottom_layer.rho_g) - end - - # --- add!: Float64 model, add Measurement layers --- - @testset "add!: model T=Float64, add Measurement" begin - modelF = EarthModel(freqsF, ρF, εF, μF; t=25.0) - modelF = add!(modelF, freqsF, ρM, εM, μM; t=12.0) - bottom_layer = last(modelF.layers) - @test bottom_layer.base_rho_g isa typeof(ρM) - @test bottom_layer.base_rho_g ≈ value(ρM) - @test eltype(bottom_layer.rho_g) === typeof(ρM) - @test all(≈(value(ρM)), bottom_layer.rho_g) - # model T=Float64, adding Measurement ⇒ MUST capture widened model - modelF = EarthModel(freqsF, ρF, εF, μF; t=25.0) - modelF = add!(modelF, freqsF, ρM, εM, μM; t=12.0) # <-- capture - @test first(typeof(modelF).parameters) <: Measurement - end - - # --- Keyword thickness as Measurement (forces T=Measurement) --- - @testset "EarthLayer: thickness Measurement" begin - tM = measurement(7.5, 0.3) - layer = EP.EarthLayer(freqsF, ρF, εF, μF, tM, EP.CPEarth()) - # If your method ties a single T across all args, this lifts everything to Measurement - @test isapprox(layer.t, tM) - @test layer.base_rho_g isa promote_type(typeof(ρF), typeof(tM)) - end - end - - @info "EarthProps tests completed." - -end +@testsnippet defs_earthprops begin + # Access internal components for testing + const LCM = LineCableModels + const EP = LCM.EarthProps +end + +@testitem "EarthProps module" setup = [defaults, defs_earthprops] begin + + @testset "FDEM Formulations" begin + @testset "CPEarth" begin + cp_formulation = EP.CPEarth() + @test cp_formulation isa EP.AbstractFDEMFormulation + @test LCM.Commons.get_description(cp_formulation) == "Constant properties (CP)" + end + end + + @testset "CPEarth numerical tests" begin + frequencies = [50.0, 60.0, 1000.0] + base_rho_g = 100.0 + base_epsr_g = 10.0 + base_mur_g = 1.0 + base_kappa_g = 0.5 + formulation = EP.CPEarth() + + @testset "Float64 inputs" begin + rho, epsilon, mu, kappa = formulation(frequencies, base_rho_g, base_epsr_g, base_mur_g, base_kappa_g) + + @test length(rho) == length(frequencies) + @test all(r -> r == base_rho_g, rho) + + @test length(epsilon) == length(frequencies) + @test all(e -> isapprox(e, LCM.Commons.ε₀ * base_epsr_g), epsilon) + + @test length(mu) == length(frequencies) + @test all(m -> isapprox(m, LCM.Commons.μ₀ * base_mur_g), mu) + + @test length(kappa) == length(frequencies) + @test all(k -> isapprox(k, base_kappa_g), kappa) + end + + @testset "Measurement inputs" begin + rho_m = 100.0 ± 5.0 + epsr_m = 10.0 ± 0.5 + mur_m = 1.0 ± 0.01 + kappa_m = 0.5 ± 0.02 + + rho, epsilon, mu, kappa = formulation(frequencies, rho_m, epsr_m, mur_m, kappa_m) + + @test length(rho) == length(frequencies) + @test all(r -> r == rho_m, rho) + + @test length(epsilon) == length(frequencies) + @test all(e -> e.val ≈ (LCM.Commons.ε₀ * epsr_m).val, epsilon) + @test all(e -> e.err ≈ (LCM.Commons.ε₀ * epsr_m).err, epsilon) + + @test length(mu) == length(frequencies) + @test all(m -> m.val ≈ (LCM.Commons.μ₀ * mur_m).val, mu) + @test all(m -> m.err ≈ (LCM.Commons.μ₀ * mur_m).err, mu) + + @test length(kappa) == length(frequencies) + @test all(k -> k.val ≈ kappa_m.val, kappa) + @test all(k -> k.err ≈ kappa_m.err, kappa) + end + end + + @testset "EarthLayer Constructor" begin + frequencies = [50.0, 60.0] + base_rho_g = 100.0 + base_epsr_g = 10.0 + base_mur_g = 1.0 + base_kappa_g = 0.5 + t = 5.0 + formulation = EP.CPEarth() + + layer = EP.EarthLayer(frequencies, base_rho_g, base_epsr_g, base_mur_g, base_kappa_g, t, formulation) + + @test layer.base_rho_g == base_rho_g + @test layer.base_epsr_g == base_epsr_g + @test layer.base_mur_g == base_mur_g + @test layer.base_kappa_g == base_kappa_g + @test layer.t == t + @test length(layer.rho_g) == length(frequencies) + @test all(layer.rho_g .== base_rho_g) + end + + @testset "EarthModel Constructor" begin + frequencies = [50.0, 60.0] + rho_g = 100.0 + epsr_g = 10.0 + mur_g = 1.0 + kappa_g = 0.5 + + @testset "Homogeneous Model" begin + model = EarthModel(frequencies, rho_g, epsr_g, mur_g, kappa_g) + @test length(model.layers) == 2 # Air + 1 earth layer + @test model.vertical_layers == false + @test model.freq_dependence isa EP.CPEarth + @test isinf(model.layers[1].t) # Air layer + @test isinf(model.layers[2].t) # Homogeneous earth + @test model.layers[2].base_rho_g == rho_g + end + + @testset "Finite Thickness Layer" begin + model = EarthModel(frequencies, rho_g, epsr_g, mur_g, kappa_g, t=20.0) + @test length(model.layers) == 2 + @test model.layers[2].t == 20.0 + end + + @testset "Vertical Layers" begin + model = EarthModel(frequencies, rho_g, epsr_g, mur_g, kappa_g, vertical_layers=true) + @test model.vertical_layers == true + end + + @testset "Input Validation" begin + @test_throws AssertionError EarthModel([-50.0], rho_g, epsr_g, mur_g, kappa_g) + @test_throws AssertionError EarthModel(frequencies, -100.0, epsr_g, mur_g, kappa_g) + @test_throws AssertionError EarthModel(frequencies, rho_g, -10.0, mur_g, kappa_g) + @test_throws AssertionError EarthModel(frequencies, rho_g, epsr_g, -1.0, kappa_g) + @test_throws AssertionError EarthModel(frequencies, rho_g, epsr_g, mur_g, -1.0) + @test_throws AssertionError EarthModel(frequencies, rho_g, epsr_g, mur_g, kappa_g, t=-5.0) + end + end + + @testset "add! for EarthModel" begin + frequencies = [50.0, 60.0] + + @testset "Horizontal Layering" begin + model = EarthModel(frequencies, 100.0, 10.0, 1.0, 0.5, t=20.0) + @test length(model.layers) == 2 + + add!(model, frequencies, 200.0, 15.0, 1.0, 1.0, t=50.0) + @test length(model.layers) == 3 + @test model.layers[3].base_rho_g == 200.0 + @test model.layers[3].t == 50.0 + + add!(model, frequencies, 500.0, 20.0, 1.0, 1.0, t=Inf) + @test length(model.layers) == 4 + @test isinf(model.layers[4].t) + + # Test invalid addition + @test_throws ErrorException add!(model, frequencies, 1000.0, 25.0, 1.0, 1.0, t=Inf) + end + + @testset "Input Validation in add!" begin + model = EarthModel(frequencies, 100.0, 10.0, 1.0, 0.5, t=20.0) + @test_throws AssertionError add!(model, [-50.0], 200.0, 15.0, 1.0, 0.5) + @test_throws AssertionError add!(model, frequencies, -200.0, 15.0, 1.0, 0.5) + @test_throws AssertionError add!(model, frequencies, 200.0, -15.0, 1.0, 0.5) + @test_throws AssertionError add!(model, frequencies, 200.0, 15.0, -1.0, 0.5) + @test_throws AssertionError add!(model, frequencies, 200.0, 15.0, 1.0, -0.5) + @test_throws AssertionError add!(model, frequencies, 200.0, 15.0, 1.0, 0.5, t=-5.0) + end + end + + @testset "Consecutive Infinite Layer Checks" begin + frequencies = [50.0] + @testset "Horizontal Model" begin + model = EarthModel(frequencies, 100.0, 10.0, 1.0, 0.5, t=Inf) + # It's an error to add any layer after an infinite one in a horizontal model + @test_throws ErrorException add!(model, frequencies, 200.0, 15.0, 1.0, 0.5, t=10.0) + @test_throws ErrorException add!(model, frequencies, 200.0, 15.0, 1.0, 0.5, t=Inf) + end + + @testset "Vertical Model" begin + # Setup: model with two earth layers, the second being infinite + model = EarthModel(frequencies, 100.0, 10.0, 1.0, 1.0, t=Inf, vertical_layers=true) + add!(model, frequencies, 150.0, 12.0, 1.0, 0.5, t=20.0) # Add one more layer + add!(model, frequencies, 150.0, 12.0, 1.0, 0.5, t=Inf) # Add one more layer, now Inf + + # It's an error to add another infinite layer + @test_throws ErrorException add!(model, frequencies, 200.0, 15.0, 1.0, 0.5, t=Inf) + + # It should not be posssible to add a finite layer after an infinite one + @test_throws ErrorException add!(model, frequencies, 300.0, 20.0, 1.0, 0.5, t=5.0) + @test length(model.layers) == 4 + @test model.layers[4].base_rho_g == 150.0 + @test isinf(model.layers[4].t) # The last layer should still be infinite + end + end + + @testset "show method for EarthModel" begin + frequencies = [50.0] + # Homogeneous model + model_homo = EarthModel(frequencies, 100.0, 10.0, 1.0, 1.0) + s_homo = sprint(show, "text/plain", model_homo) + @test contains(s_homo, "EarthModel with 1 horizontal earth layer (homogeneous)") + @test contains(s_homo, "└─ Layer 2: [rho_g=100.0, epsr_g=10.0, mur_g=1.0, kappa_g=1.0, t=Inf]") + + # Multilayer horizontal model + model_multi_h = EarthModel(frequencies, 100.0, 10.0, 1.0, 0.5, t=20.0) + add!(model_multi_h, frequencies, 200.0, 15.0, 1.0, 0.5, t=Inf) + s_multi_h = sprint(show, "text/plain", model_multi_h) + @test contains(s_multi_h, "EarthModel with 2 horizontal earth layers (multilayer)") + @test contains(s_multi_h, "├─ Layer 2: [rho_g=100.0, epsr_g=10.0, mur_g=1.0, kappa_g=0.5, t=20.0]") + @test contains(s_multi_h, "└─ Layer 3: [rho_g=200.0, epsr_g=15.0, mur_g=1.0, kappa_g=0.5, t=Inf]") + + # Multilayer vertical model + model_multi_v = EarthModel(frequencies, 100.0, 10.0, 1.0, 0.5, t=Inf, vertical_layers=true) + add!(model_multi_v, frequencies, 200.0, 15.0, 1.0, 0.5, t=30.0) + s_multi_v = sprint(show, "text/plain", model_multi_v) + @test contains(s_multi_v, "EarthModel with 2 vertical earth layers (multilayer)") + @test contains(s_multi_v, "├─ Layer 2: [rho_g=100.0, epsr_g=10.0, mur_g=1.0, kappa_g=0.5, t=Inf]") + @test contains(s_multi_v, "└─ Layer 3: [rho_g=200.0, epsr_g=15.0, mur_g=1.0, kappa_g=0.5, t=30.0]") + end + + @testset "DataFrame for EarthModel" begin + frequencies = [50.0] + model = EarthModel(frequencies, 100.0, 10.0, 1.0, 0.5, t=20.0) + add!(model, frequencies, 200.0, 15.0, 1.0, 0.5, t=Inf) + + df = DataFrame(model) + @test df isa DataFrame + @test names(df) == ["rho_g", "epsr_g", "mur_g", "kappa_g", "thickness"] + @test nrow(df) == 3 + + # Air layer + @test isinf(df.rho_g[1]) + @test df.epsr_g[1] == 1.0 + @test df.mur_g[1] == 1.0 + @test df.kappa_g[1] == 0.024 + @test isinf(df.thickness[1]) + + # First earth layer + @test df.rho_g[2] == 100.0 + @test df.epsr_g[2] == 10.0 + @test df.kappa_g[2] == 0.5 + @test df.thickness[2] == 20.0 + + # Second earth layer + @test df.rho_g[3] == 200.0 + @test df.epsr_g[3] == 15.0 + @test df.kappa_g[3] == 0.5 + @test isinf(df.thickness[3]) + end + + @testset "show for EarthModel" begin + frequencies = [50.0, 60.0] + + @testset "Homogeneous Horizontal" begin + model = EarthModel(frequencies, 100.0, 10.0, 1.0, 1.0) + str_repr = sprint(show, "text/plain", model) + @test occursin("EarthModel with 1 horizontal earth layer (homogeneous) and 2 frequency samples", str_repr) + @test occursin("└─ Layer 2:", str_repr) + @test occursin("t=Inf", str_repr) + @test occursin("Frequency-dependent model: Constant properties (CP)", str_repr) + end + + @testset "Multilayer Horizontal" begin + model = EarthModel(frequencies, 100.0, 10.0, 1.0, 1.0, t=20.0) + add!(model, frequencies, 200.0, 15.0, 1.0, 0.5, t=Inf) + str_repr = sprint(show, "text/plain", model) + @test occursin("EarthModel with 2 horizontal earth layers (multilayer) and 2 frequency samples", str_repr) + @test occursin("├─ Layer 2:", str_repr) + @test occursin("└─ Layer 3:", str_repr) + @test occursin("t=20", str_repr) + end + + @testset "Multilayer Vertical" begin + model = EarthModel(frequencies, 100.0, 10.0, 1.0, 1.0, t=Inf, vertical_layers=true) + add!(model, frequencies, 200.0, 15.0, 1.0, 0.5, t=5.0) + str_repr = sprint(show, "text/plain", model) + @test occursin("EarthModel with 2 vertical earth layers (multilayer) and 2 frequency samples", str_repr) + @test occursin("t=5", str_repr) + end + end + + cp = CPEarth() + + freqF = [1e3, 1e4, 1e5] # Vector{Float64} + freqM = measurement.(freqF, 0.1) # Vector{Measurement} + ρF, εF, μF, κF = 100.0, 10.0, 1.0, 0.5 + ρM, εM, μM, κM = measurement.((ρF, εF, μF, κF), 0.1) + + cases = ( + (freqF, ρF, εF, μF, κF), + (freqM, ρM, εM, μM, κM), + (freqF, ρM, εM, μM, κM), # <- the one that used to blow up + (freqM, ρF, εM, μF, κF), + (freqF, ρF, εM, μM, κM), + (freqM, ρM, εF, μF, κF), + ) + + promT(ρ, ε, μ, κ) = promote_type(typeof(ρ), typeof(ε), typeof(μ), typeof(κ)) + + @testset "CPEarth measurify promotion" begin + for (fr, ρ, ε, μ, κ) in cases + ρv, εv, μv, κv = cp(fr, ρ, ε, μ, κ) + + # element types follow the COMMON promoted scalar type T + Texp = promT(ρ, ε, μ, κ) + @test eltype(ρv) === Texp + @test eltype(εv) === Texp + @test eltype(μv) === Texp + @test eltype(κv) === Texp + + # values still match the right physics + @test all(≈(ρ), ρv) + @test all(≈(ε₀ * ε), εv) + @test all(≈(μ₀ * μ), μv) + @test all(≈(κ), κv) + end + end + + @testset "Mixed-type constructors & add!" begin + freqsF = [50.0, 60.0, 1000.0] # Vector{Float64} + freqsM = measurement.(freqsF, 0.1) # Vector{Measurement} + ρF, εF, μF, κF = 100.0, 10.0, 1.0, 0.5 + ρM, εM, μM, κM = measurement.((ρF, εF, μF, κF), 0.2) + + # --- EarthLayer: Float freqs + Measurement scalars (used to recurse) --- + @testset "EarthLayer: freqs Float64, scalars Measurement" begin + layer = EP.EarthLayer(freqsF, ρM, εM, μM, κM, 5.0, EP.CPEarth()) + # Base params keep types (after macro coercion they must share one T) + Texp = promT(ρM, εM, μM, κM) # Measurement{Float64} + @test layer.base_rho_g isa Texp + @test layer.base_epsr_g isa Texp + @test layer.base_mur_g isa Texp + @test layer.base_kappa_g isa Texp + # Frequency-dependent arrays adopt that same T + @test eltype(layer.rho_g) === Texp + @test length(layer.rho_g) == length(freqsF) + @test all(≈(ρM), layer.rho_g) + end + + # --- EarthModel: scalars Measurement, freqs Float64 (lift container) --- + @testset "EarthModel: freqs Float64, scalars Measurement" begin + model = EarthModel(freqsF, ρM, εM, μM, κM; t=20.0) + @test model.freq_dependence isa EP.CPEarth + @test length(model.layers) == 2 + # Layer 2 (earth) carries Measurement T + earth = model.layers[2] + Texp = promT(ρM, εM, μM, κM) + @test earth.base_rho_g isa Texp + @test eltype(earth.rho_g) === Texp + @test all(≈(ρM), earth.rho_g) + end + + # --- EarthModel: scalars Float64, freqs Measurement --- + @testset "EarthModel: freqs Measurement, scalars Float64" begin + model = EarthModel(freqsM, ρF, εF, μF, κF; t=10.0) + @test length(model.layers) == 2 + earth = model.layers[2] + Texp = eltype(freqsM) + @test earth.base_rho_g isa Texp + @test eltype(earth.rho_g) === Texp + @test all(≈(ρF), earth.rho_g) + end + + # --- add!: Measurement model, add Float64 layers (promote to Measurement) --- + @testset "add!: model T=Measurement, add Float64" begin + modelM = EarthModel(freqsF, ρM, εM, μM, κM; t=10.0) + @test eltype(modelM.layers) <: EP.EarthLayer # sanity + # Add deterministic layer; should be coerced to Measurement with zero-σ + add!(modelM, freqsF, 200.0, 15.0, 1.0, 0.5; t=30.0) + bottom_layer = last(modelM.layers) + @test bottom_layer.base_rho_g isa typeof(ρM) + @test value(bottom_layer.base_rho_g) ≈ 200.0 + @test uncertainty(bottom_layer.base_rho_g) ≈ 0.0 + @test eltype(bottom_layer.rho_g) === typeof(ρM) + @test all(x -> value(x) ≈ 200.0 && uncertainty(x) ≈ 0.0, bottom_layer.rho_g) + end + + # --- add!: Float64 model, add Measurement layers --- + @testset "add!: model T=Float64, add Measurement" begin + modelF = EarthModel(freqsF, ρF, εF, μF, κF; t=25.0) + modelF = add!(modelF, freqsF, ρM, εM, μM, κM; t=12.0) + bottom_layer = last(modelF.layers) + @test bottom_layer.base_rho_g isa typeof(ρM) + @test bottom_layer.base_rho_g ≈ value(ρM) + @test eltype(bottom_layer.rho_g) === typeof(ρM) + @test all(≈(value(ρM)), bottom_layer.rho_g) + # model T=Float64, adding Measurement ⇒ MUST capture widened model + modelF = EarthModel(freqsF, ρF, εF, μF, κF; t=25.0) + modelF = add!(modelF, freqsF, ρM, εM, μM, κM; t=12.0) # <-- capture + @test first(typeof(modelF).parameters) <: Measurement + end + + # --- Keyword thickness as Measurement (forces T=Measurement) --- + @testset "EarthLayer: thickness Measurement" begin + tM = measurement(7.5, 0.3) + layer = EP.EarthLayer(freqsF, ρF, εF, μF, κF, tM, EP.CPEarth()) + # If your method ties a single T across all args, this lifts everything to Measurement + @test isapprox(layer.t, tM) + @test layer.base_rho_g isa promote_type(typeof(ρF), typeof(tM)) + end + end + + @info "EarthProps tests completed." + +end diff --git a/test/runtests.jl b/test/runtests.jl index 0bd85fdf..477f6b18 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,66 +1,67 @@ -using LineCableModels -using Test -using TestItemRunner - -@testsnippet defaults begin - const TEST_TOL = 1e-8 - using Measurements - using Measurements: measurement, uncertainty, value - using DataFrames - using LineCableModels - using LineCableModels.Commons - using LineCableModels.Utils - using LineCableModels.Materials - using LineCableModels.DataModel - using LineCableModels.EarthProps - using LineCableModels.DataModel.BaseParams - using LineCableModels.Engine - using LineCableModels.Engine.FEM - using LineCableModels.ImportExport -end - -@testsnippet defs_materials begin - materials = MaterialsLibrary(add_defaults = true) - copper_props = Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393) - aluminum_props = Material(2.8264e-8, 1.0, 1.0, 20.0, 0.00429) - insulator_props = Material(1e14, 2.3, 1.0, 20.0, 0.0) - semicon_props = Material(1000.0, 1000.0, 1.0, 20.0, 0.0) -end - -@testsnippet cable_system_export begin - - cables_library = CablesLibrary() - @show file_name = joinpath(@__DIR__, "cable_test.json") - cables_library = load!(cables_library, file_name = file_name) - - # Retrieve the reloaded design - cable_design = collect(values(cables_library.data))[1] - x0, y0 = 0.0, -1.0 - xa, ya, xb, yb, xc, yc = trifoil_formation(x0, y0, 0.035) - - # Initialize the `LineCableSystem` with the first cable (phase A): - cablepos = CablePosition(cable_design, xa, ya, - Dict("core" => 1, "sheath" => 0, "jacket" => 0)) - cable_system = LineCableSystem("test_cable_sys", 1000.0, cablepos) - - # Add remaining cables (phases B and C): - add!(cable_system, cable_design, xb, yb, - Dict("core" => 2, "sheath" => 0, "jacket" => 0)) - add!(cable_system, cable_design, xc, yc, - Dict("core" => 3, "sheath" => 0, "jacket" => 0)) - - freqs = [50.0] - earth_props = EarthModel(freqs, 100.0, 10.0, 1.0) - num_phases = cable_system.num_phases - - # Create minimal mock objects for the other required arguments - problem_atp = LineParametersProblem( - cable_system, - temperature = 20.0, # Operating temperature - earth_props = earth_props, - frequencies = freqs, # Frequency for the analysis - ) - -end - -@run_package_tests(verbose = true, filter=ti->!(:skipci in ti.tags)) +using LineCableModels +using Test +using TestItemRunner + +@testsnippet defaults begin + const TEST_TOL = 1e-8 + using Measurements + using Measurements: measurement, uncertainty, value + using DataFrames + using LineCableModels + using LineCableModels.Commons + using LineCableModels.Utils + using LineCableModels.Materials + using LineCableModels.DataModel + using LineCableModels.EarthProps + using LineCableModels.DataModel.BaseParams + using LineCableModels.Engine + using LineCableModels.Engine.FEM + using LineCableModels.ImportExport +end + +@testsnippet defs_materials begin + materials = MaterialsLibrary(add_defaults = true) + copper_props = Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393, 401.0) + aluminum_props = Material(2.8264e-8, 1.0, 1.0, 20.0, 0.00429, 237.0) + insulator_props = Material(1e14, 2.3, 1.0, 20.0, 0.0, 0.5) + semicon_props = Material(1000.0, 1000.0, 1.0, 20.0, 0.0, 148.0) +end + +@testsnippet cable_system_export begin + + cables_library = CablesLibrary() + @show file_name = joinpath(@__DIR__, "cable_test.json") + display("Loading cable design from JSON file: $file_name") + cables_library = load!(cables_library, file_name = file_name) + + # Retrieve the reloaded design + cable_design = collect(values(cables_library.data))[1] + x0, y0 = 0.0, -1.0 + xa, ya, xb, yb, xc, yc = trifoil_formation(x0, y0, 0.035) + + # Initialize the `LineCableSystem` with the first cable (phase A): + cablepos = CablePosition(cable_design, xa, ya, + Dict("core" => 1, "sheath" => 0, "jacket" => 0)) + cable_system = LineCableSystem("test_cable_sys", 1000.0, cablepos) + + # Add remaining cables (phases B and C): + add!(cable_system, cable_design, xb, yb, + Dict("core" => 2, "sheath" => 0, "jacket" => 0)) + add!(cable_system, cable_design, xc, yc, + Dict("core" => 3, "sheath" => 0, "jacket" => 0)) + + freqs = [50.0] + earth_props = EarthModel(freqs, 100.0, 10.0, 1.0, 1.0) + num_phases = cable_system.num_phases + + # Create minimal mock objects for the other required arguments + problem_atp = LineParametersProblem( + cable_system, + temperature = 20.0, # Operating temperature + earth_props = earth_props, + frequencies = freqs, # Frequency for the analysis + ) + +end + +# @run_package_tests(verbose = true, filter=ti->!(:skipci in ti.tags)) diff --git a/test/test_tutorial1.jl b/test/test_tutorial1.jl index 57718cf8..aac37ed1 100644 --- a/test/test_tutorial1.jl +++ b/test/test_tutorial1.jl @@ -1,161 +1,161 @@ -@testitem "examples/tutorial1.jl tests" setup = [defaults] begin - - # Helpers - function material_approx_equal(m::Material, rho, eps_r, mu_r, T0, alpha; atol=1e-12, rtol=1e-8) - return isapprox(m.rho, rho; atol=atol, rtol=rtol) && - isapprox(m.eps_r, eps_r; atol=atol, rtol=rtol) && - isapprox(m.mu_r, mu_r; atol=atol, rtol=rtol) && - isapprox(m.T0, T0; atol=atol, rtol=rtol) && - isapprox(m.alpha, alpha; atol=atol, rtol=rtol) - end - - @testset "initialize and inspect" begin - materials = MaterialsLibrary() # default initialization as in the tutorial - @test materials !== nothing - - # DataFrame conversion - df = DataFrame(materials) - @test isa(df, DataFrame) - @test nrow(df) >= 0 # should be defined (>=0); more specific tests below - - # Check expected columns if present - expected = ["name", "rho", "eps_r", "mu_r", "T0", "alpha"] - @test all(x -> x in string.(names(df)), expected) - end - - @testset "add materials from tutorial" begin - materials = MaterialsLibrary(add_defaults=false) # start clean for deterministic tests - - # Define tutorial materials (subset representative of file) - copper_corrected = Material(1.835e-8, 1.0, 0.999994, 20.0, 0.00393) - aluminum_corrected = Material(3.03e-8, 1.0, 0.999994, 20.0, 0.00403) - epr = Material(1e15, 3.0, 1.0, 20.0, 0.005) - pvc = Material(1e15, 8.0, 1.0, 20.0, 0.1) - - add!(materials, "copper_corrected", copper_corrected) - add!(materials, "aluminum_corrected", aluminum_corrected) - add!(materials, "epr", epr) - add!(materials, "pvc", pvc) - - # Verify that keys exist - for name in ("copper_corrected", "aluminum_corrected", "epr", "pvc") - @test haskey(materials, name) - end - - # DataFrame contains the names - df = DataFrame(materials) - @test "copper_corrected" in df.name - @test "epr" in df.name - end - - @testset "remove duplicate" begin - materials = MaterialsLibrary(add_defaults=false) - epr = Material(1e15, 3.0, 1.0, 20.0, 0.005) - add!(materials, "epr", epr) - - # Add duplicate and then remove it - add!(materials, "epr_dupe", epr) - @test haskey(materials, "epr_dupe") - delete!(materials, "epr_dupe") - @test !haskey(materials, "epr_dupe") - end - - @testset "save and load round-trip (temp file)" begin - materials = MaterialsLibrary(add_defaults=false) - - # Add a small set of materials - copper_corrected = Material(1.835e-8, 1.0, 0.999994, 20.0, 0.00393) - epr = Material(1e15, 3.0, 1.0, 20.0, 0.005) - add!(materials, "copper_corrected", copper_corrected) - add!(materials, "epr", epr) - - tmpfile = tempname() * ".json" - try - # Save to temporary file - save(materials, file_name=tmpfile) - @test isfile(tmpfile) - - # Load into a fresh library - loaded = MaterialsLibrary(add_defaults=false) - load!(loaded, file_name=tmpfile) - - # Keys present after load - @test haskey(loaded, "copper_corrected") - @test haskey(loaded, "epr") - - # Retrieve and compare properties - copper_loaded = get(loaded, "copper_corrected") - @test isa(copper_loaded, Material) - @test material_approx_equal(copper_loaded, 1.835e-8, 1.0, 0.999994, 20.0, 0.00393) - - epr_loaded = get(loaded, "epr") - @test isa(epr_loaded, Material) - @test material_approx_equal(epr_loaded, 1e15, 3.0, 1.0, 20.0, 0.005) - - finally - isfile(tmpfile) && rm(tmpfile) - end - end - - @testset "error handling" begin - # Fresh empty library (no defaults) for deterministic error behavior - empty_lib = MaterialsLibrary(add_defaults=false) - - # get on non-existent key should display alert - @test get(empty_lib, "non_existent_material") === nothing - - - # delete! on non-existent key should throw KeyError - @test_throws KeyError delete!(empty_lib, "non_existent_material") - - # load! from a non-existent file should throw an I/O-related error (SystemError / IOError) - bad_file_lib = MaterialsLibrary(add_defaults=false) - @test_throws Exception load!(bad_file_lib, file_name="this_file_should_not_exist_hopefully_0123456789.json") - end - - @testset "integration-like workflow (safe, uses temp files)" begin - # Recreate the main tutorial workflow but using temporary save path - materials = MaterialsLibrary(add_defaults=false) - - # Add the full tutorial list used in examples (representative) - add!(materials, "copper_corrected", Material(1.835e-8, 1.0, 0.999994, 20.0, 0.00393)) - add!(materials, "aluminum_corrected", Material(3.03e-8, 1.0, 0.999994, 20.0, 0.00403)) - add!(materials, "lead", Material(21.4e-8, 1.0, 0.999983, 20.0, 0.00400)) - add!(materials, "steel", Material(13.8e-8, 1.0, 300.0, 20.0, 0.00450)) - add!(materials, "bronze", Material(3.5e-8, 1.0, 1.0, 20.0, 0.00300)) - add!(materials, "stainless_steel", Material(70.0e-8, 1.0, 500.0, 20.0, 0.0)) - add!(materials, "epr", Material(1e15, 3.0, 1.0, 20.0, 0.005)) - add!(materials, "pvc", Material(1e15, 8.0, 1.0, 20.0, 0.1)) - add!(materials, "laminated_paper", Material(1e15, 2.8, 1.0, 20.0, 0.0)) - add!(materials, "carbon_pe", Material(0.06, 1e3, 1.0, 20.0, 0.0)) - add!(materials, "conductive_paper", Material(18.5, 8.6, 1.0, 20.0, 0.0)) - - # Duplicate add and delete - add!(materials, "epr_dupe", get(materials, "epr")) - @test haskey(materials, "epr_dupe") - delete!(materials, "epr_dupe") - @test !haskey(materials, "epr_dupe") - - tmpfile = tempname() * ".json" - try - save(materials, file_name=tmpfile) - @test isfile(tmpfile) - - reloaded = MaterialsLibrary(add_defaults=false) - load!(reloaded, file_name=tmpfile) - - # verify a representative sample of materials exists after reload - for name in ("copper_corrected", "pvc", "stainless_steel") - @test haskey(reloaded, name) - end - - # Verify copper properties after reload - copper = get(reloaded, "copper_corrected") - @test material_approx_equal(copper, 1.835e-8, 1.0, 0.999994, 20.0, 0.00393) - - finally - isfile(tmpfile) && rm(tmpfile) - end - end - +@testitem "examples/tutorial1.jl tests" setup = [defaults] begin + + # Helpers + function material_approx_equal(m::Material, rho, eps_r, mu_r, T0, alpha; atol=1e-12, rtol=1e-8) + return isapprox(m.rho, rho; atol=atol, rtol=rtol) && + isapprox(m.eps_r, eps_r; atol=atol, rtol=rtol) && + isapprox(m.mu_r, mu_r; atol=atol, rtol=rtol) && + isapprox(m.T0, T0; atol=atol, rtol=rtol) && + isapprox(m.alpha, alpha; atol=atol, rtol=rtol) + end + + @testset "initialize and inspect" begin + materials = MaterialsLibrary() # default initialization as in the tutorial + @test materials !== nothing + + # DataFrame conversion + df = DataFrame(materials) + @test isa(df, DataFrame) + @test nrow(df) >= 0 # should be defined (>=0); more specific tests below + + # Check expected columns if present + expected = ["name", "rho", "eps_r", "mu_r", "T0", "alpha", "kappa"] + @test all(x -> x in string.(names(df)), expected) + end + + @testset "add materials from tutorial" begin + materials = MaterialsLibrary(add_defaults=false) # start clean for deterministic tests + + # Define tutorial materials (subset representative of file) + copper_corrected = Material(1.835e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0) + aluminum_corrected = Material(3.03e-8, 1.0, 0.999994, 20.0, 0.00403, 237.0) + epr = Material(1e15, 3.0, 1.0, 20.0, 0.005, 0.2) + pvc = Material(1e15, 8.0, 1.0, 20.0, 0.1, 0.14) + + add!(materials, "copper_corrected", copper_corrected) + add!(materials, "aluminum_corrected", aluminum_corrected) + add!(materials, "epr", epr) + add!(materials, "pvc", pvc) + + # Verify that keys exist + for name in ("copper_corrected", "aluminum_corrected", "epr", "pvc") + @test haskey(materials, name) + end + + # DataFrame contains the names + df = DataFrame(materials) + @test "copper_corrected" in df.name + @test "epr" in df.name + end + + @testset "remove duplicate" begin + materials = MaterialsLibrary(add_defaults=false) + epr = Material(1e15, 3.0, 1.0, 20.0, 0.005, 0.2) + add!(materials, "epr", epr) + + # Add duplicate and then remove it + add!(materials, "epr_dupe", epr) + @test haskey(materials, "epr_dupe") + delete!(materials, "epr_dupe") + @test !haskey(materials, "epr_dupe") + end + + @testset "save and load round-trip (temp file)" begin + materials = MaterialsLibrary(add_defaults=false) + + # Add a small set of materials + copper_corrected = Material(1.835e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0) + epr = Material(1e15, 3.0, 1.0, 20.0, 0.005, 0.2) + add!(materials, "copper_corrected", copper_corrected) + add!(materials, "epr", epr) + + tmpfile = tempname() * ".json" + try + # Save to temporary file + save(materials, file_name=tmpfile) + @test isfile(tmpfile) + + # Load into a fresh library + loaded = MaterialsLibrary(add_defaults=false) + load!(loaded, file_name=tmpfile) + + # Keys present after load + @test haskey(loaded, "copper_corrected") + @test haskey(loaded, "epr") + + # Retrieve and compare properties + copper_loaded = get(loaded, "copper_corrected") + @test isa(copper_loaded, Material) + @test material_approx_equal(copper_loaded, 1.835e-8, 1.0, 0.999994, 20.0, 0.00393) + + epr_loaded = get(loaded, "epr") + @test isa(epr_loaded, Material) + @test material_approx_equal(epr_loaded, 1e15, 3.0, 1.0, 20.0, 0.005) + + finally + isfile(tmpfile) && rm(tmpfile) + end + end + + @testset "error handling" begin + # Fresh empty library (no defaults) for deterministic error behavior + empty_lib = MaterialsLibrary(add_defaults=false) + + # get on non-existent key should display alert + @test get(empty_lib, "non_existent_material") === nothing + + + # delete! on non-existent key should throw KeyError + @test_throws KeyError delete!(empty_lib, "non_existent_material") + + # load! from a non-existent file should throw an I/O-related error (SystemError / IOError) + bad_file_lib = MaterialsLibrary(add_defaults=false) + @test_throws Exception load!(bad_file_lib, file_name="this_file_should_not_exist_hopefully_0123456789.json") + end + + @testset "integration-like workflow (safe, uses temp files)" begin + # Recreate the main tutorial workflow but using temporary save path + materials = MaterialsLibrary(add_defaults=false) + + # Add the full tutorial list used in examples (representative) + add!(materials, "copper_corrected", Material(1.835e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0)) + add!(materials, "aluminum_corrected", Material(3.03e-8, 1.0, 0.999994, 20.0, 0.00403, 237.0)) + add!(materials, "lead", Material(21.4e-8, 1.0, 0.999983, 20.0, 0.00400, 35.0)) + add!(materials, "steel", Material(13.8e-8, 1.0, 300.0, 20.0, 0.00450, 45.0)) + add!(materials, "bronze", Material(3.5e-8, 1.0, 1.0, 20.0, 0.00300, 108.0)) + add!(materials, "stainless_steel", Material(70.0e-8, 1.0, 500.0, 20.0, 0.0, 25.0)) + add!(materials, "epr", Material(1e15, 3.0, 1.0, 20.0, 0.005, 0.2)) + add!(materials, "pvc", Material(1e15, 8.0, 1.0, 20.0, 0.1, 0.14)) + add!(materials, "laminated_paper", Material(1e15, 2.8, 1.0, 20.0, 0.0, 90.0)) + add!(materials, "carbon_pe", Material(0.06, 1e3, 1.0, 20.0, 0.0, 1.0)) + add!(materials, "conductive_paper", Material(18.5, 8.6, 1.0, 20.0, 0.0, 150.0)) + + # Duplicate add and delete + add!(materials, "epr_dupe", get(materials, "epr")) + @test haskey(materials, "epr_dupe") + delete!(materials, "epr_dupe") + @test !haskey(materials, "epr_dupe") + + tmpfile = tempname() * ".json" + try + save(materials, file_name=tmpfile) + @test isfile(tmpfile) + + reloaded = MaterialsLibrary(add_defaults=false) + load!(reloaded, file_name=tmpfile) + + # verify a representative sample of materials exists after reload + for name in ("copper_corrected", "pvc", "stainless_steel") + @test haskey(reloaded, name) + end + + # Verify copper properties after reload + copper = get(reloaded, "copper_corrected") + @test material_approx_equal(copper, 1.835e-8, 1.0, 0.999994, 20.0, 0.00393) + + finally + isfile(tmpfile) && rm(tmpfile) + end + end + end \ No newline at end of file diff --git a/test/test_tutorial2.jl b/test/test_tutorial2.jl index dddb0919..ccb1bb46 100644 --- a/test/test_tutorial2.jl +++ b/test/test_tutorial2.jl @@ -1,237 +1,237 @@ -@testitem "examples/tutorial2.jl tests" setup = [defaults] begin - # Replicate the setup from the tutorial - materials = MaterialsLibrary(add_defaults=true) - - # Cable dimensions from the tutorial - num_co_wires = 61 - num_sc_wires = 49 - d_core = 38.1e-3 - d_w = 4.7e-3 - t_sc_in = 0.6e-3 - t_ins = 8e-3 - t_sc_out = 0.3e-3 - d_ws = 0.95e-3 - t_cut = 0.1e-3 - w_cut = 10e-3 - t_wbt = 0.3e-3 - t_sct = 0.3e-3 - t_alt = 0.15e-3 - t_pet = 0.05e-3 - t_jac = 2.4e-3 - - # Test Core and Main Insulation construction - @testset "core and main insulation" begin - material_al = get(materials, "aluminum") - @test material_al isa LineCableModels.Materials.Material - core = ConductorGroup(WireArray(0.0, Diameter(d_w), 1, 0.0, material_al)) - add!(core, WireArray, Diameter(d_w), 6, 15.0, material_al) - add!(core, WireArray, Diameter(d_w), 12, 13.5, material_al) - add!(core, WireArray, Diameter(d_w), 18, 12.5, material_al) - add!(core, WireArray, Diameter(d_w), 24, 11.0, material_al) - - @test length(core.layers) == 5 - @test isapprox(core.radius_ext * 2, 0.0423, atol=1e-4) - - material_poly = get(materials, "polyacrylate") - material_sc1 = get(materials, "semicon1") - material_pe = get(materials, "pe") - material_sc2 = get(materials, "semicon2") - - main_insu = InsulatorGroup(Semicon(core, Thickness(t_sct), material_poly)) - add!(main_insu, Semicon, Thickness(t_sc_in), material_sc1) - add!(main_insu, Insulator, Thickness(t_ins), material_pe) - add!(main_insu, Semicon, Thickness(t_sc_out), material_sc2) - add!(main_insu, Semicon, Thickness(t_sct), material_poly) - - @test length(main_insu.layers) == 5 - - core_cc = CableComponent("core", core, main_insu) - @test core_cc.id == "core" - end - - # Build the full cable design step-by-step as in the tutorial - # This also tests the constructors and `add!` methods implicitly - material_al = get(materials, "aluminum") - core = ConductorGroup(WireArray(0.0, Diameter(d_w), 1, 0.0, material_al)) - add!(core, WireArray, Diameter(d_w), 6, 15.0, material_al) - add!(core, WireArray, Diameter(d_w), 12, 13.5, material_al) - add!(core, WireArray, Diameter(d_w), 18, 12.5, material_al) - add!(core, WireArray, Diameter(d_w), 24, 11.0, material_al) - - material_poly = get(materials, "polyacrylate") - material_sc1 = get(materials, "semicon1") - material_pe = get(materials, "pe") - material_sc2 = get(materials, "semicon2") - main_insu = InsulatorGroup(Semicon(core, Thickness(t_sct), material_poly)) - add!(main_insu, Semicon, Thickness(t_sc_in), material_sc1) - add!(main_insu, Insulator, Thickness(t_ins), material_pe) - add!(main_insu, Semicon, Thickness(t_sc_out), material_sc2) - add!(main_insu, Semicon, Thickness(t_sct), material_poly) - - core_cc = CableComponent("core", core, main_insu) - - cable_id = "18kV_1000mm2" - datasheet_info = NominalData( - designation_code="NA2XS(FL)2Y", U0=18.0, U=30.0, - conductor_cross_section=1000.0, screen_cross_section=35.0, - resistance=0.0291, capacitance=0.39, inductance=0.3 - ) - cable_design = CableDesign(cable_id, core_cc, nominal_data=datasheet_info) - - @test length(cable_design.components) == 1 - @test cable_design.cable_id == cable_id - - material_cu = get(materials, "copper") - lay_ratio = 10.0 - screen_con = ConductorGroup(WireArray(main_insu, Diameter(d_ws), num_sc_wires, lay_ratio, material_cu)) - add!(screen_con, Strip, Thickness(t_cut), w_cut, lay_ratio, material_cu) - screen_insu = InsulatorGroup(Semicon(screen_con, Thickness(t_wbt), material_poly)) - sheath_cc = CableComponent("sheath", screen_con, screen_insu) - add!(cable_design, sheath_cc) - - @test length(cable_design.components) == 2 - @test cable_design.components[2].id == "sheath" - - jacket_con = ConductorGroup(Tubular(screen_insu, Thickness(t_alt), material_al)) - jacket_insu = InsulatorGroup(Insulator(jacket_con, Thickness(t_pet), material_pe)) - add!(jacket_insu, Insulator, Thickness(t_jac), material_pe) - add!(cable_design, "jacket", jacket_con, jacket_insu) - - @test length(cable_design.components) == 3 - @test cable_design.components[3].id == "jacket" - - @testset "calculated parameters vs hard-coded values" begin - core_df = DataFrame(cable_design, :baseparams) - - # Hard-coded values from the tutorial - expected_R = 0.0275677 - expected_L = 0.287184 - expected_C = 0.413357 - - # Test R - computed_R = core_df[core_df.parameter.=="R [Ω/km]", :computed][1] - @test isapprox(computed_R, expected_R, atol=1e-5) - - # Test L - computed_L = core_df[core_df.parameter.=="L [mH/km]", :computed][1] - @test isapprox(computed_L, expected_L, atol=1e-5) - - # Test C - computed_C = core_df[core_df.parameter.=="C [μF/km]", :computed][1] - @test isapprox(computed_C, expected_C, atol=1e-5) - end - - @testset "dataframes and library" begin - # Test that DataFrame constructors do not throw errors - @test DataFrame(cable_design, :components) isa DataFrame - @test DataFrame(cable_design, :detailed) isa DataFrame - - # Test CablesLibrary functionality - library = CablesLibrary() - add!(library, cable_design) - @test length(library) == 1 - @test DataFrame(library) isa DataFrame - - # Test saving and loading - mktempdir(joinpath(@__DIR__)) do temp_dir - output_file = joinpath(temp_dir, "cables_library.json") - save(library, file_name=output_file) - @test isfile(output_file) - - loaded_library = CablesLibrary() - load!(loaded_library, file_name=output_file) - @test length(loaded_library) == 1 - @test loaded_library.data[cable_id].cable_id == cable_id - end - end - - @testset "cable system and export" begin - f = 10.0 .^ range(0, stop=6, length=10) - earth_params = EarthModel(f, 100.0, 10.0, 1.0) - @test DataFrame(earth_params) isa DataFrame - - x0, y0 = 0.0, -1.0 - xa, ya, xb, yb, xc, yc = trifoil_formation(x0, y0, 0.035) - - cablepos = CablePosition(cable_design, xa, ya, Dict("core" => 1, "sheath" => 0, "jacket" => 0)) - cable_system = LineCableSystem("18kV_1000mm2_trifoil", 1000.0, cablepos) - add!(cable_system, cable_design, xb, yb, Dict("core" => 2, "sheath" => 0, "jacket" => 0)) - add!(cable_system, cable_design, xc, yc, Dict("core" => 3, "sheath" => 0, "jacket" => 0)) - - @test length(cable_system.cables) == 3 - @test DataFrame(cable_system) isa DataFrame - - # Test PSCAD export - mktempdir(joinpath(@__DIR__)) do temp_dir - output_file = joinpath(temp_dir, "$(cable_system.system_id)_export.pscx") - result_path = export_data(:pscad, cable_system, earth_params, file_name=output_file) - @test !isnothing(result_path) - @test isfile(result_path) - # Check if file has content - @test filesize(result_path) > 0 - end - end - - @testset "preview functions" begin - # Test that preview functions execute without error - # Note: This does not check the plot content, only that they don't crash. - @test preview(cable_design, display_plot=false) isa Any - - f = 10.0 .^ range(0, stop=6, length=10) - earth_params = EarthModel(f, 100.0, 10.0, 1.0) - x0, y0 = 0.0, -1.0 - xa, ya, xb, yb, xc, yc = trifoil_formation(x0, y0, 0.035) - cablepos = CablePosition(cable_design, xa, ya, Dict("core" => 1, "sheath" => 0, "jacket" => 0)) - cable_system = LineCableSystem("18kV_1000mm2_trifoil", 1000.0, cablepos) - add!(cable_system, cable_design, xb, yb, Dict("core" => 2, "sheath" => 0, "jacket" => 0)) - add!(cable_system, cable_design, xc, yc, Dict("core" => 3, "sheath" => 0, "jacket" => 0)) - - end - - @testset "user error handling and robustness" begin - # Test invalid material request - @test get(materials, "unobtanium") === nothing - - # Test invalid geometric parameters - @test_throws ArgumentError WireArray(0.0, Diameter(-1.0), 1, 0.0, material_al) - @test_throws ArgumentError Insulator(core, Thickness(-1.0), material_pe) - @test_throws ArgumentError WireArray(core, Diameter(d_w), 1, -1.0, material_al) # Negative lay ratio - @test_throws ArgumentError Strip(core, Thickness(-0.1), w_cut, lay_ratio, material_cu) - - # Test empty object creation - @test_throws ArgumentError ConductorGroup() - @test_throws ArgumentError InsulatorGroup() - @test_throws MethodError CableDesign("empty_cable") - @test_throws MethodError LineCableSystem("empty_system", 1000.0) - - # Test trifoil formation with negative radius - @test_throws AssertionError trifoil_formation(0.0, -1.0, -1.0) - - # Test adding a cable at an overlapping position in LineCableSystem - x0, y0 = 0.0, -1.0 - xa, ya, xb, yb, xc, yc = trifoil_formation(x0, y0, 0.1) # Use a valid distance - cablepos = CablePosition(cable_design, xa, ya, Dict("core" => 1)) - cable_system = LineCableSystem("overlap_test", 1000.0, cablepos) - @test_throws ArgumentError add!(cable_system, cable_design, xa, ya, Dict("core" => 2)) - - # Test conductor at interface - @test_throws ArgumentError CablePosition(cable_design, 0.0, 0.0, Dict("core" => 1)) - - # Test invalid phase mapping - @test_throws ArgumentError CablePosition(cable_design, 1.0, 1.0, Dict("non_existent_component" => 1)) - - - # Test exporting a system where some components are grounded (valid case) - f = 10.0 .^ range(0, stop=6, length=10) - earth_params = EarthModel(f, 100.0, 10.0, 1.0) - cablepos_partially_grounded = CablePosition(cable_design, xa, ya, Dict("core" => 1, "sheath" => 0, "jacket" => 0)) - system_partially_grounded = LineCableSystem("partially_grounded_system", 1000.0, cablepos_partially_grounded) - mktempdir(joinpath(@__DIR__)) do temp_dir - output_file = joinpath(temp_dir, "partially_grounded_export.pscx") - result_path = export_data(:pscad, system_partially_grounded, earth_params, file_name=output_file) - @test !isnothing(result_path) - @test isfile(result_path) - @test filesize(result_path) > 0 - end - end +@testitem "examples/tutorial2.jl tests" setup = [defaults] begin + # Replicate the setup from the tutorial + materials = MaterialsLibrary(add_defaults=true) + + # Cable dimensions from the tutorial + num_co_wires = 61 + num_sc_wires = 49 + d_core = 38.1e-3 + d_w = 4.7e-3 + t_sc_in = 0.6e-3 + t_ins = 8e-3 + t_sc_out = 0.3e-3 + d_ws = 0.95e-3 + t_cut = 0.1e-3 + w_cut = 10e-3 + t_wbt = 0.3e-3 + t_sct = 0.3e-3 + t_alt = 0.15e-3 + t_pet = 0.05e-3 + t_jac = 2.4e-3 + + # Test Core and Main Insulation construction + @testset "core and main insulation" begin + material_al = get(materials, "aluminum") + @test material_al isa LineCableModels.Materials.Material + core = ConductorGroup(WireArray(0.0, Diameter(d_w), 1, 0.0, material_al)) + add!(core, WireArray, Diameter(d_w), 6, 15.0, material_al) + add!(core, WireArray, Diameter(d_w), 12, 13.5, material_al) + add!(core, WireArray, Diameter(d_w), 18, 12.5, material_al) + add!(core, WireArray, Diameter(d_w), 24, 11.0, material_al) + + @test length(core.layers) == 5 + @test isapprox(core.radius_ext * 2, 0.0423, atol=1e-4) + + material_poly = get(materials, "polyacrylate") + material_sc1 = get(materials, "semicon1") + material_pe = get(materials, "pe") + material_sc2 = get(materials, "semicon2") + + main_insu = InsulatorGroup(Semicon(core, Thickness(t_sct), material_poly)) + add!(main_insu, Semicon, Thickness(t_sc_in), material_sc1) + add!(main_insu, Insulator, Thickness(t_ins), material_pe) + add!(main_insu, Semicon, Thickness(t_sc_out), material_sc2) + add!(main_insu, Semicon, Thickness(t_sct), material_poly) + + @test length(main_insu.layers) == 5 + + core_cc = CableComponent("core", core, main_insu) + @test core_cc.id == "core" + end + + # Build the full cable design step-by-step as in the tutorial + # This also tests the constructors and `add!` methods implicitly + material_al = get(materials, "aluminum") + core = ConductorGroup(WireArray(0.0, Diameter(d_w), 1, 0.0, material_al)) + add!(core, WireArray, Diameter(d_w), 6, 15.0, material_al) + add!(core, WireArray, Diameter(d_w), 12, 13.5, material_al) + add!(core, WireArray, Diameter(d_w), 18, 12.5, material_al) + add!(core, WireArray, Diameter(d_w), 24, 11.0, material_al) + + material_poly = get(materials, "polyacrylate") + material_sc1 = get(materials, "semicon1") + material_pe = get(materials, "pe") + material_sc2 = get(materials, "semicon2") + main_insu = InsulatorGroup(Semicon(core, Thickness(t_sct), material_poly)) + add!(main_insu, Semicon, Thickness(t_sc_in), material_sc1) + add!(main_insu, Insulator, Thickness(t_ins), material_pe) + add!(main_insu, Semicon, Thickness(t_sc_out), material_sc2) + add!(main_insu, Semicon, Thickness(t_sct), material_poly) + + core_cc = CableComponent("core", core, main_insu) + + cable_id = "18kV_1000mm2" + datasheet_info = NominalData( + designation_code="NA2XS(FL)2Y", U0=18.0, U=30.0, + conductor_cross_section=1000.0, screen_cross_section=35.0, + resistance=0.0291, capacitance=0.39, inductance=0.3 + ) + cable_design = CableDesign(cable_id, core_cc, nominal_data=datasheet_info) + + @test length(cable_design.components) == 1 + @test cable_design.cable_id == cable_id + + material_cu = get(materials, "copper") + lay_ratio = 10.0 + screen_con = ConductorGroup(WireArray(main_insu, Diameter(d_ws), num_sc_wires, lay_ratio, material_cu)) + add!(screen_con, Strip, Thickness(t_cut), w_cut, lay_ratio, material_cu) + screen_insu = InsulatorGroup(Semicon(screen_con, Thickness(t_wbt), material_poly)) + sheath_cc = CableComponent("sheath", screen_con, screen_insu) + add!(cable_design, sheath_cc) + + @test length(cable_design.components) == 2 + @test cable_design.components[2].id == "sheath" + + jacket_con = ConductorGroup(Tubular(screen_insu, Thickness(t_alt), material_al)) + jacket_insu = InsulatorGroup(Insulator(jacket_con, Thickness(t_pet), material_pe)) + add!(jacket_insu, Insulator, Thickness(t_jac), material_pe) + add!(cable_design, "jacket", jacket_con, jacket_insu) + + @test length(cable_design.components) == 3 + @test cable_design.components[3].id == "jacket" + + @testset "calculated parameters vs hard-coded values" begin + core_df = DataFrame(cable_design, :baseparams) + + # Hard-coded values from the tutorial + expected_R = 0.0275677 + expected_L = 0.287184 + expected_C = 0.413357 + + # Test R + computed_R = core_df[core_df.parameter.=="R [Ω/km]", :computed][1] + @test isapprox(computed_R, expected_R, atol=1e-5) + + # Test L + computed_L = core_df[core_df.parameter.=="L [mH/km]", :computed][1] + @test isapprox(computed_L, expected_L, atol=1e-5) + + # Test C + computed_C = core_df[core_df.parameter.=="C [μF/km]", :computed][1] + @test isapprox(computed_C, expected_C, atol=1e-5) + end + + @testset "dataframes and library" begin + # Test that DataFrame constructors do not throw errors + @test DataFrame(cable_design, :components) isa DataFrame + @test DataFrame(cable_design, :detailed) isa DataFrame + + # Test CablesLibrary functionality + library = CablesLibrary() + add!(library, cable_design) + @test length(library) == 1 + @test DataFrame(library) isa DataFrame + + # Test saving and loading + mktempdir(joinpath(@__DIR__)) do temp_dir + output_file = joinpath(temp_dir, "cables_library.json") + save(library, file_name=output_file) + @test isfile(output_file) + + loaded_library = CablesLibrary() + load!(loaded_library, file_name=output_file) + @test length(loaded_library) == 1 + @test loaded_library.data[cable_id].cable_id == cable_id + end + end + + @testset "cable system and export" begin + f = 10.0 .^ range(0, stop=6, length=10) + earth_params = EarthModel(f, 100.0, 10.0, 1.0, 1.0) + @test DataFrame(earth_params) isa DataFrame + + x0, y0 = 0.0, -1.0 + xa, ya, xb, yb, xc, yc = trifoil_formation(x0, y0, 0.035) + + cablepos = CablePosition(cable_design, xa, ya, Dict("core" => 1, "sheath" => 0, "jacket" => 0)) + cable_system = LineCableSystem("18kV_1000mm2_trifoil", 1000.0, cablepos) + add!(cable_system, cable_design, xb, yb, Dict("core" => 2, "sheath" => 0, "jacket" => 0)) + add!(cable_system, cable_design, xc, yc, Dict("core" => 3, "sheath" => 0, "jacket" => 0)) + + @test length(cable_system.cables) == 3 + @test DataFrame(cable_system) isa DataFrame + + # Test PSCAD export + mktempdir(joinpath(@__DIR__)) do temp_dir + output_file = joinpath(temp_dir, "$(cable_system.system_id)_export.pscx") + result_path = export_data(:pscad, cable_system, earth_params, file_name=output_file) + @test !isnothing(result_path) + @test isfile(result_path) + # Check if file has content + @test filesize(result_path) > 0 + end + end + + @testset "preview functions" begin + # Test that preview functions execute without error + # Note: This does not check the plot content, only that they don't crash. + @test preview(cable_design, display_plot=false) isa Any + + f = 10.0 .^ range(0, stop=6, length=10) + earth_params = EarthModel(f, 100.0, 10.0, 1.0, 1.0) + x0, y0 = 0.0, -1.0 + xa, ya, xb, yb, xc, yc = trifoil_formation(x0, y0, 0.035) + cablepos = CablePosition(cable_design, xa, ya, Dict("core" => 1, "sheath" => 0, "jacket" => 0)) + cable_system = LineCableSystem("18kV_1000mm2_trifoil", 1000.0, cablepos) + add!(cable_system, cable_design, xb, yb, Dict("core" => 2, "sheath" => 0, "jacket" => 0)) + add!(cable_system, cable_design, xc, yc, Dict("core" => 3, "sheath" => 0, "jacket" => 0)) + + end + + @testset "user error handling and robustness" begin + # Test invalid material request + @test get(materials, "unobtanium") === nothing + + # Test invalid geometric parameters + @test_throws ArgumentError WireArray(0.0, Diameter(-1.0), 1, 0.0, material_al) + @test_throws ArgumentError Insulator(core, Thickness(-1.0), material_pe) + @test_throws ArgumentError WireArray(core, Diameter(d_w), 1, -1.0, material_al) # Negative lay ratio + @test_throws ArgumentError Strip(core, Thickness(-0.1), w_cut, lay_ratio, material_cu) + + # Test empty object creation + @test_throws ArgumentError ConductorGroup() + @test_throws ArgumentError InsulatorGroup() + @test_throws MethodError CableDesign("empty_cable") + @test_throws MethodError LineCableSystem("empty_system", 1000.0) + + # Test trifoil formation with negative radius + @test_throws AssertionError trifoil_formation(0.0, -1.0, -1.0) + + # Test adding a cable at an overlapping position in LineCableSystem + x0, y0 = 0.0, -1.0 + xa, ya, xb, yb, xc, yc = trifoil_formation(x0, y0, 0.1) # Use a valid distance + cablepos = CablePosition(cable_design, xa, ya, Dict("core" => 1)) + cable_system = LineCableSystem("overlap_test", 1000.0, cablepos) + @test_throws ArgumentError add!(cable_system, cable_design, xa, ya, Dict("core" => 2)) + + # Test conductor at interface + @test_throws ArgumentError CablePosition(cable_design, 0.0, 0.0, Dict("core" => 1)) + + # Test invalid phase mapping + @test_throws ArgumentError CablePosition(cable_design, 1.0, 1.0, Dict("non_existent_component" => 1)) + + + # Test exporting a system where some components are grounded (valid case) + f = 10.0 .^ range(0, stop=6, length=10) + earth_params = EarthModel(f, 100.0, 10.0, 1.0, 1.0) + cablepos_partially_grounded = CablePosition(cable_design, xa, ya, Dict("core" => 1, "sheath" => 0, "jacket" => 0)) + system_partially_grounded = LineCableSystem("partially_grounded_system", 1000.0, cablepos_partially_grounded) + mktempdir(joinpath(@__DIR__)) do temp_dir + output_file = joinpath(temp_dir, "partially_grounded_export.pscx") + result_path = export_data(:pscad, system_partially_grounded, earth_params, file_name=output_file) + @test !isnothing(result_path) + @test isfile(result_path) + @test filesize(result_path) > 0 + end + end end \ No newline at end of file diff --git a/test/test_tutorial3.jl b/test/test_tutorial3.jl index ef0774e7..ccf18931 100644 --- a/test/test_tutorial3.jl +++ b/test/test_tutorial3.jl @@ -1,179 +1,178 @@ -@testitem "examples/tutorial3.jl tests" setup = [defaults] begin - - - mktempdir(joinpath(@__DIR__)) do tmpdir - # Materials - materials = MaterialsLibrary(add_defaults = true) - lead = Material(21.4e-8, 1.0, 0.999983, 20.0, 0.00400) - add!(materials, "lead", lead) - steel = Material(13.8e-8, 1.0, 300.0, 20.0, 0.00450) - add!(materials, "steel", steel) - pp = Material(1e15, 2.8, 1.0, 20.0, 0.0) - add!(materials, "pp", pp) - - @test haskey(materials, "lead") - @test haskey(materials, "steel") - @test haskey(materials, "pp") - - # Cable dimensions - num_ar_wires = 68 - d_w = 3.6649e-3 - t_sc_in = 2e-3 - t_ins = 26e-3 - t_sc_out = 1.8e-3 - t_wbt = 0.3e-3 - t_sc = 3.3e-3 - t_pe = 3e-3 - t_bed = 3e-3 - d_wa = 5.827e-3 - t_jac = 10e-3 - - # Core and main insulation - material_cu = get(materials, "copper") - n = 6 - core = ConductorGroup(WireArray(0.0, Diameter(d_w), 1, 0.0, material_cu)) - add!(core, WireArray, Diameter(d_w), 1 * n, 11.0, material_cu) - add!(core, WireArray, Diameter(d_w), 2 * n, 11.0, material_cu) - add!(core, WireArray, Diameter(d_w), 3 * n, 11.0, material_cu) - add!(core, WireArray, Diameter(d_w), 4 * n, 11.0, material_cu) - add!(core, WireArray, Diameter(d_w), 5 * n, 11.0, material_cu) - add!(core, WireArray, Diameter(d_w), 6 * n, 11.0, material_cu) - - material_sc1 = get(materials, "semicon1") - main_insu = InsulatorGroup(Semicon(core, Thickness(t_sc_in), material_sc1)) - material_pe = get(materials, "pe") - add!(main_insu, Insulator, Thickness(t_ins), material_pe) - material_sc2 = get(materials, "semicon2") - add!(main_insu, Semicon, Thickness(t_sc_out), material_sc2) - material_pa = get(materials, "polyacrylate") - add!(main_insu, Semicon, Thickness(t_wbt), material_pa) - - core_cc = CableComponent("core", core, main_insu) - cable_id = "525kV_1600mm2" - datasheet_info = NominalData(U = 525.0, conductor_cross_section = 1600.0) - cable_design = CableDesign(cable_id, core_cc, nominal_data = datasheet_info) - - @test length(cable_design.components) == 1 - @test cable_design.components[1].id == "core" - - # Lead screen/sheath - material_lead = get(materials, "lead") - screen_con = ConductorGroup(Tubular(main_insu, Thickness(t_sc), material_lead)) - material_pe_sheath = get(materials, "pe") - screen_insu = - InsulatorGroup(Insulator(screen_con, Thickness(t_pe), material_pe_sheath)) - material_pp_bedding = get(materials, "pp") - add!(screen_insu, Insulator, Thickness(t_bed), material_pp_bedding) - sheath_cc = CableComponent("sheath", screen_con, screen_insu) - add!(cable_design, sheath_cc) - - @test length(cable_design.components) == 2 - @test cable_design.components[2].id == "sheath" - - # Armor and outer jacket components - lay_ratio = 10.0 - material_steel = get(materials, "steel") - armor_con = ConductorGroup( - WireArray(screen_insu, Diameter(d_wa), num_ar_wires, lay_ratio, material_steel), - ) - material_pp_jacket = get(materials, "pp") - armor_insu = - InsulatorGroup(Insulator(armor_con, Thickness(t_jac), material_pp_jacket)) - add!(cable_design, "armor", armor_con, armor_insu) - - @test length(cable_design.components) == 3 - @test cable_design.components[3].id == "armor" - - # Saving the cable design - library = CablesLibrary() - library_file = joinpath(tmpdir, "cables_library.json") - add!(library, cable_design) - save(library, file_name = library_file) - - loaded_library = CablesLibrary() - load!(loaded_library, file_name = library_file) - @test haskey(loaded_library, cable_id) - reloaded_design = get(loaded_library, cable_id) - @test reloaded_design.cable_id == cable_design.cable_id - @test length(reloaded_design.components) == length(cable_design.components) - - # Defining a cable system - f = 1e-3 - earth_params = EarthModel([f], 100.0, 10.0, 1.0) - xp = -0.5 - xn = 0.5 - y0 = -1.0 - cablepos = CablePosition( - cable_design, - xp, - y0, - Dict("core" => 1, "sheath" => 0, "armor" => 0), - ) - cable_system = LineCableSystem("525kV_1600mm2_bipole", 1000.0, cablepos) - add!( - cable_system, - cable_design, - xn, - y0, - Dict("core" => 2, "sheath" => 0, "armor" => 0), - ) - - @test length(cable_system.cables) == 2 - - # FEM calculations - problem = LineParametersProblem( - cable_system, - temperature = 20.0, - earth_props = earth_params, - frequencies = [f], - ) - rho_g = earth_params.layers[end].rho_g[1] - mu_g = earth_params.layers[end].mu_g[1] - skin_depth_earth = abs(sqrt(rho_g / (1im * (2 * pi * f) * mu_g))) - domain_radius = clamp(skin_depth_earth, 5.0, 5000.0) - - opts = ( - force_remesh = true, - force_overwrite = true, - plot_field_maps = false, - mesh_only = false, - save_path = joinpath(tmpdir, "fem_output"), - keep_run_files = false, - verbosity = 0, - ) - - formulation = FormulationSet(:FEM, - impedance = Darwin(), - admittance = Electrodynamics(), - domain_radius = domain_radius, - domain_radius_inf = domain_radius * 1.25, - elements_per_length_conductor = 1, - elements_per_length_insulator = 2, - elements_per_length_semicon = 1, - elements_per_length_interfaces = 5, - points_per_circumference = 16, - mesh_size_min = 1e-6, - mesh_size_max = domain_radius / 5, - mesh_size_default = domain_radius / 10, - mesh_algorithm = 5, - mesh_max_retries = 20, - materials = materials, - options = opts, - ) - - workspace, line_params = compute!(problem, formulation) - - @test line_params isa LineParameters - @test size(line_params.Z) == (2, 2, 1) - @test size(line_params.Y) == (2, 2, 1) - - R = real(line_params.Z[1, 1, 1]) * 1000 - L = imag(line_params.Z[1, 1, 1]) / (2π * f) * 1e6 - C = imag(line_params.Y[1, 1, 1]) / (2π * f) * 1e9 - - # Check if the results match hard-coded benchmarks - @test isapprox(R, 0.01303, atol = 1e-5) - @test isapprox(L, 2.7600, atol = 1e-4) - @test isapprox(C, 0.1851, atol = 1e-4) - end -end +@testitem "examples/tutorial3.jl tests" setup = [defaults] begin + + mktempdir(joinpath(@__DIR__)) do tmpdir + # Materials + materials = MaterialsLibrary(add_defaults = true) + lead = Material(21.4e-8, 1.0, 0.999983, 20.0, 0.00400, 35.0) + add!(materials, "lead", lead) + steel = Material(13.8e-8, 1.0, 300.0, 20.0, 0.00450, 14.0) + add!(materials, "steel", steel) + pp = Material(1e15, 2.8, 1.0, 20.0, 0.0, 0.11) + add!(materials, "pp", pp) + + @test haskey(materials, "lead") + @test haskey(materials, "steel") + @test haskey(materials, "pp") + + # Cable dimensions + num_ar_wires = 68 + d_w = 3.6649e-3 + t_sc_in = 2e-3 + t_ins = 26e-3 + t_sc_out = 1.8e-3 + t_wbt = 0.3e-3 + t_sc = 3.3e-3 + t_pe = 3e-3 + t_bed = 3e-3 + d_wa = 5.827e-3 + t_jac = 10e-3 + + # Core and main insulation + material_cu = get(materials, "copper") + n = 6 + core = ConductorGroup(WireArray(0.0, Diameter(d_w), 1, 0.0, material_cu)) + add!(core, WireArray, Diameter(d_w), 1 * n, 11.0, material_cu) + add!(core, WireArray, Diameter(d_w), 2 * n, 11.0, material_cu) + add!(core, WireArray, Diameter(d_w), 3 * n, 11.0, material_cu) + add!(core, WireArray, Diameter(d_w), 4 * n, 11.0, material_cu) + add!(core, WireArray, Diameter(d_w), 5 * n, 11.0, material_cu) + add!(core, WireArray, Diameter(d_w), 6 * n, 11.0, material_cu) + + material_sc1 = get(materials, "semicon1") + main_insu = InsulatorGroup(Semicon(core, Thickness(t_sc_in), material_sc1)) + material_pe = get(materials, "pe") + add!(main_insu, Insulator, Thickness(t_ins), material_pe) + material_sc2 = get(materials, "semicon2") + add!(main_insu, Semicon, Thickness(t_sc_out), material_sc2) + material_pa = get(materials, "polyacrylate") + add!(main_insu, Semicon, Thickness(t_wbt), material_pa) + + core_cc = CableComponent("core", core, main_insu) + cable_id = "525kV_1600mm2" + datasheet_info = NominalData(U = 525.0, conductor_cross_section = 1600.0) + cable_design = CableDesign(cable_id, core_cc, nominal_data = datasheet_info) + + @test length(cable_design.components) == 1 + @test cable_design.components[1].id == "core" + + # Lead screen/sheath + material_lead = get(materials, "lead") + screen_con = ConductorGroup(Tubular(main_insu, Thickness(t_sc), material_lead)) + material_pe_sheath = get(materials, "pe") + screen_insu = + InsulatorGroup(Insulator(screen_con, Thickness(t_pe), material_pe_sheath)) + material_pp_bedding = get(materials, "pp") + add!(screen_insu, Insulator, Thickness(t_bed), material_pp_bedding) + sheath_cc = CableComponent("sheath", screen_con, screen_insu) + add!(cable_design, sheath_cc) + + @test length(cable_design.components) == 2 + @test cable_design.components[2].id == "sheath" + + # Armor and outer jacket components + lay_ratio = 10.0 + material_steel = get(materials, "steel") + armor_con = ConductorGroup( + WireArray(screen_insu, Diameter(d_wa), num_ar_wires, lay_ratio, material_steel), + ) + material_pp_jacket = get(materials, "pp") + armor_insu = + InsulatorGroup(Insulator(armor_con, Thickness(t_jac), material_pp_jacket)) + add!(cable_design, "armor", armor_con, armor_insu) + + @test length(cable_design.components) == 3 + @test cable_design.components[3].id == "armor" + + # Saving the cable design + library = CablesLibrary() + library_file = joinpath(tmpdir, "cables_library.json") + add!(library, cable_design) + save(library, file_name = library_file) + + loaded_library = CablesLibrary() + load!(loaded_library, file_name = library_file) + @test haskey(loaded_library, cable_id) + reloaded_design = get(loaded_library, cable_id) + @test reloaded_design.cable_id == cable_design.cable_id + @test length(reloaded_design.components) == length(cable_design.components) + + # Defining a cable system + f = 1e-3 + earth_params = EarthModel([f], 100.0, 10.0, 1.0, 1.0) + xp = -0.5 + xn = 0.5 + y0 = -1.0 + cablepos = CablePosition( + cable_design, + xp, + y0, + Dict("core" => 1, "sheath" => 0, "armor" => 0), + ) + cable_system = LineCableSystem("525kV_1600mm2_bipole", 1000.0, cablepos) + add!( + cable_system, + cable_design, + xn, + y0, + Dict("core" => 2, "sheath" => 0, "armor" => 0), + ) + + @test length(cable_system.cables) == 2 + + # FEM calculations + problem = LineParametersProblem( + cable_system, + temperature = 20.0, + earth_props = earth_params, + frequencies = [f], + ) + rho_g = earth_params.layers[end].rho_g[1] + mu_g = earth_params.layers[end].mu_g[1] + skin_depth_earth = abs(sqrt(rho_g / (1im * (2 * pi * f) * mu_g))) + domain_radius = clamp(skin_depth_earth, 5.0, 5000.0) + + opts = ( + force_remesh = true, + force_overwrite = true, + plot_field_maps = false, + mesh_only = false, + save_path = joinpath(tmpdir, "fem_output"), + keep_run_files = false, + verbosity = 0, + ) + + formulation = FormulationSet(:FEM, + impedance = Darwin(), + admittance = Electrodynamics(), + domain_radius = domain_radius, + domain_radius_inf = domain_radius * 1.25, + elements_per_length_conductor = 1, + elements_per_length_insulator = 2, + elements_per_length_semicon = 1, + elements_per_length_interfaces = 5, + points_per_circumference = 16, + mesh_size_min = 1e-6, + mesh_size_max = domain_radius / 5, + mesh_size_default = domain_radius / 10, + mesh_algorithm = 5, + mesh_max_retries = 20, + materials = materials, + options = opts, + ) + + workspace, line_params = compute!(problem, formulation) + + @test line_params isa LineParameters + @test size(line_params.Z) == (2, 2, 1) + @test size(line_params.Y) == (2, 2, 1) + + R = real(line_params.Z[1, 1, 1]) * 1000 + L = imag(line_params.Z[1, 1, 1]) / (2π * f) * 1e6 + C = imag(line_params.Y[1, 1, 1]) / (2π * f) * 1e9 + + # Check if the results match hard-coded benchmarks + @test isapprox(R, 0.01303, atol = 1e-5) + @test isapprox(L, 2.7600, atol = 1e-4) + @test isapprox(C, 0.1851, atol = 1e-4) + end +end diff --git a/test/unit_BaseParams/test_calc_equivalent_alpha.jl b/test/unit_BaseParams/test_calc_equivalent_alpha.jl index 945b6ef2..c416e8c1 100644 --- a/test/unit_BaseParams/test_calc_equivalent_alpha.jl +++ b/test/unit_BaseParams/test_calc_equivalent_alpha.jl @@ -1,131 +1,131 @@ -@testitem "BaseParams: calc_equivalent_alpha unit tests" setup = [defaults] begin - - @testset "calc_equivalent_alpha: Basic Functionality (Copper & Aluminum)" begin - alpha1 = 0.00393 # Copper - R1 = 0.5 - alpha2 = 0.00403 # Aluminum - R2 = 1.0 - expected = (alpha1 * R2 + alpha2 * R1) / (R1 + R2) - result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) - @test isapprox(result, expected; atol=TEST_TOL) - end - - @testset "calc_equivalent_alpha: Edge Case - Zero Resistance" begin - alpha1 = 0.00393 - R1 = 0.0 - alpha2 = 0.00403 - R2 = 1.0 - expected = alpha1 # Only R2 matters - result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) - @test isapprox(result, expected; atol=TEST_TOL) - - alpha1 = 0.00393 - R1 = 0.5 - alpha2 = 0.00403 - R2 = 0.0 - expected = alpha2 # Only R1 matters - result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) - @test isapprox(result, expected; atol=TEST_TOL) - end - - @testset "calc_equivalent_alpha: Edge Case - Very Large Resistance" begin - alpha1 = 0.00393 - R1 = 1e12 - alpha2 = 0.00403 - R2 = 1.0 - expected = (alpha1 * R2 + alpha2 * R1) / (R1 + R2) - result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) - @test isapprox(result, expected; atol=TEST_TOL) - end - - - @testset "calc_equivalent_alpha: Type Stability & Promotion" begin - alpha1 = 0.00393 - R1 = 0.5 - alpha2 = 0.00403 - R2 = 1 - result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) - @test typeof(result) == typeof(1.0) - - # Mixed Int/Float - result2 = calc_equivalent_alpha(0, 1, 1, 1.0) - @test typeof(result2) == typeof(1.0) - end - - @testset "calc_equivalent_alpha: Uncertainty Quantification (Measurements.jl)" begin - alpha1 = measurement(0.00393, 1e-5) - R1 = measurement(0.5, 1e-3) - alpha2 = measurement(0.00403, 1e-5) - R2 = measurement(1.0, 1e-3) - result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) - # Check value - expected_val = (value(alpha1) * value(R2) + value(alpha2) * value(R1)) / (value(R1) + value(R2)) - @test isapprox(value(result), expected_val; atol=TEST_TOL) - # Check uncertainty propagation (should be nonzero) - @test uncertainty(result) > 0 - end - - @testset "calc_equivalent_alpha: Equivalent temperature coefficient for parallel resistors" begin - - # Example values (Copper and Aluminum) - alpha1 = 0.00393 # Copper - R1 = 0.5 - alpha2 = 0.00403 # Aluminum - R2 = 1.0 - - # Analytical result - expected = (alpha1 * R2 + alpha2 * R1) / (R1 + R2) - result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) - @test isapprox(result, expected; atol=TEST_TOL) - - # Edge case: Identical conductors - alpha = 0.00393 - R = 1.0 - @test isapprox(calc_equivalent_alpha(alpha, R, alpha, R), alpha; atol=TEST_TOL) - - # Edge case: One resistance much larger than the other - @test isapprox(calc_equivalent_alpha(0.003, 1e6, 0.005, 1.0), 0.005; atol=TEST_TOL) - @test isapprox(calc_equivalent_alpha(0.003, 1.0, 0.005, 1e6), 0.003; atol=TEST_TOL) - - # Type promotion and Measurements.jl propagation - using Measurements: ±, value, uncertainty - m1 = 0.00393 ± 0.00001 - m2 = 0.00403 ± 0.00001 - r1 = 0.5 ± 0.01 - r2 = 1.0 ± 0.01 - - @testset "Type Promotion with Measurements.jl" begin - # Base case: All Float64 - @test calc_equivalent_alpha(alpha1, R1, alpha2, R2) isa Float64 - - # Fully promoted: All Measurement - res = calc_equivalent_alpha(m1, r1, m2, r2) - @test res isa Measurement{Float64} - @test isapprox(value(res), expected; atol=TEST_TOL) - # Uncertainty should be nonzero - @test uncertainty(res) > 0 - - # Mixed case 1: First argument is Measurement - res = calc_equivalent_alpha(m1, R1, alpha2, R2) - @test res isa Measurement{Float64} - @test isapprox(value(res), expected; atol=TEST_TOL) - - # Mixed case 2: Middle argument is Measurement - res = calc_equivalent_alpha(alpha1, r1, alpha2, R2) - @test res isa Measurement{Float64} - @test isapprox(value(res), expected; atol=TEST_TOL) - - # Mixed case 3: Last argument is Measurement - res = calc_equivalent_alpha(alpha1, R1, alpha2, r2) - @test res isa Measurement{Float64} - @test isapprox(value(res), expected; atol=TEST_TOL) - end - - # Physically unusual but valid: zero resistance (should return NaN) - @test isnan(calc_equivalent_alpha(0.003, 0.0, 0.005, 0.0)) - - # Large values - @test isapprox(calc_equivalent_alpha(1e-3, 1e6, 2e-3, 2e6), (1e-3 * 2e6 + 2e-3 * 1e6) / (1e6 + 2e6); atol=TEST_TOL) - end - -end # End of test file +@testitem "BaseParams: calc_equivalent_alpha unit tests" setup = [defaults] begin + + @testset "calc_equivalent_alpha: Basic Functionality (Copper & Aluminum)" begin + alpha1 = 0.00393 # Copper + R1 = 0.5 + alpha2 = 0.00403 # Aluminum + R2 = 1.0 + expected = (alpha1 * R2 + alpha2 * R1) / (R1 + R2) + result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) + @test isapprox(result, expected; atol=TEST_TOL) + end + + @testset "calc_equivalent_alpha: Edge Case - Zero Resistance" begin + alpha1 = 0.00393 + R1 = 0.0 + alpha2 = 0.00403 + R2 = 1.0 + expected = alpha1 # Only R2 matters + result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) + @test isapprox(result, expected; atol=TEST_TOL) + + alpha1 = 0.00393 + R1 = 0.5 + alpha2 = 0.00403 + R2 = 0.0 + expected = alpha2 # Only R1 matters + result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) + @test isapprox(result, expected; atol=TEST_TOL) + end + + @testset "calc_equivalent_alpha: Edge Case - Very Large Resistance" begin + alpha1 = 0.00393 + R1 = 1e12 + alpha2 = 0.00403 + R2 = 1.0 + expected = (alpha1 * R2 + alpha2 * R1) / (R1 + R2) + result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) + @test isapprox(result, expected; atol=TEST_TOL) + end + + + @testset "calc_equivalent_alpha: Type Stability & Promotion" begin + alpha1 = 0.00393 + R1 = 0.5 + alpha2 = 0.00403 + R2 = 1 + result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) + @test typeof(result) == typeof(1.0) + + # Mixed Int/Float + result2 = calc_equivalent_alpha(0, 1, 1, 1.0) + @test typeof(result2) == typeof(1.0) + end + + @testset "calc_equivalent_alpha: Uncertainty Quantification (Measurements.jl)" begin + alpha1 = measurement(0.00393, 1e-5) + R1 = measurement(0.5, 1e-3) + alpha2 = measurement(0.00403, 1e-5) + R2 = measurement(1.0, 1e-3) + result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) + # Check value + expected_val = (value(alpha1) * value(R2) + value(alpha2) * value(R1)) / (value(R1) + value(R2)) + @test isapprox(value(result), expected_val; atol=TEST_TOL) + # Check uncertainty propagation (should be nonzero) + @test uncertainty(result) > 0 + end + + @testset "calc_equivalent_alpha: Equivalent temperature coefficient for parallel resistors" begin + + # Example values (Copper and Aluminum) + alpha1 = 0.00393 # Copper + R1 = 0.5 + alpha2 = 0.00403 # Aluminum + R2 = 1.0 + + # Analytical result + expected = (alpha1 * R2 + alpha2 * R1) / (R1 + R2) + result = calc_equivalent_alpha(alpha1, R1, alpha2, R2) + @test isapprox(result, expected; atol=TEST_TOL) + + # Edge case: Identical conductors + alpha = 0.00393 + R = 1.0 + @test isapprox(calc_equivalent_alpha(alpha, R, alpha, R), alpha; atol=TEST_TOL) + + # Edge case: One resistance much larger than the other + @test isapprox(calc_equivalent_alpha(0.003, 1e6, 0.005, 1.0), 0.005; atol=TEST_TOL) + @test isapprox(calc_equivalent_alpha(0.003, 1.0, 0.005, 1e6), 0.003; atol=TEST_TOL) + + # Type promotion and Measurements.jl propagation + using Measurements: ±, value, uncertainty + m1 = 0.00393 ± 0.00001 + m2 = 0.00403 ± 0.00001 + r1 = 0.5 ± 0.01 + r2 = 1.0 ± 0.01 + + @testset "Type Promotion with Measurements.jl" begin + # Base case: All Float64 + @test calc_equivalent_alpha(alpha1, R1, alpha2, R2) isa Float64 + + # Fully promoted: All Measurement + res = calc_equivalent_alpha(m1, r1, m2, r2) + @test res isa Measurement{Float64} + @test isapprox(value(res), expected; atol=TEST_TOL) + # Uncertainty should be nonzero + @test uncertainty(res) > 0 + + # Mixed case 1: First argument is Measurement + res = calc_equivalent_alpha(m1, R1, alpha2, R2) + @test res isa Measurement{Float64} + @test isapprox(value(res), expected; atol=TEST_TOL) + + # Mixed case 2: Middle argument is Measurement + res = calc_equivalent_alpha(alpha1, r1, alpha2, R2) + @test res isa Measurement{Float64} + @test isapprox(value(res), expected; atol=TEST_TOL) + + # Mixed case 3: Last argument is Measurement + res = calc_equivalent_alpha(alpha1, R1, alpha2, r2) + @test res isa Measurement{Float64} + @test isapprox(value(res), expected; atol=TEST_TOL) + end + + # Physically unusual but valid: zero resistance (should return NaN) + @test isnan(calc_equivalent_alpha(0.003, 0.0, 0.005, 0.0)) + + # Large values + @test isapprox(calc_equivalent_alpha(1e-3, 1e6, 2e-3, 2e6), (1e-3 * 2e6 + 2e-3 * 1e6) / (1e6 + 2e6); atol=TEST_TOL) + end + +end # End of test file diff --git a/test/unit_BaseParams/test_calc_equivalent_eps.jl b/test/unit_BaseParams/test_calc_equivalent_eps.jl index f5418385..489ce91c 100644 --- a/test/unit_BaseParams/test_calc_equivalent_eps.jl +++ b/test/unit_BaseParams/test_calc_equivalent_eps.jl @@ -1,73 +1,73 @@ -@testitem "BaseParams: calc_equivalent_eps unit tests" setup = [defaults] begin - @testset "Basic Functionality" begin - # Example from docstring: C_eq=1e-10 F/m, r_ext=0.01 m, r_in=0.005 m - result = calc_equivalent_eps(1e-10, 0.01, 0.005) - expected = (1e-10 * log(0.01 / 0.005)) / (2 * pi) / ε₀ - @test isapprox(result, expected; atol=TEST_TOL) - @test result > 0 - end - - @testset "Edge Cases" begin - # Zero capacitance - result = calc_equivalent_eps(0.0, 0.01, 0.005) - @test isapprox(result, 0.0; atol=TEST_TOL) - # Collapsing geometry: r_ext == r_in - result = calc_equivalent_eps(1e-10, 0.01, 0.01) - @test isapprox(result, 0.0; atol=TEST_TOL) - # Very large radii - result = calc_equivalent_eps(1e-10, 1e6, 1e3) - expected = (1e-10 * log(1e6 / 1e3)) / (2 * pi) / ε₀ - @test isapprox(result, expected; atol=TEST_TOL) - # Inf/NaN - @test isnan(calc_equivalent_eps(NaN, 0.01, 0.005)) - @test isnan(calc_equivalent_eps(1e-10, NaN, 0.005)) - @test isnan(calc_equivalent_eps(1e-10, 0.01, NaN)) - @test isinf(calc_equivalent_eps(Inf, 0.01, 0.005)) - end - - @testset "Numerical Consistency" begin - # Float32 vs Float64 - r = calc_equivalent_eps(Float32(1e-10), Float32(0.01), Float32(0.005)) - d = calc_equivalent_eps(1e-10, 0.01, 0.005) - @test isapprox(r, d; atol=1e-6) - end - - @testset "Physical Behavior" begin - # Increases with capacitance - r1 = calc_equivalent_eps(1e-10, 0.01, 0.005) - r2 = calc_equivalent_eps(2e-10, 0.01, 0.005) - @test r2 > r1 - # Increases with log(r_ext/r_in) - r3 = calc_equivalent_eps(1e-10, 0.02, 0.005) - @test r3 > r1 - end - - @testset "Type Stability & Promotion" begin - using Measurements - # All Float64 - r1 = calc_equivalent_eps(1e-10, 0.01, 0.005) - @test typeof(r1) == Float64 - # All Measurement - r2 = calc_equivalent_eps(measurement(1e-10, 1e-12), measurement(0.01, 1e-5), measurement(0.005, 1e-5)) - @test r2 isa Measurement{Float64} - # Mixed: C_eq as Measurement - r3 = calc_equivalent_eps(measurement(1e-10, 1e-12), 0.01, 0.005) - @test r3 isa Measurement{Float64} - # Mixed: radius_ext as Measurement - r4 = calc_equivalent_eps(1e-10, measurement(0.01, 1e-5), 0.005) - @test r4 isa Measurement{Float64} - # Mixed: radius_in as Measurement - r5 = calc_equivalent_eps(1e-10, 0.01, measurement(0.005, 1e-5)) - @test r5 isa Measurement{Float64} - end - - @testset "Uncertainty Quantification" begin - using Measurements - C_eq = measurement(1e-10, 1e-12) - r_ext = measurement(0.01, 1e-5) - r_in = measurement(0.005, 1e-5) - result = calc_equivalent_eps(C_eq, r_ext, r_in) - @test result isa Measurement{Float64} - @test uncertainty(result) > 0 - end -end +@testitem "BaseParams: calc_equivalent_eps unit tests" setup = [defaults] begin + @testset "Basic Functionality" begin + # Example from docstring: C_eq=1e-10 F/m, r_ext=0.01 m, r_in=0.005 m + result = calc_equivalent_eps(1e-10, 0.01, 0.005) + expected = (1e-10 * log(0.01 / 0.005)) / (2 * pi) / ε₀ + @test isapprox(result, expected; atol=TEST_TOL) + @test result > 0 + end + + @testset "Edge Cases" begin + # Zero capacitance + result = calc_equivalent_eps(0.0, 0.01, 0.005) + @test isapprox(result, 0.0; atol=TEST_TOL) + # Collapsing geometry: r_ext == r_in + result = calc_equivalent_eps(1e-10, 0.01, 0.01) + @test isapprox(result, 0.0; atol=TEST_TOL) + # Very large radii + result = calc_equivalent_eps(1e-10, 1e6, 1e3) + expected = (1e-10 * log(1e6 / 1e3)) / (2 * pi) / ε₀ + @test isapprox(result, expected; atol=TEST_TOL) + # Inf/NaN + @test isnan(calc_equivalent_eps(NaN, 0.01, 0.005)) + @test isnan(calc_equivalent_eps(1e-10, NaN, 0.005)) + @test isnan(calc_equivalent_eps(1e-10, 0.01, NaN)) + @test isinf(calc_equivalent_eps(Inf, 0.01, 0.005)) + end + + @testset "Numerical Consistency" begin + # Float32 vs Float64 + r = calc_equivalent_eps(Float32(1e-10), Float32(0.01), Float32(0.005)) + d = calc_equivalent_eps(1e-10, 0.01, 0.005) + @test isapprox(r, d; atol=1e-6) + end + + @testset "Physical Behavior" begin + # Increases with capacitance + r1 = calc_equivalent_eps(1e-10, 0.01, 0.005) + r2 = calc_equivalent_eps(2e-10, 0.01, 0.005) + @test r2 > r1 + # Increases with log(r_ext/r_in) + r3 = calc_equivalent_eps(1e-10, 0.02, 0.005) + @test r3 > r1 + end + + @testset "Type Stability & Promotion" begin + using Measurements + # All Float64 + r1 = calc_equivalent_eps(1e-10, 0.01, 0.005) + @test typeof(r1) == Float64 + # All Measurement + r2 = calc_equivalent_eps(measurement(1e-10, 1e-12), measurement(0.01, 1e-5), measurement(0.005, 1e-5)) + @test r2 isa Measurement{Float64} + # Mixed: C_eq as Measurement + r3 = calc_equivalent_eps(measurement(1e-10, 1e-12), 0.01, 0.005) + @test r3 isa Measurement{Float64} + # Mixed: radius_ext as Measurement + r4 = calc_equivalent_eps(1e-10, measurement(0.01, 1e-5), 0.005) + @test r4 isa Measurement{Float64} + # Mixed: radius_in as Measurement + r5 = calc_equivalent_eps(1e-10, 0.01, measurement(0.005, 1e-5)) + @test r5 isa Measurement{Float64} + end + + @testset "Uncertainty Quantification" begin + using Measurements + C_eq = measurement(1e-10, 1e-12) + r_ext = measurement(0.01, 1e-5) + r_in = measurement(0.005, 1e-5) + result = calc_equivalent_eps(C_eq, r_ext, r_in) + @test result isa Measurement{Float64} + @test uncertainty(result) > 0 + end +end diff --git a/test/unit_BaseParams/test_calc_equivalent_gmr.jl b/test/unit_BaseParams/test_calc_equivalent_gmr.jl index 73c13443..227e06b5 100644 --- a/test/unit_BaseParams/test_calc_equivalent_gmr.jl +++ b/test/unit_BaseParams/test_calc_equivalent_gmr.jl @@ -1,79 +1,79 @@ -@testitem "BaseParams: calc_equivalent_gmr unit tests" setup = [defaults, deps_datamodel, defs_materials] begin - @testset "Basic Functionality" begin - # Example from docstring - material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) - strip = Strip(0.01, Thickness(0.002), 0.05, 10, material_props) - wirearray = WireArray(0.02, 0.002, 7, 15, material_props) - gmr_eq = calc_equivalent_gmr(strip, wirearray) - @test gmr_eq > 0 - end - - @testset "Edge Cases" begin - # Identical layers (should reduce to geometric mean) - material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) - part1 = WireArray(0.01, 0.002, 7, 10, material_props) - part2 = WireArray(0.01, 0.002, 7, 10, material_props) - gmr_eq = calc_equivalent_gmr(part1, part2) - @test gmr_eq > 0 - # Very large cross-section for new_layer - big_layer = WireArray(0.02, 0.002, 7, 1e6, material_props) - gmr_eq2 = calc_equivalent_gmr(part1, big_layer) - @test gmr_eq2 > 0 - end - - @testset "Numerical Consistency" begin - # Float32 vs Float64 - material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) - part1f32 = WireArray(Float32(0.01), Float32(0.002), 7, Float32(10), material_props) - part2f32 = WireArray(Float32(0.02), Float32(0.002), 7, Float32(15), material_props) - gmr_eq_f32 = calc_equivalent_gmr(part1f32, part2f32) - part1f64 = WireArray(0.01, 0.002, 7, 10, material_props) - part2f64 = WireArray(0.02, 0.002, 7, 15, material_props) - gmr_eq_f64 = calc_equivalent_gmr(part1f64, part2f64) - @test isapprox(gmr_eq_f32, gmr_eq_f64, atol=TEST_TOL) - end - - @testset "Physical Behavior" begin - # Equivalent GMR increases as GMD increases - material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) - part1 = WireArray(0.01, 0.002, 7, 10, material_props) - part2 = WireArray(0.02, 0.002, 7, 15, material_props) - part3 = WireArray(0.03, 0.002, 7, 15, material_props) - gmr_eq1 = calc_equivalent_gmr(part1, part2) - gmr_eq2 = calc_equivalent_gmr(part1, part3) - @test gmr_eq2 > gmr_eq1 - end - - @testset "Type Stability & Promotion" begin - using Measurements - material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) - part1 = WireArray(0.01, 0.002, 7, 10, material_props) - part2 = WireArray(0.02, 0.002, 7, 15, material_props) - mpart1 = WireArray(measurement(0.01, 1e-4), 0.002, 7, 10, material_props) - mpart2 = WireArray(0.02, measurement(0.002, 1e-4), 7, 15, material_props) - # All Float64 - res1 = calc_equivalent_gmr(part1, part2) - @test typeof(res1) == Float64 - # All Measurement - res2 = calc_equivalent_gmr(mpart1, mpart2) - @test res2 isa Measurement{Float64} - # Mixed: first argument Measurement - res3 = calc_equivalent_gmr(mpart1, part2) - @test res3 isa Measurement{Float64} - # Mixed: second argument Measurement - res4 = calc_equivalent_gmr(part1, mpart2) - @test res4 isa Measurement{Float64} - end - - @testset "Uncertainty Quantification" begin - using Measurements - material_props = Material(1.7241e-8, 1.0, 0.999994, measurement(20, 10), 0.00393) - part1 = WireArray(0.01, 0.002, 7, 10, material_props) - part2 = WireArray(0.02, 0.002, 7, 15, material_props) - mpart1 = WireArray(measurement(0.01, 1e-4), 0.002, 7, 10, material_props) - mpart2 = WireArray(0.02, measurement(0.002, 1e-4), 7, 15, material_props) - gmr_eq = calc_equivalent_gmr(mpart1, mpart2) - @test gmr_eq isa Measurement{Float64} - @test uncertainty(gmr_eq) > 0 - end -end +@testitem "BaseParams: calc_equivalent_gmr unit tests" setup = [defaults, deps_datamodel, defs_materials] begin + @testset "Basic Functionality" begin + # Example from docstring + material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0) + strip = Strip(0.01, Thickness(0.002), 0.05, 10, material_props) + wirearray = WireArray(0.02, 0.002, 7, 15, material_props) + gmr_eq = calc_equivalent_gmr(strip, wirearray) + @test gmr_eq > 0 + end + + @testset "Edge Cases" begin + # Identical layers (should reduce to geometric mean) + material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0) + part1 = WireArray(0.01, 0.002, 7, 10, material_props) + part2 = WireArray(0.01, 0.002, 7, 10, material_props) + gmr_eq = calc_equivalent_gmr(part1, part2) + @test gmr_eq > 0 + # Very large cross-section for new_layer + big_layer = WireArray(0.02, 0.002, 7, 1e6, material_props) + gmr_eq2 = calc_equivalent_gmr(part1, big_layer) + @test gmr_eq2 > 0 + end + + @testset "Numerical Consistency" begin + # Float32 vs Float64 + material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0) + part1f32 = WireArray(Float32(0.01), Float32(0.002), 7, Float32(10), material_props) + part2f32 = WireArray(Float32(0.02), Float32(0.002), 7, Float32(15), material_props) + gmr_eq_f32 = calc_equivalent_gmr(part1f32, part2f32) + part1f64 = WireArray(0.01, 0.002, 7, 10, material_props) + part2f64 = WireArray(0.02, 0.002, 7, 15, material_props) + gmr_eq_f64 = calc_equivalent_gmr(part1f64, part2f64) + @test isapprox(gmr_eq_f32, gmr_eq_f64, atol=TEST_TOL) + end + + @testset "Physical Behavior" begin + # Equivalent GMR increases as GMD increases + material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0) + part1 = WireArray(0.01, 0.002, 7, 10, material_props) + part2 = WireArray(0.02, 0.002, 7, 15, material_props) + part3 = WireArray(0.03, 0.002, 7, 15, material_props) + gmr_eq1 = calc_equivalent_gmr(part1, part2) + gmr_eq2 = calc_equivalent_gmr(part1, part3) + @test gmr_eq2 > gmr_eq1 + end + + @testset "Type Stability & Promotion" begin + using Measurements + material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0) + part1 = WireArray(0.01, 0.002, 7, 10, material_props) + part2 = WireArray(0.02, 0.002, 7, 15, material_props) + mpart1 = WireArray(measurement(0.01, 1e-4), 0.002, 7, 10, material_props) + mpart2 = WireArray(0.02, measurement(0.002, 1e-4), 7, 15, material_props) + # All Float64 + res1 = calc_equivalent_gmr(part1, part2) + @test typeof(res1) == Float64 + # All Measurement + res2 = calc_equivalent_gmr(mpart1, mpart2) + @test res2 isa Measurement{Float64} + # Mixed: first argument Measurement + res3 = calc_equivalent_gmr(mpart1, part2) + @test res3 isa Measurement{Float64} + # Mixed: second argument Measurement + res4 = calc_equivalent_gmr(part1, mpart2) + @test res4 isa Measurement{Float64} + end + + @testset "Uncertainty Quantification" begin + using Measurements + material_props = Material(1.7241e-8, 1.0, 0.999994, measurement(20, 10), 0.00393, 401.0) + part1 = WireArray(0.01, 0.002, 7, 10, material_props) + part2 = WireArray(0.02, 0.002, 7, 15, material_props) + mpart1 = WireArray(measurement(0.01, 1e-4), 0.002, 7, 10, material_props) + mpart2 = WireArray(0.02, measurement(0.002, 1e-4), 7, 15, material_props) + gmr_eq = calc_equivalent_gmr(mpart1, mpart2) + @test gmr_eq isa Measurement{Float64} + @test uncertainty(gmr_eq) > 0 + end +end diff --git a/test/unit_BaseParams/test_calc_equivalent_lossfact.jl b/test/unit_BaseParams/test_calc_equivalent_lossfact.jl index 668ffe17..9360e547 100644 --- a/test/unit_BaseParams/test_calc_equivalent_lossfact.jl +++ b/test/unit_BaseParams/test_calc_equivalent_lossfact.jl @@ -1,78 +1,78 @@ -@testitem "BaseParams: calc_equivalent_lossfact unit tests" setup = [defaults] begin - @testset "Basic Functionality" begin - # Example from docstring: G_eq=1e-8 S·m, C_eq=1e-10 F/m, ω=2π*50 - G_eq = 1e-8 - C_eq = 1e-10 - ω = 2 * pi * 50 - result = calc_equivalent_lossfact(G_eq, C_eq, ω) - expected = G_eq / (ω * C_eq) - @test isapprox(result, expected; atol=TEST_TOL) - @test result > 0 - end - - @testset "Edge Cases" begin - # Zero conductance - result = calc_equivalent_lossfact(0.0, 1e-10, 2 * pi * 50) - @test isapprox(result, 0.0; atol=TEST_TOL) - # Zero capacitance (should be Inf) - result = calc_equivalent_lossfact(1e-8, 0.0, 2 * pi * 50) - @test isinf(result) - # Zero frequency (should be Inf) - result = calc_equivalent_lossfact(1e-8, 1e-10, 0.0) - @test isinf(result) - # Inf/NaN - @test isnan(calc_equivalent_lossfact(NaN, 1e-10, 2 * pi * 50)) - @test isnan(calc_equivalent_lossfact(1e-8, NaN, 2 * pi * 50)) - @test isnan(calc_equivalent_lossfact(1e-8, 1e-10, NaN)) - @test isinf(calc_equivalent_lossfact(Inf, 1e-10, 2 * pi * 50)) - end - - @testset "Numerical Consistency" begin - # Float32 vs Float64 - r = calc_equivalent_lossfact(Float32(1e-8), Float32(1e-10), Float32(2 * pi * 50)) - d = calc_equivalent_lossfact(1e-8, 1e-10, 2 * pi * 50) - @test isapprox(r, d; atol=1e-6) - end - - @testset "Physical Behavior" begin - # Increases with G_eq - r1 = calc_equivalent_lossfact(1e-8, 1e-10, 2 * pi * 50) - r2 = calc_equivalent_lossfact(2e-8, 1e-10, 2 * pi * 50) - @test r2 > r1 - # Decreases with C_eq - r3 = calc_equivalent_lossfact(1e-8, 2e-10, 2 * pi * 50) - @test r3 < r1 - # Decreases with ω - r4 = calc_equivalent_lossfact(1e-8, 1e-10, 2 * pi * 100) - @test r4 < r1 - end - - @testset "Type Stability & Promotion" begin - using Measurements - # All Float64 - r1 = calc_equivalent_lossfact(1e-8, 1e-10, 2 * pi * 50) - @test typeof(r1) == Float64 - # All Measurement - r2 = calc_equivalent_lossfact(measurement(1e-8, 1e-10), measurement(1e-10, 1e-12), measurement(2 * pi * 50, 0.1)) - @test r2 isa Measurement{Float64} - # Mixed: G_eq as Measurement - r3 = calc_equivalent_lossfact(measurement(1e-8, 1e-10), 1e-10, 2 * pi * 50) - @test r3 isa Measurement{Float64} - # Mixed: C_eq as Measurement - r4 = calc_equivalent_lossfact(1e-8, measurement(1e-10, 1e-12), 2 * pi * 50) - @test r4 isa Measurement{Float64} - # Mixed: ω as Measurement - r5 = calc_equivalent_lossfact(1e-8, 1e-10, measurement(2 * pi * 50, 0.1)) - @test r5 isa Measurement{Float64} - end - - @testset "Uncertainty Quantification" begin - using Measurements - G_eq = measurement(1e-8, 1e-10) - C_eq = measurement(1e-10, 1e-12) - ω = measurement(2 * pi * 50, 0.1) - result = calc_equivalent_lossfact(G_eq, C_eq, ω) - @test result isa Measurement{Float64} - @test uncertainty(result) > 0 - end -end +@testitem "BaseParams: calc_equivalent_lossfact unit tests" setup = [defaults] begin + @testset "Basic Functionality" begin + # Example from docstring: G_eq=1e-8 S·m, C_eq=1e-10 F/m, ω=2π*50 + G_eq = 1e-8 + C_eq = 1e-10 + ω = 2 * pi * 50 + result = calc_equivalent_lossfact(G_eq, C_eq, ω) + expected = G_eq / (ω * C_eq) + @test isapprox(result, expected; atol=TEST_TOL) + @test result > 0 + end + + @testset "Edge Cases" begin + # Zero conductance + result = calc_equivalent_lossfact(0.0, 1e-10, 2 * pi * 50) + @test isapprox(result, 0.0; atol=TEST_TOL) + # Zero capacitance (should be Inf) + result = calc_equivalent_lossfact(1e-8, 0.0, 2 * pi * 50) + @test isinf(result) + # Zero frequency (should be Inf) + result = calc_equivalent_lossfact(1e-8, 1e-10, 0.0) + @test isinf(result) + # Inf/NaN + @test isnan(calc_equivalent_lossfact(NaN, 1e-10, 2 * pi * 50)) + @test isnan(calc_equivalent_lossfact(1e-8, NaN, 2 * pi * 50)) + @test isnan(calc_equivalent_lossfact(1e-8, 1e-10, NaN)) + @test isinf(calc_equivalent_lossfact(Inf, 1e-10, 2 * pi * 50)) + end + + @testset "Numerical Consistency" begin + # Float32 vs Float64 + r = calc_equivalent_lossfact(Float32(1e-8), Float32(1e-10), Float32(2 * pi * 50)) + d = calc_equivalent_lossfact(1e-8, 1e-10, 2 * pi * 50) + @test isapprox(r, d; atol=1e-6) + end + + @testset "Physical Behavior" begin + # Increases with G_eq + r1 = calc_equivalent_lossfact(1e-8, 1e-10, 2 * pi * 50) + r2 = calc_equivalent_lossfact(2e-8, 1e-10, 2 * pi * 50) + @test r2 > r1 + # Decreases with C_eq + r3 = calc_equivalent_lossfact(1e-8, 2e-10, 2 * pi * 50) + @test r3 < r1 + # Decreases with ω + r4 = calc_equivalent_lossfact(1e-8, 1e-10, 2 * pi * 100) + @test r4 < r1 + end + + @testset "Type Stability & Promotion" begin + using Measurements + # All Float64 + r1 = calc_equivalent_lossfact(1e-8, 1e-10, 2 * pi * 50) + @test typeof(r1) == Float64 + # All Measurement + r2 = calc_equivalent_lossfact(measurement(1e-8, 1e-10), measurement(1e-10, 1e-12), measurement(2 * pi * 50, 0.1)) + @test r2 isa Measurement{Float64} + # Mixed: G_eq as Measurement + r3 = calc_equivalent_lossfact(measurement(1e-8, 1e-10), 1e-10, 2 * pi * 50) + @test r3 isa Measurement{Float64} + # Mixed: C_eq as Measurement + r4 = calc_equivalent_lossfact(1e-8, measurement(1e-10, 1e-12), 2 * pi * 50) + @test r4 isa Measurement{Float64} + # Mixed: ω as Measurement + r5 = calc_equivalent_lossfact(1e-8, 1e-10, measurement(2 * pi * 50, 0.1)) + @test r5 isa Measurement{Float64} + end + + @testset "Uncertainty Quantification" begin + using Measurements + G_eq = measurement(1e-8, 1e-10) + C_eq = measurement(1e-10, 1e-12) + ω = measurement(2 * pi * 50, 0.1) + result = calc_equivalent_lossfact(G_eq, C_eq, ω) + @test result isa Measurement{Float64} + @test uncertainty(result) > 0 + end +end diff --git a/test/unit_BaseParams/test_calc_equivalent_mu.jl b/test/unit_BaseParams/test_calc_equivalent_mu.jl index 0ce1d0ab..7bfa08f7 100644 --- a/test/unit_BaseParams/test_calc_equivalent_mu.jl +++ b/test/unit_BaseParams/test_calc_equivalent_mu.jl @@ -1,103 +1,103 @@ -@testitem "BaseParams: calc_equivalent_mu unit tests" setup = [defaults] begin - @testset "Basic Functionality" begin - # Example from docstring - gmr = 0.015 - radius_ext = 0.02 - radius_in = 0.01 - mu_r = calc_equivalent_mu(gmr, radius_ext, radius_in) - @test isapprox(mu_r, 1.79409188, atol=TEST_TOL) - - # Solid conductor (radius_in = 0) - radius_ext_solid = 0.0135 - radius_in_solid = 0.0 - gmr_solid = calc_tubular_gmr(radius_ext_solid, radius_in_solid, 1.0) - mu_r_solid = calc_equivalent_mu(gmr_solid, radius_ext_solid, radius_in_solid) - @test isapprox(mu_r_solid, 1.0, atol=TEST_TOL) - radius_ext = -0.01 - radius_in = 0.01 - @test_throws ArgumentError calc_equivalent_mu(gmr, radius_ext, radius_in) - end - - @testset "Edge Cases" begin - # Collapsing geometry: radius_in -> radius_ext, should be 0 if == gmr - gmr = 0.02 - radius_ext = 0.02 - radius_in = 0.02 - mu_r = calc_equivalent_mu(gmr, radius_ext, radius_in) - @test isapprox(mu_r, 0.0, atol=TEST_TOL) - - # Very large radii - gmr = 1e3 - radius_ext = 1e3 - radius_in = 1e2 - mu_r = calc_equivalent_mu(gmr, radius_ext, radius_in) - @test isfinite(mu_r) - - # Inf/NaN input - @test isnan(calc_equivalent_mu(NaN, 0.02, 0.01)) - @test isnan(calc_equivalent_mu(0.015, NaN, 0.01)) - @test isnan(calc_equivalent_mu(0.015, 0.02, NaN)) - end - - @testset "Numerical Consistency" begin - # Float32 vs Float64 - gmr = Float32(0.015) - radius_ext = Float32(0.02) - radius_in = Float32(0.01) - mu_r_f32 = calc_equivalent_mu(gmr, radius_ext, radius_in) - mu_r_f64 = calc_equivalent_mu(Float64(gmr), Float64(radius_ext), Float64(radius_in)) - @test isapprox(mu_r_f32, mu_r_f64, atol=TEST_TOL) - end - - @testset "Physical Behavior" begin - # mu_r increases as gmr decreases (for fixed radii) - mu1 = calc_equivalent_mu(0.015, 0.02, 0.01) - mu2 = calc_equivalent_mu(0.012, 0.02, 0.01) - @test mu2 > mu1 - # mu_r decreases as gmr increases - mu3 = calc_equivalent_mu(0.018, 0.02, 0.01) - @test mu3 < mu1 - end - - @testset "Type Stability & Promotion" begin - using Measurements - gmr = 0.015 - radius_ext = 0.02 - radius_in = 0.01 - mgmr = measurement(gmr, 1e-4) - mrex = measurement(radius_ext, 1e-4) - mrin = measurement(radius_in, 1e-4) - - # All Float64 - res1 = calc_equivalent_mu(gmr, radius_ext, radius_in) - @test typeof(res1) == Float64 - # All Measurement - res2 = calc_equivalent_mu(mgmr, mrex, mrin) - @test res2 isa Measurement{Float64} - # Mixed: first argument Measurement - res3 = calc_equivalent_mu(mgmr, radius_ext, radius_in) - @test res3 isa Measurement{Float64} - # Mixed: second argument Measurement - res4 = calc_equivalent_mu(gmr, mrex, radius_in) - @test res4 isa Measurement{Float64} - # Mixed: third argument Measurement - res5 = calc_equivalent_mu(gmr, radius_ext, mrin) - @test res5 isa Measurement{Float64} - end - - @testset "Uncertainty Quantification" begin - using Measurements - gmr = measurement(0.015, 1e-4) - radius_ext = measurement(0.02, 1e-4) - radius_in = measurement(0.01, 1e-4) - mu_r = calc_equivalent_mu(gmr, radius_ext, radius_in) - # Should propagate uncertainty - @test mu_r isa Measurement{Float64} - @test uncertainty(mu_r) > 0 - end - - @testset "Error Handling" begin - # Only error thrown is for radius_ext < radius_in - @test_throws ArgumentError calc_equivalent_mu(0.015, 0.01, 0.02) - end -end +@testitem "BaseParams: calc_equivalent_mu unit tests" setup = [defaults] begin + @testset "Basic Functionality" begin + # Example from docstring + gmr = 0.015 + radius_ext = 0.02 + radius_in = 0.01 + mu_r = calc_equivalent_mu(gmr, radius_ext, radius_in) + @test isapprox(mu_r, 1.79409188, atol=TEST_TOL) + + # Solid conductor (radius_in = 0) + radius_ext_solid = 0.0135 + radius_in_solid = 0.0 + gmr_solid = calc_tubular_gmr(radius_ext_solid, radius_in_solid, 1.0) + mu_r_solid = calc_equivalent_mu(gmr_solid, radius_ext_solid, radius_in_solid) + @test isapprox(mu_r_solid, 1.0, atol=TEST_TOL) + radius_ext = -0.01 + radius_in = 0.01 + @test_throws ArgumentError calc_equivalent_mu(gmr, radius_ext, radius_in) + end + + @testset "Edge Cases" begin + # Collapsing geometry: radius_in -> radius_ext, should be 0 if == gmr + gmr = 0.02 + radius_ext = 0.02 + radius_in = 0.02 + mu_r = calc_equivalent_mu(gmr, radius_ext, radius_in) + @test isapprox(mu_r, 0.0, atol=TEST_TOL) + + # Very large radii + gmr = 1e3 + radius_ext = 1e3 + radius_in = 1e2 + mu_r = calc_equivalent_mu(gmr, radius_ext, radius_in) + @test isfinite(mu_r) + + # Inf/NaN input + @test isnan(calc_equivalent_mu(NaN, 0.02, 0.01)) + @test isnan(calc_equivalent_mu(0.015, NaN, 0.01)) + @test isnan(calc_equivalent_mu(0.015, 0.02, NaN)) + end + + @testset "Numerical Consistency" begin + # Float32 vs Float64 + gmr = Float32(0.015) + radius_ext = Float32(0.02) + radius_in = Float32(0.01) + mu_r_f32 = calc_equivalent_mu(gmr, radius_ext, radius_in) + mu_r_f64 = calc_equivalent_mu(Float64(gmr), Float64(radius_ext), Float64(radius_in)) + @test isapprox(mu_r_f32, mu_r_f64, atol=TEST_TOL) + end + + @testset "Physical Behavior" begin + # mu_r increases as gmr decreases (for fixed radii) + mu1 = calc_equivalent_mu(0.015, 0.02, 0.01) + mu2 = calc_equivalent_mu(0.012, 0.02, 0.01) + @test mu2 > mu1 + # mu_r decreases as gmr increases + mu3 = calc_equivalent_mu(0.018, 0.02, 0.01) + @test mu3 < mu1 + end + + @testset "Type Stability & Promotion" begin + using Measurements + gmr = 0.015 + radius_ext = 0.02 + radius_in = 0.01 + mgmr = measurement(gmr, 1e-4) + mrex = measurement(radius_ext, 1e-4) + mrin = measurement(radius_in, 1e-4) + + # All Float64 + res1 = calc_equivalent_mu(gmr, radius_ext, radius_in) + @test typeof(res1) == Float64 + # All Measurement + res2 = calc_equivalent_mu(mgmr, mrex, mrin) + @test res2 isa Measurement{Float64} + # Mixed: first argument Measurement + res3 = calc_equivalent_mu(mgmr, radius_ext, radius_in) + @test res3 isa Measurement{Float64} + # Mixed: second argument Measurement + res4 = calc_equivalent_mu(gmr, mrex, radius_in) + @test res4 isa Measurement{Float64} + # Mixed: third argument Measurement + res5 = calc_equivalent_mu(gmr, radius_ext, mrin) + @test res5 isa Measurement{Float64} + end + + @testset "Uncertainty Quantification" begin + using Measurements + gmr = measurement(0.015, 1e-4) + radius_ext = measurement(0.02, 1e-4) + radius_in = measurement(0.01, 1e-4) + mu_r = calc_equivalent_mu(gmr, radius_ext, radius_in) + # Should propagate uncertainty + @test mu_r isa Measurement{Float64} + @test uncertainty(mu_r) > 0 + end + + @testset "Error Handling" begin + # Only error thrown is for radius_ext < radius_in + @test_throws ArgumentError calc_equivalent_mu(0.015, 0.01, 0.02) + end +end diff --git a/test/unit_BaseParams/test_calc_equivalent_rho.jl b/test/unit_BaseParams/test_calc_equivalent_rho.jl index 3a220314..66d7e5d9 100644 --- a/test/unit_BaseParams/test_calc_equivalent_rho.jl +++ b/test/unit_BaseParams/test_calc_equivalent_rho.jl @@ -1,73 +1,73 @@ -@testitem "BaseParams: calc_equivalent_rho unit tests" setup = [defaults] begin - @testset "Basic Functionality" begin - # Example from docstring: R=0.01 Ω, r_ext=0.02 m, r_in=0.01 m - result = calc_equivalent_rho(0.01, 0.02, 0.01) - expected = 0.01 * π * (0.02^2 - 0.01^2) - @test isapprox(result, expected; atol=TEST_TOL) - @test result > 0 - end - - @testset "Edge Cases" begin - # Zero resistance - result = calc_equivalent_rho(0.0, 0.02, 0.01) - @test isapprox(result, 0.0; atol=TEST_TOL) - # Zero thickness (r_ext == r_in) - result = calc_equivalent_rho(0.01, 0.01, 0.01) - @test isapprox(result, 0.0; atol=TEST_TOL) - # Very large radii - result = calc_equivalent_rho(0.01, 1e6, 1e3) - expected = 0.01 * π * (1e6^2 - 1e3^2) - @test isapprox(result, expected; atol=TEST_TOL) - # Inf/NaN - @test isnan(calc_equivalent_rho(NaN, 0.02, 0.01)) - @test isnan(calc_equivalent_rho(0.01, NaN, 0.01)) - @test isnan(calc_equivalent_rho(0.01, 0.02, NaN)) - @test isinf(calc_equivalent_rho(Inf, 0.02, 0.01)) - end - - @testset "Numerical Consistency" begin - # Float32 vs Float64 - r = calc_equivalent_rho(Float32(0.01), Float32(0.02), Float32(0.01)) - d = calc_equivalent_rho(0.01, 0.02, 0.01) - @test isapprox(r, d; atol=TEST_TOL) - end - - @testset "Physical Behavior" begin - # Increases with resistance - r1 = calc_equivalent_rho(0.01, 0.02, 0.01) - r2 = calc_equivalent_rho(0.02, 0.02, 0.01) - @test r2 > r1 - # Increases with area - r3 = calc_equivalent_rho(0.01, 0.03, 0.01) - @test r3 > r1 - end - - @testset "Type Stability & Promotion" begin - using Measurements - # All Float64 - r1 = calc_equivalent_rho(0.01, 0.02, 0.01) - @test typeof(r1) == Float64 - # All Measurement - r2 = calc_equivalent_rho(measurement(0.01, 1e-4), measurement(0.02, 1e-5), measurement(0.01, 1e-5)) - @test r2 isa Measurement{Float64} - # Mixed: R as Measurement - r3 = calc_equivalent_rho(measurement(0.01, 1e-4), 0.02, 0.01) - @test r3 isa Measurement{Float64} - # Mixed: radius_ext_con as Measurement - r4 = calc_equivalent_rho(0.01, measurement(0.02, 1e-5), 0.01) - @test r4 isa Measurement{Float64} - # Mixed: radius_in_con as Measurement - r5 = calc_equivalent_rho(0.01, 0.02, measurement(0.01, 1e-5)) - @test r5 isa Measurement{Float64} - end - - @testset "Uncertainty Quantification" begin - using Measurements - R = measurement(0.01, 1e-4) - r_ext = measurement(0.02, 1e-5) - r_in = measurement(0.01, 1e-5) - result = calc_equivalent_rho(R, r_ext, r_in) - @test result isa Measurement{Float64} - @test uncertainty(result) > 0 - end -end +@testitem "BaseParams: calc_equivalent_rho unit tests" setup = [defaults] begin + @testset "Basic Functionality" begin + # Example from docstring: R=0.01 Ω, r_ext=0.02 m, r_in=0.01 m + result = calc_equivalent_rho(0.01, 0.02, 0.01) + expected = 0.01 * π * (0.02^2 - 0.01^2) + @test isapprox(result, expected; atol=TEST_TOL) + @test result > 0 + end + + @testset "Edge Cases" begin + # Zero resistance + result = calc_equivalent_rho(0.0, 0.02, 0.01) + @test isapprox(result, 0.0; atol=TEST_TOL) + # Zero thickness (r_ext == r_in) + result = calc_equivalent_rho(0.01, 0.01, 0.01) + @test isapprox(result, 0.0; atol=TEST_TOL) + # Very large radii + result = calc_equivalent_rho(0.01, 1e6, 1e3) + expected = 0.01 * π * (1e6^2 - 1e3^2) + @test isapprox(result, expected; atol=TEST_TOL) + # Inf/NaN + @test isnan(calc_equivalent_rho(NaN, 0.02, 0.01)) + @test isnan(calc_equivalent_rho(0.01, NaN, 0.01)) + @test isnan(calc_equivalent_rho(0.01, 0.02, NaN)) + @test isinf(calc_equivalent_rho(Inf, 0.02, 0.01)) + end + + @testset "Numerical Consistency" begin + # Float32 vs Float64 + r = calc_equivalent_rho(Float32(0.01), Float32(0.02), Float32(0.01)) + d = calc_equivalent_rho(0.01, 0.02, 0.01) + @test isapprox(r, d; atol=TEST_TOL) + end + + @testset "Physical Behavior" begin + # Increases with resistance + r1 = calc_equivalent_rho(0.01, 0.02, 0.01) + r2 = calc_equivalent_rho(0.02, 0.02, 0.01) + @test r2 > r1 + # Increases with area + r3 = calc_equivalent_rho(0.01, 0.03, 0.01) + @test r3 > r1 + end + + @testset "Type Stability & Promotion" begin + using Measurements + # All Float64 + r1 = calc_equivalent_rho(0.01, 0.02, 0.01) + @test typeof(r1) == Float64 + # All Measurement + r2 = calc_equivalent_rho(measurement(0.01, 1e-4), measurement(0.02, 1e-5), measurement(0.01, 1e-5)) + @test r2 isa Measurement{Float64} + # Mixed: R as Measurement + r3 = calc_equivalent_rho(measurement(0.01, 1e-4), 0.02, 0.01) + @test r3 isa Measurement{Float64} + # Mixed: radius_ext_con as Measurement + r4 = calc_equivalent_rho(0.01, measurement(0.02, 1e-5), 0.01) + @test r4 isa Measurement{Float64} + # Mixed: radius_in_con as Measurement + r5 = calc_equivalent_rho(0.01, 0.02, measurement(0.01, 1e-5)) + @test r5 isa Measurement{Float64} + end + + @testset "Uncertainty Quantification" begin + using Measurements + R = measurement(0.01, 1e-4) + r_ext = measurement(0.02, 1e-5) + r_in = measurement(0.01, 1e-5) + result = calc_equivalent_rho(R, r_ext, r_in) + @test result isa Measurement{Float64} + @test uncertainty(result) > 0 + end +end diff --git a/test/unit_BaseParams/test_calc_gmd.jl b/test/unit_BaseParams/test_calc_gmd.jl index 7ebb8158..3611f465 100644 --- a/test/unit_BaseParams/test_calc_gmd.jl +++ b/test/unit_BaseParams/test_calc_gmd.jl @@ -1,79 +1,79 @@ -@testitem "BaseParams: calc_gmd unit tests" setup = [defaults, deps_datamodel, defs_materials] begin - @testset "Basic Functionality" begin - material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) - wire_array = WireArray(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) - tubular = Tubular(0.01, 0.02, material_props, temperature=25) - gmd = calc_gmd(wire_array, tubular) - @test gmd > 0 - # Symmetry - gmd2 = calc_gmd(tubular, wire_array) - @test isapprox(gmd, gmd2, atol=TEST_TOL) - end - - @testset "Edge Cases" begin - material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) - # Identical objects (should return outer radius) - tubular = Tubular(0.01, 0.02, material_props, temperature=25) - gmd_same = calc_gmd(tubular, tubular) - @test isapprox(gmd_same, 0.02, atol=TEST_TOL) - # WireArray with itself - wire_array = WireArray(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) - gmd_wa = calc_gmd(wire_array, wire_array) - @test gmd_wa > 0 - end - - @testset "Numerical Consistency" begin - material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) - wire_array_f32 = WireArray(Float32(0.01), Diameter(Float32(0.002)), 7, Float32(10), material_props, temperature=25) - tubular_f32 = Tubular(Float32(0.01), Float32(0.02), material_props, temperature=25) - gmd_f32 = calc_gmd(wire_array_f32, tubular_f32) - wire_array_f64 = WireArray(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) - tubular_f64 = Tubular(0.01, 0.02, material_props, temperature=25) - gmd_f64 = calc_gmd(wire_array_f64, tubular_f64) - @test isapprox(gmd_f32, gmd_f64, atol=TEST_TOL) - end - - @testset "Physical Behavior" begin - material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) - wa1 = WireArray(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) - wa2 = WireArray(0.02, Diameter(0.002), 7, 10, material_props, temperature=25) - tubular = Tubular(0.01, 0.02, material_props, temperature=25) - gmd1 = calc_gmd(wa1, tubular) - gmd2 = calc_gmd(wa2, tubular) - @test gmd2 > gmd1 - end - - @testset "Type Stability & Promotion" begin - material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) - wa = WireArray(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) - tub = Tubular(0.01, 0.02, material_props, temperature=25) - mwa = WireArray(0.01, Diameter(measurement(0.002, 1e-4)), 7, 10, material_props, temperature=25) - mtub = Tubular(0.01, measurement(0.02, 1e-4), material_props, temperature=25) - # All Float64 - res1 = calc_gmd(wa, tub) - @test typeof(res1) == Float64 - # All Measurement - res2 = calc_gmd(mwa, mtub) - @test res2 isa Measurement{Float64} - # Mixed: first argument Measurement - res3 = calc_gmd(mwa, tub) - @test res3 isa Measurement{Float64} - # Mixed: second argument Measurement - res4 = calc_gmd(wa, mtub) - @test res4 isa Measurement{Float64} - mtub_temp = Tubular(0.01, 0.02, material_props, temperature=measurement(25, 1e-4)) - res5 = calc_gmd(wa, mtub_temp) - @test res5 isa Measurement{Float64} - end - - @testset "Uncertainty Quantification" begin - material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393) - wa = WireArray(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) - tub = Tubular(0.01, 0.02, material_props, temperature=25) - mwa = WireArray(measurement(0.01, 1e-4), Diameter(0.002), 7, 10, material_props, temperature=25) - mtub = Tubular(0.01, measurement(0.02, 1e-4), material_props, temperature=25) - gmd = calc_gmd(mwa, mtub) - @test gmd isa Measurement{Float64} - @test uncertainty(gmd) > 0 - end -end +@testitem "BaseParams: calc_gmd unit tests" setup = [defaults, deps_datamodel, defs_materials] begin + @testset "Basic Functionality" begin + material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0) + wire_array = WireArray(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) + tubular = Tubular(0.01, 0.02, material_props, temperature=25) + gmd = calc_gmd(wire_array, tubular) + @test gmd > 0 + # Symmetry + gmd2 = calc_gmd(tubular, wire_array) + @test isapprox(gmd, gmd2, atol=TEST_TOL) + end + + @testset "Edge Cases" begin + material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0) + # Identical objects (should return outer radius) + tubular = Tubular(0.01, 0.02, material_props, temperature=25) + gmd_same = calc_gmd(tubular, tubular) + @test isapprox(gmd_same, 0.02, atol=TEST_TOL) + # WireArray with itself + wire_array = WireArray(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) + gmd_wa = calc_gmd(wire_array, wire_array) + @test gmd_wa > 0 + end + + @testset "Numerical Consistency" begin + material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0) + wire_array_f32 = WireArray(Float32(0.01), Diameter(Float32(0.002)), 7, Float32(10), material_props, temperature=25) + tubular_f32 = Tubular(Float32(0.01), Float32(0.02), material_props, temperature=25) + gmd_f32 = calc_gmd(wire_array_f32, tubular_f32) + wire_array_f64 = WireArray(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) + tubular_f64 = Tubular(0.01, 0.02, material_props, temperature=25) + gmd_f64 = calc_gmd(wire_array_f64, tubular_f64) + @test isapprox(gmd_f32, gmd_f64, atol=TEST_TOL) + end + + @testset "Physical Behavior" begin + material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0) + wa1 = WireArray(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) + wa2 = WireArray(0.02, Diameter(0.002), 7, 10, material_props, temperature=25) + tubular = Tubular(0.01, 0.02, material_props, temperature=25) + gmd1 = calc_gmd(wa1, tubular) + gmd2 = calc_gmd(wa2, tubular) + @test gmd2 > gmd1 + end + + @testset "Type Stability & Promotion" begin + material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0) + wa = WireArray(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) + tub = Tubular(0.01, 0.02, material_props, temperature=25) + mwa = WireArray(0.01, Diameter(measurement(0.002, 1e-4)), 7, 10, material_props, temperature=25) + mtub = Tubular(0.01, measurement(0.02, 1e-4), material_props, temperature=25) + # All Float64 + res1 = calc_gmd(wa, tub) + @test typeof(res1) == Float64 + # All Measurement + res2 = calc_gmd(mwa, mtub) + @test res2 isa Measurement{Float64} + # Mixed: first argument Measurement + res3 = calc_gmd(mwa, tub) + @test res3 isa Measurement{Float64} + # Mixed: second argument Measurement + res4 = calc_gmd(wa, mtub) + @test res4 isa Measurement{Float64} + mtub_temp = Tubular(0.01, 0.02, material_props, temperature=measurement(25, 1e-4)) + res5 = calc_gmd(wa, mtub_temp) + @test res5 isa Measurement{Float64} + end + + @testset "Uncertainty Quantification" begin + material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393, 401.0) + wa = WireArray(0.01, Diameter(0.002), 7, 10, material_props, temperature=25) + tub = Tubular(0.01, 0.02, material_props, temperature=25) + mwa = WireArray(measurement(0.01, 1e-4), Diameter(0.002), 7, 10, material_props, temperature=25) + mtub = Tubular(0.01, measurement(0.02, 1e-4), material_props, temperature=25) + gmd = calc_gmd(mwa, mtub) + @test gmd isa Measurement{Float64} + @test uncertainty(gmd) > 0 + end +end diff --git a/test/unit_BaseParams/test_calc_helical_params.jl b/test/unit_BaseParams/test_calc_helical_params.jl index 196b2305..2a285690 100644 --- a/test/unit_BaseParams/test_calc_helical_params.jl +++ b/test/unit_BaseParams/test_calc_helical_params.jl @@ -1,90 +1,90 @@ -@testitem "BaseParams: calc_helical_params unit tests" setup = [defaults] begin - using Measurements - # --- Basic Functionality --- - @testset "Basic Functionality" begin - radius_in = 0.01 - radius_ext = 0.015 - lay_ratio = 12.0 - mean_diam, pitch, overlength = calc_helical_params(radius_in, radius_ext, lay_ratio) - @test isapprox(mean_diam, 0.025, atol=TEST_TOL) - @test isapprox(pitch, 0.3, atol=TEST_TOL) - @test overlength > 1.0 - end - - # --- Edge Cases --- - @testset "Edge Cases" begin - # Zero lay ratio (pitch_length = 0) - m, p, o = calc_helical_params(0.01, 0.015, 0.0) - @test isapprox(m, 0.025, atol=TEST_TOL) - @test isapprox(p, 0.0, atol=TEST_TOL) - @test isapprox(o, 1.0, atol=TEST_TOL) - - # Collapsing geometry (radius_in == radius_ext) - m2, p2, o2 = calc_helical_params(0.02, 0.02, 10.0) - @test isapprox(m2, 0.04, atol=TEST_TOL) - @test isapprox(p2, 0.4, atol=TEST_TOL) - @test o2 > 1.0 - - # Very large lay ratio - m3, p3, o3 = calc_helical_params(0.01, 0.015, 1e6) - @test isapprox(m3, 0.025, atol=TEST_TOL) - @test isapprox(p3, 25000.0, atol=TEST_TOL) - @test isapprox(o3, 1.0, atol=TEST_TOL) - end - - # --- Numerical Consistency --- - @testset "Numerical Consistency" begin - # Float32 - m, p, o = calc_helical_params(Float32(0.01), Float32(0.015), Float32(12.0)) - @test isapprox(m, 0.025, atol=TEST_TOL) - @test isapprox(p, 0.3, atol=TEST_TOL) - @test o > 1.0 - end - - # --- Physical Behavior --- - @testset "Physical Behavior" begin - # Increasing lay_ratio increases pitch_length - _, p1, _ = calc_helical_params(0.01, 0.015, 10.0) - _, p2, _ = calc_helical_params(0.01, 0.015, 20.0) - @test p2 > p1 - # Overlength approaches 1 as lay_ratio increases - _, _, o1 = calc_helical_params(0.01, 0.015, 1e3) - @test isapprox(o1, 1.0, atol=1e-5) - end - - # --- Type Stability & Promotion --- - @testset "Type Stability & Promotion" begin - # All Float64 - m, p, o = calc_helical_params(0.01, 0.015, 12.0) - @test typeof(m) == Float64 - @test typeof(p) == Float64 - @test typeof(o) == Float64 - # All Measurement - mM, pM, oM = calc_helical_params(measurement(0.01, 1e-5), measurement(0.015, 1e-5), measurement(12.0, 0.1)) - @test mM isa Measurement{Float64} - @test pM isa Measurement{Float64} - @test oM isa Measurement{Float64} - # Mixed: radius_in as Measurement - m1, p1, o1 = calc_helical_params(measurement(0.01, 1e-5), 0.015, 12.0) - @test m1 isa Measurement{Float64} - @test p1 isa Measurement{Float64} - @test o1 isa Measurement{Float64} - # Mixed: lay_ratio as Measurement - m2, p2, o2 = calc_helical_params(0.01, 0.015, measurement(12.0, 0.1)) - @test m2 isa Measurement{Float64} - @test p2 isa Measurement{Float64} - @test o2 isa Measurement{Float64} - end - - # --- Uncertainty Quantification --- - @testset "Uncertainty Quantification" begin - rin = measurement(0.01, 1e-5) - rext = measurement(0.015, 1e-5) - lrat = measurement(12.0, 0.1) - m, p, o = calc_helical_params(rin, rext, lrat) - # Check propagated uncertainties are nonzero - @test uncertainty(m) > 0 - @test uncertainty(p) > 0 - @test uncertainty(o) > 0 - end -end +@testitem "BaseParams: calc_helical_params unit tests" setup = [defaults] begin + using Measurements + # --- Basic Functionality --- + @testset "Basic Functionality" begin + radius_in = 0.01 + radius_ext = 0.015 + lay_ratio = 12.0 + mean_diam, pitch, overlength = calc_helical_params(radius_in, radius_ext, lay_ratio) + @test isapprox(mean_diam, 0.025, atol=TEST_TOL) + @test isapprox(pitch, 0.3, atol=TEST_TOL) + @test overlength > 1.0 + end + + # --- Edge Cases --- + @testset "Edge Cases" begin + # Zero lay ratio (pitch_length = 0) + m, p, o = calc_helical_params(0.01, 0.015, 0.0) + @test isapprox(m, 0.025, atol=TEST_TOL) + @test isapprox(p, 0.0, atol=TEST_TOL) + @test isapprox(o, 1.0, atol=TEST_TOL) + + # Collapsing geometry (radius_in == radius_ext) + m2, p2, o2 = calc_helical_params(0.02, 0.02, 10.0) + @test isapprox(m2, 0.04, atol=TEST_TOL) + @test isapprox(p2, 0.4, atol=TEST_TOL) + @test o2 > 1.0 + + # Very large lay ratio + m3, p3, o3 = calc_helical_params(0.01, 0.015, 1e6) + @test isapprox(m3, 0.025, atol=TEST_TOL) + @test isapprox(p3, 25000.0, atol=TEST_TOL) + @test isapprox(o3, 1.0, atol=TEST_TOL) + end + + # --- Numerical Consistency --- + @testset "Numerical Consistency" begin + # Float32 + m, p, o = calc_helical_params(Float32(0.01), Float32(0.015), Float32(12.0)) + @test isapprox(m, 0.025, atol=TEST_TOL) + @test isapprox(p, 0.3, atol=TEST_TOL) + @test o > 1.0 + end + + # --- Physical Behavior --- + @testset "Physical Behavior" begin + # Increasing lay_ratio increases pitch_length + _, p1, _ = calc_helical_params(0.01, 0.015, 10.0) + _, p2, _ = calc_helical_params(0.01, 0.015, 20.0) + @test p2 > p1 + # Overlength approaches 1 as lay_ratio increases + _, _, o1 = calc_helical_params(0.01, 0.015, 1e3) + @test isapprox(o1, 1.0, atol=1e-5) + end + + # --- Type Stability & Promotion --- + @testset "Type Stability & Promotion" begin + # All Float64 + m, p, o = calc_helical_params(0.01, 0.015, 12.0) + @test typeof(m) == Float64 + @test typeof(p) == Float64 + @test typeof(o) == Float64 + # All Measurement + mM, pM, oM = calc_helical_params(measurement(0.01, 1e-5), measurement(0.015, 1e-5), measurement(12.0, 0.1)) + @test mM isa Measurement{Float64} + @test pM isa Measurement{Float64} + @test oM isa Measurement{Float64} + # Mixed: radius_in as Measurement + m1, p1, o1 = calc_helical_params(measurement(0.01, 1e-5), 0.015, 12.0) + @test m1 isa Measurement{Float64} + @test p1 isa Measurement{Float64} + @test o1 isa Measurement{Float64} + # Mixed: lay_ratio as Measurement + m2, p2, o2 = calc_helical_params(0.01, 0.015, measurement(12.0, 0.1)) + @test m2 isa Measurement{Float64} + @test p2 isa Measurement{Float64} + @test o2 isa Measurement{Float64} + end + + # --- Uncertainty Quantification --- + @testset "Uncertainty Quantification" begin + rin = measurement(0.01, 1e-5) + rext = measurement(0.015, 1e-5) + lrat = measurement(12.0, 0.1) + m, p, o = calc_helical_params(rin, rext, lrat) + # Check propagated uncertainties are nonzero + @test uncertainty(m) > 0 + @test uncertainty(p) > 0 + @test uncertainty(o) > 0 + end +end diff --git a/test/unit_BaseParams/test_calc_inductance_trifoil.jl b/test/unit_BaseParams/test_calc_inductance_trifoil.jl index 87f3e4e9..3fbbf076 100644 --- a/test/unit_BaseParams/test_calc_inductance_trifoil.jl +++ b/test/unit_BaseParams/test_calc_inductance_trifoil.jl @@ -1,88 +1,88 @@ -# test/unit_BaseParams/test_calc_inductance_trifoil.jl - -@testitem "BaseParams: calc_inductance_trifoil unit tests" setup = [defaults] begin - - #= - ## Test Case Setup - Parameters are explicitly separated into positional and keyword arguments - to match the function signature. This makes all test calls clean and robust. - =# - const CANONICAL_POS_ARGS = ( - r_in_co=10e-3, - r_ext_co=15e-3, - rho_co=1.72e-8, - mu_r_co=1.0, - r_in_scr=20e-3, - r_ext_scr=25e-3, - rho_scr=2.82e-8, - mu_r_scr=1.0, - S=100e-3, - ) - - const CANONICAL_KW_ARGS = (rho_e=100.0, f=50.0) - - @testset "Basic functionality: canonical example" begin - L = calc_inductance_trifoil(values(CANONICAL_POS_ARGS)...; CANONICAL_KW_ARGS...) - expected_L = 1.573964832699787e-7 # H/m - @test L ≈ expected_L atol = TEST_TOL - end - - @testset "Physical behavior" begin - L_base = calc_inductance_trifoil(values(CANONICAL_POS_ARGS)...; CANONICAL_KW_ARGS...) - - pos_args_better_screen = merge(CANONICAL_POS_ARGS, (rho_scr=CANONICAL_POS_ARGS.rho_scr / 10,)) - L_better_screen = calc_inductance_trifoil(values(pos_args_better_screen)...; CANONICAL_KW_ARGS...) - @test L_better_screen < L_base - - pos_args_higher_mu = merge(CANONICAL_POS_ARGS, (mu_r_co=CANONICAL_POS_ARGS.mu_r_co * 2,)) - L_higher_mu = calc_inductance_trifoil(values(pos_args_higher_mu)...; CANONICAL_KW_ARGS...) - @test L_higher_mu > L_base - - # Override a keyword argument directly in the call - L_60Hz = calc_inductance_trifoil(values(CANONICAL_POS_ARGS)...; CANONICAL_KW_ARGS..., f=60.0) - @test L_60Hz < L_base - end - - @testset "Edge cases" begin - - pos_args_solid_core = merge(CANONICAL_POS_ARGS, (r_in_co=0.0,)) - L_solid_core = calc_inductance_trifoil(values(pos_args_solid_core)...; CANONICAL_KW_ARGS...) - @test isfinite(L_solid_core) - @test L_solid_core > 0.0 - - pos_args_perfect_screen = merge(CANONICAL_POS_ARGS, (rho_scr=0.0,)) - L_perfect_screen = calc_inductance_trifoil(values(pos_args_perfect_screen)...; CANONICAL_KW_ARGS...) - @test isfinite(L_perfect_screen) - @test L_perfect_screen < calc_inductance_trifoil(values(CANONICAL_POS_ARGS)...; CANONICAL_KW_ARGS...) - end - - @testset "Type stability and promotion with Measurements.jl" begin - # Base values - p_pos = CANONICAL_POS_ARGS - p_kw = CANONICAL_KW_ARGS - - # Create Measurement versions - p_pos_meas = map(x -> x ± (x * 0.01), p_pos) - p_kw_meas = map(x -> x ± (x * 0.01), p_kw) - - # Float case - L_float = calc_inductance_trifoil(values(p_pos)...; p_kw...) - @test L_float isa Float64 - - # Fully promoted case - L_meas_all = calc_inductance_trifoil(values(p_pos_meas)...; p_kw_meas...) - @test L_meas_all isa Measurement{Float64} - @test L_meas_all.val ≈ L_float atol = TEST_TOL - @test L_meas_all.err > 0.0 - - # Mixed case (manual call for clarity) - L_meas_rho_e = calc_inductance_trifoil( - p_pos.r_in_co ± 0.0, p_pos.r_ext_co, p_pos.rho_co, p_pos.mu_r_co, - p_pos.r_in_scr, p_pos.r_ext_scr, p_pos.rho_scr, p_pos.mu_r_scr, p_pos.S; - rho_e=p_kw.rho_e ± 10.0, f=p_kw.f - ) - @test L_meas_rho_e isa Measurement{Float64} - @test L_meas_rho_e.val ≈ L_float atol = TEST_TOL - @test L_meas_rho_e.err > 0.0 - end +# test/unit_BaseParams/test_calc_inductance_trifoil.jl + +@testitem "BaseParams: calc_inductance_trifoil unit tests" setup = [defaults] begin + + #= + ## Test Case Setup + Parameters are explicitly separated into positional and keyword arguments + to match the function signature. This makes all test calls clean and robust. + =# + const CANONICAL_POS_ARGS = ( + r_in_co=10e-3, + r_ext_co=15e-3, + rho_co=1.72e-8, + mu_r_co=1.0, + r_in_scr=20e-3, + r_ext_scr=25e-3, + rho_scr=2.82e-8, + mu_r_scr=1.0, + S=100e-3, + ) + + const CANONICAL_KW_ARGS = (rho_e=100.0, f=50.0) + + @testset "Basic functionality: canonical example" begin + L = calc_inductance_trifoil(values(CANONICAL_POS_ARGS)...; CANONICAL_KW_ARGS...) + expected_L = 1.573964832699787e-7 # H/m + @test L ≈ expected_L atol = TEST_TOL + end + + @testset "Physical behavior" begin + L_base = calc_inductance_trifoil(values(CANONICAL_POS_ARGS)...; CANONICAL_KW_ARGS...) + + pos_args_better_screen = merge(CANONICAL_POS_ARGS, (rho_scr=CANONICAL_POS_ARGS.rho_scr / 10,)) + L_better_screen = calc_inductance_trifoil(values(pos_args_better_screen)...; CANONICAL_KW_ARGS...) + @test L_better_screen < L_base + + pos_args_higher_mu = merge(CANONICAL_POS_ARGS, (mu_r_co=CANONICAL_POS_ARGS.mu_r_co * 2,)) + L_higher_mu = calc_inductance_trifoil(values(pos_args_higher_mu)...; CANONICAL_KW_ARGS...) + @test L_higher_mu > L_base + + # Override a keyword argument directly in the call + L_60Hz = calc_inductance_trifoil(values(CANONICAL_POS_ARGS)...; CANONICAL_KW_ARGS..., f=60.0) + @test L_60Hz < L_base + end + + @testset "Edge cases" begin + + pos_args_solid_core = merge(CANONICAL_POS_ARGS, (r_in_co=0.0,)) + L_solid_core = calc_inductance_trifoil(values(pos_args_solid_core)...; CANONICAL_KW_ARGS...) + @test isfinite(L_solid_core) + @test L_solid_core > 0.0 + + pos_args_perfect_screen = merge(CANONICAL_POS_ARGS, (rho_scr=0.0,)) + L_perfect_screen = calc_inductance_trifoil(values(pos_args_perfect_screen)...; CANONICAL_KW_ARGS...) + @test isfinite(L_perfect_screen) + @test L_perfect_screen < calc_inductance_trifoil(values(CANONICAL_POS_ARGS)...; CANONICAL_KW_ARGS...) + end + + @testset "Type stability and promotion with Measurements.jl" begin + # Base values + p_pos = CANONICAL_POS_ARGS + p_kw = CANONICAL_KW_ARGS + + # Create Measurement versions + p_pos_meas = map(x -> x ± (x * 0.01), p_pos) + p_kw_meas = map(x -> x ± (x * 0.01), p_kw) + + # Float case + L_float = calc_inductance_trifoil(values(p_pos)...; p_kw...) + @test L_float isa Float64 + + # Fully promoted case + L_meas_all = calc_inductance_trifoil(values(p_pos_meas)...; p_kw_meas...) + @test L_meas_all isa Measurement{Float64} + @test L_meas_all.val ≈ L_float atol = TEST_TOL + @test L_meas_all.err > 0.0 + + # Mixed case (manual call for clarity) + L_meas_rho_e = calc_inductance_trifoil( + p_pos.r_in_co ± 0.0, p_pos.r_ext_co, p_pos.rho_co, p_pos.mu_r_co, + p_pos.r_in_scr, p_pos.r_ext_scr, p_pos.rho_scr, p_pos.mu_r_scr, p_pos.S; + rho_e=p_kw.rho_e ± 10.0, f=p_kw.f + ) + @test L_meas_rho_e isa Measurement{Float64} + @test L_meas_rho_e.val ≈ L_float atol = TEST_TOL + @test L_meas_rho_e.err > 0.0 + end end \ No newline at end of file diff --git a/test/unit_BaseParams/test_calc_parallel_equivalent.jl b/test/unit_BaseParams/test_calc_parallel_equivalent.jl index 64bf971e..5a0240ae 100644 --- a/test/unit_BaseParams/test_calc_parallel_equivalent.jl +++ b/test/unit_BaseParams/test_calc_parallel_equivalent.jl @@ -1,123 +1,123 @@ -@testitem "BaseParams: calc_parallel_equivalent unit tests" setup = [defaults] begin - - @testset "Basic Functionality" begin - # Test with real numbers (Float64) - Z1_real = 5.0 - Z2_real = 10.0 - expected_real = 1 / (1 / Z1_real + 1 / Z2_real) - result_real = calc_parallel_equivalent(Z1_real, Z2_real) - @test isapprox(result_real, expected_real; atol=TEST_TOL) - @test isapprox(result_real, 3.3333333333333335; atol=TEST_TOL) - - # Test with complex numbers (Complex{Float64}) - Z1_complex = 3.0 + 4.0im - Z2_complex = 8.0 - 6.0im - expected_complex = 1 / (1 / Z1_complex + 1 / Z2_complex) - @test isapprox(calc_parallel_equivalent(Z1_complex, Z2_complex), expected_complex; atol=TEST_TOL) - end - - @testset "Edge Cases" begin - # Zero impedance (short circuit) - @test isapprox(calc_parallel_equivalent(0.0, 10.0), 0.0; atol=TEST_TOL) - @test isapprox(calc_parallel_equivalent(10.0, 0.0), 0.0; atol=TEST_TOL) - @test isapprox(calc_parallel_equivalent(0.0, 0.0), 0.0; atol=TEST_TOL) - @test isapprox(calc_parallel_equivalent(0.0 + 0.0im, 5.0 + 5.0im), 0.0 + 0.0im; atol=TEST_TOL) - - # Infinite impedance (open circuit) - @test isapprox(calc_parallel_equivalent(Inf, 10.0), 10.0; atol=TEST_TOL) - @test isapprox(calc_parallel_equivalent(10.0, Inf), 10.0; atol=TEST_TOL) - @test isapprox(calc_parallel_equivalent(Inf, Inf), Inf; atol=TEST_TOL) - - # NaN propagation - @test isnan(calc_parallel_equivalent(NaN, 10.0)) - @test isnan(calc_parallel_equivalent(10.0, NaN)) - - # Equal and opposite impedances (Z1 = -Z2), leading to singularity - result_inf = calc_parallel_equivalent(10.0, -10.0) - @test isinf(real(result_inf)) - result_nan = calc_parallel_equivalent(3.0 + 4.0im, -3.0 - 4.0im) - @test isnan(real(result_nan)) && isnan(imag(result_nan)) - end - - @testset "Numerical Consistency" begin - Z1f = 5.0 - Z2f = 10.0 - resultf = calc_parallel_equivalent(Z1f, Z2f) - @test resultf isa Float64 - @test isapprox(resultf, 3.33333333; atol=TEST_TOL) - end - - @testset "Physical Behavior" begin - # Parallel resistance is always less than the smallest individual resistance - @test calc_parallel_equivalent(10.0, 20.0) < 10.0 - - # Symmetry: calc_parallel_equivalent(Z1, Z2) == calc_parallel_equivalent(Z2, Z1) - @test isapprox(calc_parallel_equivalent(7.0, 13.0), calc_parallel_equivalent(13.0, 7.0); atol=TEST_TOL) - - # If Z1 == Z2, the result is Z1 / 2 - @test isapprox(calc_parallel_equivalent(8.0, 8.0), 4.0; atol=TEST_TOL) - end - - @testset "Type Stability & Promotion" begin - # Both Float64 -> Float64 - @test calc_parallel_equivalent(5.0, 10.0) isa Float64 - - # Int and Float64 -> Float64 - result_mixed_real = calc_parallel_equivalent(5, 10.0) - @test result_mixed_real isa Float64 - @test isapprox(result_mixed_real, 1 / (1 / 5.0 + 1 / 10.0); atol=TEST_TOL) - - # Float64 and Complex{Float64} -> Complex{Float64} - result_mixed_complex = calc_parallel_equivalent(10.0, 3.0 + 4.0im) - @test result_mixed_complex isa Complex{Float64} - expected_mixed_complex = 1 / (1 / (10.0 + 0.0im) + 1 / (3.0 + 4.0im)) - @test isapprox(result_mixed_complex, expected_mixed_complex; atol=TEST_TOL) - - # Both Measurement -> Measurement - Z1m = measurement(5.0, 0.1) - Z2m = measurement(10.0, 0.2) - @test calc_parallel_equivalent(Z1m, Z2m) isa Measurement - - # Mixed: Measurement and Float64 -> Measurement - @test calc_parallel_equivalent(Z1m, 10.0) isa Measurement - @test calc_parallel_equivalent(5.0, Z2m) isa Measurement - end - - @testset "Uncertainty Quantification with Measurements.jl" begin - # Mixed Case 1: First argument is a Measurement - Z1_meas = measurement(5.0, 0.1) - Z2_float = 10.0 - result_mixed1 = calc_parallel_equivalent(Z1_meas, Z2_float) - expected_mixed1 = 1 / (1 / Z1_meas + 1 / Z2_float) - @test result_mixed1 isa Measurement{Float64} - @test isapprox(value(result_mixed1), value(expected_mixed1); atol=TEST_TOL) - @test isapprox(uncertainty(result_mixed1), uncertainty(expected_mixed1); atol=TEST_TOL) - - # Mixed Case 2: Second argument is a Measurement - Z1_float = 5.0 - Z2_meas = measurement(10.0, 0.2) - result_mixed2 = calc_parallel_equivalent(Z1_float, Z2_meas) - expected_mixed2 = 1 / (1 / Z1_float + 1 / Z2_meas) - @test result_mixed2 isa Measurement{Float64} - @test isapprox(value(result_mixed2), value(expected_mixed2); atol=TEST_TOL) - @test isapprox(uncertainty(result_mixed2), uncertainty(expected_mixed2); atol=TEST_TOL) - - # Fully Promoted Case: Both inputs are Measurements - result_full_meas = calc_parallel_equivalent(Z1_meas, Z2_meas) - expected_full_meas = 1 / (1 / Z1_meas + 1 / Z2_meas) - @test result_full_meas isa Measurement{Float64} - @test isapprox(value(result_full_meas), value(expected_full_meas); atol=TEST_TOL) - @test isapprox(uncertainty(result_full_meas), uncertainty(expected_full_meas); atol=TEST_TOL) - - # Fully Promoted Complex Case - Z1_cplx_meas = measurement(3.0, 0.1) + measurement(4.0, 0.2)im - Z2_cplx_meas = measurement(8.0, 0.3) - measurement(6.0, 0.4)im - result_cplx_meas = calc_parallel_equivalent(Z1_cplx_meas, Z2_cplx_meas) - expected_cplx_meas = 1 / (1 / Z1_cplx_meas + 1 / Z2_cplx_meas) - @test result_cplx_meas isa Complex{Measurement{Float64}} - @test isapprox(value(real(result_cplx_meas)), value(real(expected_cplx_meas)); atol=TEST_TOL) - @test isapprox(value(imag(result_cplx_meas)), value(imag(expected_cplx_meas)); atol=TEST_TOL) - @test isapprox(uncertainty(real(result_cplx_meas)), uncertainty(real(expected_cplx_meas)); atol=TEST_TOL) - @test isapprox(uncertainty(imag(result_cplx_meas)), uncertainty(imag(expected_cplx_meas)); atol=TEST_TOL) - end +@testitem "BaseParams: calc_parallel_equivalent unit tests" setup = [defaults] begin + + @testset "Basic Functionality" begin + # Test with real numbers (Float64) + Z1_real = 5.0 + Z2_real = 10.0 + expected_real = 1 / (1 / Z1_real + 1 / Z2_real) + result_real = calc_parallel_equivalent(Z1_real, Z2_real) + @test isapprox(result_real, expected_real; atol=TEST_TOL) + @test isapprox(result_real, 3.3333333333333335; atol=TEST_TOL) + + # Test with complex numbers (Complex{Float64}) + Z1_complex = 3.0 + 4.0im + Z2_complex = 8.0 - 6.0im + expected_complex = 1 / (1 / Z1_complex + 1 / Z2_complex) + @test isapprox(calc_parallel_equivalent(Z1_complex, Z2_complex), expected_complex; atol=TEST_TOL) + end + + @testset "Edge Cases" begin + # Zero impedance (short circuit) + @test isapprox(calc_parallel_equivalent(0.0, 10.0), 0.0; atol=TEST_TOL) + @test isapprox(calc_parallel_equivalent(10.0, 0.0), 0.0; atol=TEST_TOL) + @test isapprox(calc_parallel_equivalent(0.0, 0.0), 0.0; atol=TEST_TOL) + @test isapprox(calc_parallel_equivalent(0.0 + 0.0im, 5.0 + 5.0im), 0.0 + 0.0im; atol=TEST_TOL) + + # Infinite impedance (open circuit) + @test isapprox(calc_parallel_equivalent(Inf, 10.0), 10.0; atol=TEST_TOL) + @test isapprox(calc_parallel_equivalent(10.0, Inf), 10.0; atol=TEST_TOL) + @test isapprox(calc_parallel_equivalent(Inf, Inf), Inf; atol=TEST_TOL) + + # NaN propagation + @test isnan(calc_parallel_equivalent(NaN, 10.0)) + @test isnan(calc_parallel_equivalent(10.0, NaN)) + + # Equal and opposite impedances (Z1 = -Z2), leading to singularity + result_inf = calc_parallel_equivalent(10.0, -10.0) + @test isinf(real(result_inf)) + result_nan = calc_parallel_equivalent(3.0 + 4.0im, -3.0 - 4.0im) + @test isnan(real(result_nan)) && isnan(imag(result_nan)) + end + + @testset "Numerical Consistency" begin + Z1f = 5.0 + Z2f = 10.0 + resultf = calc_parallel_equivalent(Z1f, Z2f) + @test resultf isa Float64 + @test isapprox(resultf, 3.33333333; atol=TEST_TOL) + end + + @testset "Physical Behavior" begin + # Parallel resistance is always less than the smallest individual resistance + @test calc_parallel_equivalent(10.0, 20.0) < 10.0 + + # Symmetry: calc_parallel_equivalent(Z1, Z2) == calc_parallel_equivalent(Z2, Z1) + @test isapprox(calc_parallel_equivalent(7.0, 13.0), calc_parallel_equivalent(13.0, 7.0); atol=TEST_TOL) + + # If Z1 == Z2, the result is Z1 / 2 + @test isapprox(calc_parallel_equivalent(8.0, 8.0), 4.0; atol=TEST_TOL) + end + + @testset "Type Stability & Promotion" begin + # Both Float64 -> Float64 + @test calc_parallel_equivalent(5.0, 10.0) isa Float64 + + # Int and Float64 -> Float64 + result_mixed_real = calc_parallel_equivalent(5, 10.0) + @test result_mixed_real isa Float64 + @test isapprox(result_mixed_real, 1 / (1 / 5.0 + 1 / 10.0); atol=TEST_TOL) + + # Float64 and Complex{Float64} -> Complex{Float64} + result_mixed_complex = calc_parallel_equivalent(10.0, 3.0 + 4.0im) + @test result_mixed_complex isa Complex{Float64} + expected_mixed_complex = 1 / (1 / (10.0 + 0.0im) + 1 / (3.0 + 4.0im)) + @test isapprox(result_mixed_complex, expected_mixed_complex; atol=TEST_TOL) + + # Both Measurement -> Measurement + Z1m = measurement(5.0, 0.1) + Z2m = measurement(10.0, 0.2) + @test calc_parallel_equivalent(Z1m, Z2m) isa Measurement + + # Mixed: Measurement and Float64 -> Measurement + @test calc_parallel_equivalent(Z1m, 10.0) isa Measurement + @test calc_parallel_equivalent(5.0, Z2m) isa Measurement + end + + @testset "Uncertainty Quantification with Measurements.jl" begin + # Mixed Case 1: First argument is a Measurement + Z1_meas = measurement(5.0, 0.1) + Z2_float = 10.0 + result_mixed1 = calc_parallel_equivalent(Z1_meas, Z2_float) + expected_mixed1 = 1 / (1 / Z1_meas + 1 / Z2_float) + @test result_mixed1 isa Measurement{Float64} + @test isapprox(value(result_mixed1), value(expected_mixed1); atol=TEST_TOL) + @test isapprox(uncertainty(result_mixed1), uncertainty(expected_mixed1); atol=TEST_TOL) + + # Mixed Case 2: Second argument is a Measurement + Z1_float = 5.0 + Z2_meas = measurement(10.0, 0.2) + result_mixed2 = calc_parallel_equivalent(Z1_float, Z2_meas) + expected_mixed2 = 1 / (1 / Z1_float + 1 / Z2_meas) + @test result_mixed2 isa Measurement{Float64} + @test isapprox(value(result_mixed2), value(expected_mixed2); atol=TEST_TOL) + @test isapprox(uncertainty(result_mixed2), uncertainty(expected_mixed2); atol=TEST_TOL) + + # Fully Promoted Case: Both inputs are Measurements + result_full_meas = calc_parallel_equivalent(Z1_meas, Z2_meas) + expected_full_meas = 1 / (1 / Z1_meas + 1 / Z2_meas) + @test result_full_meas isa Measurement{Float64} + @test isapprox(value(result_full_meas), value(expected_full_meas); atol=TEST_TOL) + @test isapprox(uncertainty(result_full_meas), uncertainty(expected_full_meas); atol=TEST_TOL) + + # Fully Promoted Complex Case + Z1_cplx_meas = measurement(3.0, 0.1) + measurement(4.0, 0.2)im + Z2_cplx_meas = measurement(8.0, 0.3) - measurement(6.0, 0.4)im + result_cplx_meas = calc_parallel_equivalent(Z1_cplx_meas, Z2_cplx_meas) + expected_cplx_meas = 1 / (1 / Z1_cplx_meas + 1 / Z2_cplx_meas) + @test result_cplx_meas isa Complex{Measurement{Float64}} + @test isapprox(value(real(result_cplx_meas)), value(real(expected_cplx_meas)); atol=TEST_TOL) + @test isapprox(value(imag(result_cplx_meas)), value(imag(expected_cplx_meas)); atol=TEST_TOL) + @test isapprox(uncertainty(real(result_cplx_meas)), uncertainty(real(expected_cplx_meas)); atol=TEST_TOL) + @test isapprox(uncertainty(imag(result_cplx_meas)), uncertainty(imag(expected_cplx_meas)); atol=TEST_TOL) + end end \ No newline at end of file diff --git a/test/unit_BaseParams/test_calc_shunt_capacitance.jl b/test/unit_BaseParams/test_calc_shunt_capacitance.jl index ac3f3881..ae9e2317 100644 --- a/test/unit_BaseParams/test_calc_shunt_capacitance.jl +++ b/test/unit_BaseParams/test_calc_shunt_capacitance.jl @@ -1,78 +1,78 @@ -@testitem "BaseParams: calc_shunt_capacitance unit tests" setup = [defaults] begin - @testset "Basic Functionality" begin - # Example from docstring - radius_in = 0.01 - radius_ext = 0.02 - epsr = 2.3 - cap = calc_shunt_capacitance(radius_in, radius_ext, epsr) - @test isapprox(cap, 1.241e-10, atol=TEST_TOL) - # Vacuum (epsr = 1) - cap_vac = calc_shunt_capacitance(0.01, 0.02, 1.0) - @test cap_vac < cap - end - - @testset "Edge Cases" begin - # Collapsing geometry: radius_in -> radius_ext - cap = calc_shunt_capacitance(0.02, 0.02, 2.3) - @test isinf(cap) || isnan(cap) - # Very large radii - cap = calc_shunt_capacitance(1e2, 1e3, 2.3) - @test isfinite(cap) - # Inf/NaN input - @test isnan(calc_shunt_capacitance(NaN, 0.02, 2.3)) - @test isnan(calc_shunt_capacitance(0.01, NaN, 2.3)) - @test isnan(calc_shunt_capacitance(0.01, 0.02, NaN)) - end - - @testset "Numerical Consistency" begin - # Float32 vs Float64 - cap_f32 = calc_shunt_capacitance(Float32(0.01), Float32(0.02), Float32(2.3)) - cap_f64 = calc_shunt_capacitance(0.01, 0.02, 2.3) - @test isapprox(cap_f32, cap_f64, atol=TEST_TOL) - end - - @testset "Physical Behavior" begin - # Capacitance increases with epsr - c1 = calc_shunt_capacitance(0.01, 0.02, 2.3) - c2 = calc_shunt_capacitance(0.01, 0.02, 3.0) - @test c2 > c1 - # Capacitance decreases as radii get closer - c3 = calc_shunt_capacitance(0.01, 0.011, 2.3) - @test c3 > c1 - end - - @testset "Type Stability & Promotion" begin - using Measurements - radius_in = 0.01 - radius_ext = 0.02 - epsr = 2.3 - min = measurement(radius_in, 1e-4) - mex = measurement(radius_ext, 1e-4) - mepsr = measurement(epsr, 1e-2) - # All Float64 - res1 = calc_shunt_capacitance(radius_in, radius_ext, epsr) - @test typeof(res1) == Float64 - # All Measurement - res2 = calc_shunt_capacitance(min, mex, mepsr) - @test res2 isa Measurement{Float64} - # Mixed: first argument Measurement - res3 = calc_shunt_capacitance(min, radius_ext, epsr) - @test res3 isa Measurement{Float64} - # Mixed: second argument Measurement - res4 = calc_shunt_capacitance(radius_in, mex, epsr) - @test res4 isa Measurement{Float64} - # Mixed: third argument Measurement - res5 = calc_shunt_capacitance(radius_in, radius_ext, mepsr) - @test res5 isa Measurement{Float64} - end - - @testset "Uncertainty Quantification" begin - using Measurements - min = measurement(0.01, 1e-4) - mex = measurement(0.02, 1e-4) - mepsr = measurement(2.3, 1e-2) - cap = calc_shunt_capacitance(min, mex, mepsr) - @test cap isa Measurement{Float64} - @test uncertainty(cap) > 0 - end -end +@testitem "BaseParams: calc_shunt_capacitance unit tests" setup = [defaults] begin + @testset "Basic Functionality" begin + # Example from docstring + radius_in = 0.01 + radius_ext = 0.02 + epsr = 2.3 + cap = calc_shunt_capacitance(radius_in, radius_ext, epsr) + @test isapprox(cap, 1.241e-10, atol=TEST_TOL) + # Vacuum (epsr = 1) + cap_vac = calc_shunt_capacitance(0.01, 0.02, 1.0) + @test cap_vac < cap + end + + @testset "Edge Cases" begin + # Collapsing geometry: radius_in -> radius_ext + cap = calc_shunt_capacitance(0.02, 0.02, 2.3) + @test isinf(cap) || isnan(cap) + # Very large radii + cap = calc_shunt_capacitance(1e2, 1e3, 2.3) + @test isfinite(cap) + # Inf/NaN input + @test isnan(calc_shunt_capacitance(NaN, 0.02, 2.3)) + @test isnan(calc_shunt_capacitance(0.01, NaN, 2.3)) + @test isnan(calc_shunt_capacitance(0.01, 0.02, NaN)) + end + + @testset "Numerical Consistency" begin + # Float32 vs Float64 + cap_f32 = calc_shunt_capacitance(Float32(0.01), Float32(0.02), Float32(2.3)) + cap_f64 = calc_shunt_capacitance(0.01, 0.02, 2.3) + @test isapprox(cap_f32, cap_f64, atol=TEST_TOL) + end + + @testset "Physical Behavior" begin + # Capacitance increases with epsr + c1 = calc_shunt_capacitance(0.01, 0.02, 2.3) + c2 = calc_shunt_capacitance(0.01, 0.02, 3.0) + @test c2 > c1 + # Capacitance decreases as radii get closer + c3 = calc_shunt_capacitance(0.01, 0.011, 2.3) + @test c3 > c1 + end + + @testset "Type Stability & Promotion" begin + using Measurements + radius_in = 0.01 + radius_ext = 0.02 + epsr = 2.3 + min = measurement(radius_in, 1e-4) + mex = measurement(radius_ext, 1e-4) + mepsr = measurement(epsr, 1e-2) + # All Float64 + res1 = calc_shunt_capacitance(radius_in, radius_ext, epsr) + @test typeof(res1) == Float64 + # All Measurement + res2 = calc_shunt_capacitance(min, mex, mepsr) + @test res2 isa Measurement{Float64} + # Mixed: first argument Measurement + res3 = calc_shunt_capacitance(min, radius_ext, epsr) + @test res3 isa Measurement{Float64} + # Mixed: second argument Measurement + res4 = calc_shunt_capacitance(radius_in, mex, epsr) + @test res4 isa Measurement{Float64} + # Mixed: third argument Measurement + res5 = calc_shunt_capacitance(radius_in, radius_ext, mepsr) + @test res5 isa Measurement{Float64} + end + + @testset "Uncertainty Quantification" begin + using Measurements + min = measurement(0.01, 1e-4) + mex = measurement(0.02, 1e-4) + mepsr = measurement(2.3, 1e-2) + cap = calc_shunt_capacitance(min, mex, mepsr) + @test cap isa Measurement{Float64} + @test uncertainty(cap) > 0 + end +end diff --git a/test/unit_BaseParams/test_calc_shunt_conductance.jl b/test/unit_BaseParams/test_calc_shunt_conductance.jl index e6309a9b..b5288373 100644 --- a/test/unit_BaseParams/test_calc_shunt_conductance.jl +++ b/test/unit_BaseParams/test_calc_shunt_conductance.jl @@ -1,78 +1,78 @@ -@testitem "BaseParams: calc_shunt_conductance unit tests" setup = [defaults] begin - @testset "Basic Functionality" begin - # Example from docstring - radius_in = 0.01 - radius_ext = 0.02 - rho = 1e9 - g = calc_shunt_conductance(radius_in, radius_ext, rho) - @test isapprox(g, 2.7169e-9, atol=TEST_TOL) - # Lower resistivity increases conductance - g2 = calc_shunt_conductance(0.01, 0.02, 1e8) - @test g2 > g - end - - @testset "Edge Cases" begin - # Collapsing geometry: radius_in -> radius_ext - g = calc_shunt_conductance(0.02, 0.02, 1e9) - @test isinf(g) || isnan(g) - # Very large radii - g = calc_shunt_conductance(1e2, 1e3, 1e9) - @test isfinite(g) - # Inf/NaN input - @test isnan(calc_shunt_conductance(NaN, 0.02, 1e9)) - @test isnan(calc_shunt_conductance(0.01, NaN, 1e9)) - @test isnan(calc_shunt_conductance(0.01, 0.02, NaN)) - end - - @testset "Numerical Consistency" begin - # Float32 vs Float64 - g_f32 = calc_shunt_conductance(Float32(0.01), Float32(0.02), Float32(1e9)) - g_f64 = calc_shunt_conductance(0.01, 0.02, 1e9) - @test isapprox(g_f32, g_f64, atol=TEST_TOL) - end - - @testset "Physical Behavior" begin - # Conductance increases as rho decreases - g1 = calc_shunt_conductance(0.01, 0.02, 1e9) - g2 = calc_shunt_conductance(0.01, 0.02, 1e8) - @test g2 > g1 - # Conductance increases as radii get closer - g3 = calc_shunt_conductance(0.01, 0.011, 1e9) - @test g3 > g1 - end - - @testset "Type Stability & Promotion" begin - using Measurements - radius_in = 0.01 - radius_ext = 0.02 - rho = 1e9 - min = measurement(radius_in, 1e-4) - mex = measurement(radius_ext, 1e-4) - mrho = measurement(rho, 1e7) - # All Float64 - res1 = calc_shunt_conductance(radius_in, radius_ext, rho) - @test typeof(res1) == Float64 - # All Measurement - res2 = calc_shunt_conductance(min, mex, mrho) - @test res2 isa Measurement{Float64} - # Mixed: first argument Measurement - res3 = calc_shunt_conductance(min, radius_ext, rho) - @test res3 isa Measurement{Float64} - # Mixed: second argument Measurement - res4 = calc_shunt_conductance(radius_in, mex, rho) - @test res4 isa Measurement{Float64} - # Mixed: third argument Measurement - res5 = calc_shunt_conductance(radius_in, radius_ext, mrho) - @test res5 isa Measurement{Float64} - end - - @testset "Uncertainty Quantification" begin - using Measurements - min = measurement(0.01, 1e-4) - mex = measurement(0.02, 1e-4) - mrho = measurement(1e9, 1e7) - g = calc_shunt_conductance(min, mex, mrho) - @test g isa Measurement{Float64} - @test uncertainty(g) > 0 - end -end +@testitem "BaseParams: calc_shunt_conductance unit tests" setup = [defaults] begin + @testset "Basic Functionality" begin + # Example from docstring + radius_in = 0.01 + radius_ext = 0.02 + rho = 1e9 + g = calc_shunt_conductance(radius_in, radius_ext, rho) + @test isapprox(g, 2.7169e-9, atol=TEST_TOL) + # Lower resistivity increases conductance + g2 = calc_shunt_conductance(0.01, 0.02, 1e8) + @test g2 > g + end + + @testset "Edge Cases" begin + # Collapsing geometry: radius_in -> radius_ext + g = calc_shunt_conductance(0.02, 0.02, 1e9) + @test isinf(g) || isnan(g) + # Very large radii + g = calc_shunt_conductance(1e2, 1e3, 1e9) + @test isfinite(g) + # Inf/NaN input + @test isnan(calc_shunt_conductance(NaN, 0.02, 1e9)) + @test isnan(calc_shunt_conductance(0.01, NaN, 1e9)) + @test isnan(calc_shunt_conductance(0.01, 0.02, NaN)) + end + + @testset "Numerical Consistency" begin + # Float32 vs Float64 + g_f32 = calc_shunt_conductance(Float32(0.01), Float32(0.02), Float32(1e9)) + g_f64 = calc_shunt_conductance(0.01, 0.02, 1e9) + @test isapprox(g_f32, g_f64, atol=TEST_TOL) + end + + @testset "Physical Behavior" begin + # Conductance increases as rho decreases + g1 = calc_shunt_conductance(0.01, 0.02, 1e9) + g2 = calc_shunt_conductance(0.01, 0.02, 1e8) + @test g2 > g1 + # Conductance increases as radii get closer + g3 = calc_shunt_conductance(0.01, 0.011, 1e9) + @test g3 > g1 + end + + @testset "Type Stability & Promotion" begin + using Measurements + radius_in = 0.01 + radius_ext = 0.02 + rho = 1e9 + min = measurement(radius_in, 1e-4) + mex = measurement(radius_ext, 1e-4) + mrho = measurement(rho, 1e7) + # All Float64 + res1 = calc_shunt_conductance(radius_in, radius_ext, rho) + @test typeof(res1) == Float64 + # All Measurement + res2 = calc_shunt_conductance(min, mex, mrho) + @test res2 isa Measurement{Float64} + # Mixed: first argument Measurement + res3 = calc_shunt_conductance(min, radius_ext, rho) + @test res3 isa Measurement{Float64} + # Mixed: second argument Measurement + res4 = calc_shunt_conductance(radius_in, mex, rho) + @test res4 isa Measurement{Float64} + # Mixed: third argument Measurement + res5 = calc_shunt_conductance(radius_in, radius_ext, mrho) + @test res5 isa Measurement{Float64} + end + + @testset "Uncertainty Quantification" begin + using Measurements + min = measurement(0.01, 1e-4) + mex = measurement(0.02, 1e-4) + mrho = measurement(1e9, 1e7) + g = calc_shunt_conductance(min, mex, mrho) + @test g isa Measurement{Float64} + @test uncertainty(g) > 0 + end +end diff --git a/test/unit_BaseParams/test_calc_sigma_lossfact.jl b/test/unit_BaseParams/test_calc_sigma_lossfact.jl index 5b774664..f02b5d4e 100644 --- a/test/unit_BaseParams/test_calc_sigma_lossfact.jl +++ b/test/unit_BaseParams/test_calc_sigma_lossfact.jl @@ -1,76 +1,76 @@ -@testitem "BaseParams: calc_sigma_lossfact unit tests" setup = [defaults] begin - @testset "Basic Functionality" begin - # Example from docstring: G_eq=2.7169e-9 S·m, r_in=0.01 m, r_ext=0.02 m - G_eq = 2.7169e-9 - r_in = 0.01 - r_ext = 0.02 - result = calc_sigma_lossfact(G_eq, r_in, r_ext) - expected = G_eq * log(r_ext / r_in) / (2 * pi) - @test isapprox(result, expected; atol=TEST_TOL) - @test result > 0 - end - - @testset "Edge Cases" begin - # Zero conductance - result = calc_sigma_lossfact(0.0, 0.01, 0.02) - @test isapprox(result, 0.0; atol=TEST_TOL) - # Collapsing geometry: r_ext == r_in - result = calc_sigma_lossfact(1e-9, 0.01, 0.01) - @test isapprox(result, 0.0; atol=TEST_TOL) - # Very large radii - result = calc_sigma_lossfact(1e-9, 1e3, 1e6) - expected = 1e-9 * log(1e6 / 1e3) / (2 * pi) - @test isapprox(result, expected; atol=TEST_TOL) - # Inf/NaN - @test isnan(calc_sigma_lossfact(NaN, 0.01, 0.02)) - @test isnan(calc_sigma_lossfact(1e-9, NaN, 0.02)) - @test isnan(calc_sigma_lossfact(1e-9, 0.01, NaN)) - @test isinf(calc_sigma_lossfact(Inf, 0.01, 0.02)) - end - - @testset "Numerical Consistency" begin - # Float32 vs Float64 - r = calc_sigma_lossfact(Float32(1e-9), Float32(0.01), Float32(0.02)) - d = calc_sigma_lossfact(1e-9, 0.01, 0.02) - @test isapprox(r, d; atol=TEST_TOL) - end - - @testset "Physical Behavior" begin - # Increases with G_eq - r1 = calc_sigma_lossfact(1e-9, 0.01, 0.02) - r2 = calc_sigma_lossfact(2e-9, 0.01, 0.02) - @test r2 > r1 - # Increases with log(r_ext/r_in) - r3 = calc_sigma_lossfact(1e-9, 0.01, 0.04) - @test r3 > r1 - end - - @testset "Type Stability & Promotion" begin - using Measurements - # All Float64 - r1 = calc_sigma_lossfact(1e-9, 0.01, 0.02) - @test typeof(r1) == Float64 - # All Measurement - r2 = calc_sigma_lossfact(measurement(1e-9, 1e-11), measurement(0.01, 1e-5), measurement(0.02, 1e-5)) - @test r2 isa Measurement{Float64} - # Mixed: G_eq as Measurement - r3 = calc_sigma_lossfact(measurement(1e-9, 1e-11), 0.01, 0.02) - @test r3 isa Measurement{Float64} - # Mixed: radius_in as Measurement - r4 = calc_sigma_lossfact(1e-9, measurement(0.01, 1e-5), 0.02) - @test r4 isa Measurement{Float64} - # Mixed: radius_ext as Measurement - r5 = calc_sigma_lossfact(1e-9, 0.01, measurement(0.02, 1e-5)) - @test r5 isa Measurement{Float64} - end - - @testset "Uncertainty Quantification" begin - using Measurements - G_eq = measurement(1e-9, 1e-11) - r_in = measurement(0.01, 1e-5) - r_ext = measurement(0.02, 1e-5) - result = calc_sigma_lossfact(G_eq, r_in, r_ext) - @test result isa Measurement{Float64} - @test uncertainty(result) > 0 - end -end +@testitem "BaseParams: calc_sigma_lossfact unit tests" setup = [defaults] begin + @testset "Basic Functionality" begin + # Example from docstring: G_eq=2.7169e-9 S·m, r_in=0.01 m, r_ext=0.02 m + G_eq = 2.7169e-9 + r_in = 0.01 + r_ext = 0.02 + result = calc_sigma_lossfact(G_eq, r_in, r_ext) + expected = G_eq * log(r_ext / r_in) / (2 * pi) + @test isapprox(result, expected; atol=TEST_TOL) + @test result > 0 + end + + @testset "Edge Cases" begin + # Zero conductance + result = calc_sigma_lossfact(0.0, 0.01, 0.02) + @test isapprox(result, 0.0; atol=TEST_TOL) + # Collapsing geometry: r_ext == r_in + result = calc_sigma_lossfact(1e-9, 0.01, 0.01) + @test isapprox(result, 0.0; atol=TEST_TOL) + # Very large radii + result = calc_sigma_lossfact(1e-9, 1e3, 1e6) + expected = 1e-9 * log(1e6 / 1e3) / (2 * pi) + @test isapprox(result, expected; atol=TEST_TOL) + # Inf/NaN + @test isnan(calc_sigma_lossfact(NaN, 0.01, 0.02)) + @test isnan(calc_sigma_lossfact(1e-9, NaN, 0.02)) + @test isnan(calc_sigma_lossfact(1e-9, 0.01, NaN)) + @test isinf(calc_sigma_lossfact(Inf, 0.01, 0.02)) + end + + @testset "Numerical Consistency" begin + # Float32 vs Float64 + r = calc_sigma_lossfact(Float32(1e-9), Float32(0.01), Float32(0.02)) + d = calc_sigma_lossfact(1e-9, 0.01, 0.02) + @test isapprox(r, d; atol=TEST_TOL) + end + + @testset "Physical Behavior" begin + # Increases with G_eq + r1 = calc_sigma_lossfact(1e-9, 0.01, 0.02) + r2 = calc_sigma_lossfact(2e-9, 0.01, 0.02) + @test r2 > r1 + # Increases with log(r_ext/r_in) + r3 = calc_sigma_lossfact(1e-9, 0.01, 0.04) + @test r3 > r1 + end + + @testset "Type Stability & Promotion" begin + using Measurements + # All Float64 + r1 = calc_sigma_lossfact(1e-9, 0.01, 0.02) + @test typeof(r1) == Float64 + # All Measurement + r2 = calc_sigma_lossfact(measurement(1e-9, 1e-11), measurement(0.01, 1e-5), measurement(0.02, 1e-5)) + @test r2 isa Measurement{Float64} + # Mixed: G_eq as Measurement + r3 = calc_sigma_lossfact(measurement(1e-9, 1e-11), 0.01, 0.02) + @test r3 isa Measurement{Float64} + # Mixed: radius_in as Measurement + r4 = calc_sigma_lossfact(1e-9, measurement(0.01, 1e-5), 0.02) + @test r4 isa Measurement{Float64} + # Mixed: radius_ext as Measurement + r5 = calc_sigma_lossfact(1e-9, 0.01, measurement(0.02, 1e-5)) + @test r5 isa Measurement{Float64} + end + + @testset "Uncertainty Quantification" begin + using Measurements + G_eq = measurement(1e-9, 1e-11) + r_in = measurement(0.01, 1e-5) + r_ext = measurement(0.02, 1e-5) + result = calc_sigma_lossfact(G_eq, r_in, r_ext) + @test result isa Measurement{Float64} + @test uncertainty(result) > 0 + end +end diff --git a/test/unit_BaseParams/test_calc_solenoid_correction.jl b/test/unit_BaseParams/test_calc_solenoid_correction.jl index 245b02df..9370c593 100644 --- a/test/unit_BaseParams/test_calc_solenoid_correction.jl +++ b/test/unit_BaseParams/test_calc_solenoid_correction.jl @@ -1,80 +1,80 @@ -@testitem "BaseParams: calc_solenoid_correction unit tests" setup = [defaults] begin - @testset "Basic Functionality" begin - # Example from docstring: 10 turns/m, conductor radius 5 mm, insulator radius 10 mm - result = calc_solenoid_correction(10.0, 0.005, 0.01) - expected = 1.0 + 2 * 10.0^2 * pi^2 * (0.01^2 - 0.005^2) / log(0.01 / 0.005) - @test isapprox(result, expected; atol=TEST_TOL) - @test result > 1.0 - - # Non-helical cable (NaN turns) - result = calc_solenoid_correction(NaN, 0.005, 0.01) - @test result == 1.0 - end - - @testset "Edge Cases" begin - # Zero turns (should be 1.0) - result = calc_solenoid_correction(0.0, 0.005, 0.01) - @test isapprox(result, 1.0; atol=TEST_TOL) - - # Collapsing geometry: radii nearly equal - result = calc_solenoid_correction(10.0, 0.01, 0.010001) - @test result > 1.0 - - # Very large number of turns - result = calc_solenoid_correction(1e6, 0.005, 0.01) - @test result > 1e9 - - # Inf/NaN radii - @test isnan(calc_solenoid_correction(10.0, NaN, 0.01)) - @test isnan(calc_solenoid_correction(10.0, 0.005, NaN)) - @test isinf(calc_solenoid_correction(10.0, 0.0, 0.01)) == false # log(0.01/0) = Inf, but numerator is finite - end - - @testset "Numerical Consistency" begin - # Float32 vs Float64 - r = calc_solenoid_correction(Float32(10.0), Float32(0.005), Float32(0.01)) - d = calc_solenoid_correction(10.0, 0.005, 0.01) - @test isapprox(r, d; atol=TEST_TOL) - end - - @testset "Physical Behavior" begin - # Correction increases with more turns - c1 = calc_solenoid_correction(5.0, 0.005, 0.01) - c2 = calc_solenoid_correction(10.0, 0.005, 0.01) - @test c2 > c1 - # Correction increases with larger insulator radius - c3 = calc_solenoid_correction(10.0, 0.005, 0.02) - @test c3 > c2 - end - - @testset "Type Stability & Promotion" begin - using Measurements - # All Float64 - r1 = calc_solenoid_correction(10.0, 0.005, 0.01) - @test typeof(r1) == Float64 - # All Measurement - r2 = calc_solenoid_correction(measurement(10.0, 0.1), measurement(0.005, 1e-5), measurement(0.01, 1e-5)) - @test r2 isa Measurement{Float64} - # Mixed: num_turns as Measurement - r3 = calc_solenoid_correction(measurement(10.0, 0.1), 0.005, 0.01) - @test r3 isa Measurement{Float64} - # Mixed: radius_ext_con as Measurement - r4 = calc_solenoid_correction(10.0, measurement(0.005, 1e-5), 0.01) - @test r4 isa Measurement{Float64} - # Mixed: radius_ext_ins as Measurement - r5 = calc_solenoid_correction(10.0, 0.005, measurement(0.01, 1e-5)) - @test r5 isa Measurement{Float64} - end - - @testset "Uncertainty Quantification" begin - using Measurements - num_turns = measurement(10.0, 0.1) - radius_ext_con = measurement(0.005, 1e-5) - radius_ext_ins = measurement(0.01, 1e-5) - result = calc_solenoid_correction(num_turns, radius_ext_con, radius_ext_ins) - # Should propagate uncertainty - @test result isa Measurement{Float64} - # Uncertainty should be nonzero - @test uncertainty(result) > 0 - end -end +@testitem "BaseParams: calc_solenoid_correction unit tests" setup = [defaults] begin + @testset "Basic Functionality" begin + # Example from docstring: 10 turns/m, conductor radius 5 mm, insulator radius 10 mm + result = calc_solenoid_correction(10.0, 0.005, 0.01) + expected = 1.0 + 2 * 10.0^2 * pi^2 * (0.01^2 - 0.005^2) / log(0.01 / 0.005) + @test isapprox(result, expected; atol=TEST_TOL) + @test result > 1.0 + + # Non-helical cable (NaN turns) + result = calc_solenoid_correction(NaN, 0.005, 0.01) + @test result == 1.0 + end + + @testset "Edge Cases" begin + # Zero turns (should be 1.0) + result = calc_solenoid_correction(0.0, 0.005, 0.01) + @test isapprox(result, 1.0; atol=TEST_TOL) + + # Collapsing geometry: radii nearly equal + result = calc_solenoid_correction(10.0, 0.01, 0.010001) + @test result > 1.0 + + # Very large number of turns + result = calc_solenoid_correction(1e6, 0.005, 0.01) + @test result > 1e9 + + # Inf/NaN radii + @test isnan(calc_solenoid_correction(10.0, NaN, 0.01)) + @test isnan(calc_solenoid_correction(10.0, 0.005, NaN)) + @test isinf(calc_solenoid_correction(10.0, 0.0, 0.01)) == false # log(0.01/0) = Inf, but numerator is finite + end + + @testset "Numerical Consistency" begin + # Float32 vs Float64 + r = calc_solenoid_correction(Float32(10.0), Float32(0.005), Float32(0.01)) + d = calc_solenoid_correction(10.0, 0.005, 0.01) + @test isapprox(r, d; atol=TEST_TOL) + end + + @testset "Physical Behavior" begin + # Correction increases with more turns + c1 = calc_solenoid_correction(5.0, 0.005, 0.01) + c2 = calc_solenoid_correction(10.0, 0.005, 0.01) + @test c2 > c1 + # Correction increases with larger insulator radius + c3 = calc_solenoid_correction(10.0, 0.005, 0.02) + @test c3 > c2 + end + + @testset "Type Stability & Promotion" begin + using Measurements + # All Float64 + r1 = calc_solenoid_correction(10.0, 0.005, 0.01) + @test typeof(r1) == Float64 + # All Measurement + r2 = calc_solenoid_correction(measurement(10.0, 0.1), measurement(0.005, 1e-5), measurement(0.01, 1e-5)) + @test r2 isa Measurement{Float64} + # Mixed: num_turns as Measurement + r3 = calc_solenoid_correction(measurement(10.0, 0.1), 0.005, 0.01) + @test r3 isa Measurement{Float64} + # Mixed: radius_ext_con as Measurement + r4 = calc_solenoid_correction(10.0, measurement(0.005, 1e-5), 0.01) + @test r4 isa Measurement{Float64} + # Mixed: radius_ext_ins as Measurement + r5 = calc_solenoid_correction(10.0, 0.005, measurement(0.01, 1e-5)) + @test r5 isa Measurement{Float64} + end + + @testset "Uncertainty Quantification" begin + using Measurements + num_turns = measurement(10.0, 0.1) + radius_ext_con = measurement(0.005, 1e-5) + radius_ext_ins = measurement(0.01, 1e-5) + result = calc_solenoid_correction(num_turns, radius_ext_con, radius_ext_ins) + # Should propagate uncertainty + @test result isa Measurement{Float64} + # Uncertainty should be nonzero + @test uncertainty(result) > 0 + end +end diff --git a/test/unit_BaseParams/test_calc_strip_resistance.jl b/test/unit_BaseParams/test_calc_strip_resistance.jl index 74827500..a145c507 100644 --- a/test/unit_BaseParams/test_calc_strip_resistance.jl +++ b/test/unit_BaseParams/test_calc_strip_resistance.jl @@ -1,74 +1,74 @@ -@testitem "BaseParams: calc_strip_resistance unit tests" setup = [defaults] begin - using Measurements - # --- Basic Functionality --- - @testset "Basic Functionality" begin - thickness = 0.002 - width = 0.05 - rho = 1.7241e-8 - alpha = 0.00393 - T0 = 20.0 - Top = 25.0 - R = calc_strip_resistance(thickness, width, rho, alpha, T0, Top) - @test isapprox(R, 0.00017579785649999996, atol=TEST_TOL) - end - - # --- Edge Cases --- - @testset "Edge Cases" begin - # Zero thickness (should return Inf or error in physical context, but function will return Inf) - R = calc_strip_resistance(0.0, 0.05, 1.7241e-8, 0.00393, 20.0, 25.0) - @test isinf(R) - # Zero width - R = calc_strip_resistance(0.002, 0.0, 1.7241e-8, 0.00393, 20.0, 25.0) - @test isinf(R) - # Large temperature difference (within asserted range) - R = calc_strip_resistance(0.002, 0.05, 1.7241e-8, 0.00393, 20.0, 100.0) - @test R > 0.00017579785649999996 - end - - # --- Numerical Consistency --- - @testset "Numerical Consistency" begin - # Float32 - R = calc_strip_resistance(Float32(0.002), Float32(0.05), Float32(1.7241e-8), Float32(0.00393), Float32(20.0), Float32(25.0)) - @test isapprox(R, 0.00017579785649999996, atol=TEST_TOL) - end - - # --- Physical Behavior --- - @testset "Physical Behavior" begin - # Resistance increases with temperature - R1 = calc_strip_resistance(0.002, 0.05, 1.7241e-8, 0.00393, 20.0, 25.0) - R2 = calc_strip_resistance(0.002, 0.05, 1.7241e-8, 0.00393, 20.0, 75.0) - @test R2 > R1 - # Resistance decreases with increasing cross-section - R3 = calc_strip_resistance(0.002, 0.05, 1.7241e-8, 0.00393, 20.0, 25.0) - R4 = calc_strip_resistance(0.004, 0.05, 1.7241e-8, 0.00393, 20.0, 25.0) - @test R4 < R3 - end - - # --- Type Stability & Promotion --- - @testset "Type Stability & Promotion" begin - # All Float64 - R = calc_strip_resistance(0.002, 0.05, 1.7241e-8, 0.00393, 20.0, 25.0) - @test typeof(R) == Float64 - # All Measurement - Rm = calc_strip_resistance(measurement(0.002, 1e-6), measurement(0.05, 1e-5), measurement(1.7241e-8, 1e-10), measurement(0.00393, 1e-6), measurement(20.0, 0.1), measurement(25.0, 0.1)) - @test Rm isa Measurement{Float64} - # Mixed: thickness as Measurement - R1 = calc_strip_resistance(measurement(0.002, 1e-6), 0.05, 1.7241e-8, 0.00393, 20.0, 25.0) - @test R1 isa Measurement{Float64} - # Mixed: alpha as Measurement - R2 = calc_strip_resistance(0.002, 0.05, 1.7241e-8, measurement(0.00393, 1e-6), 20.0, 25.0) - @test R2 isa Measurement{Float64} - end - - # --- Uncertainty Quantification --- - @testset "Uncertainty Quantification" begin - t = measurement(0.002, 1e-6) - w = measurement(0.05, 1e-5) - r = measurement(1.7241e-8, 1e-10) - a = measurement(0.00393, 1e-6) - t0 = measurement(20.0, 0.1) - top = measurement(25.0, 0.1) - R = calc_strip_resistance(t, w, r, a, t0, top) - @test uncertainty(R) > 0 - end -end +@testitem "BaseParams: calc_strip_resistance unit tests" setup = [defaults] begin + using Measurements + # --- Basic Functionality --- + @testset "Basic Functionality" begin + thickness = 0.002 + width = 0.05 + rho = 1.7241e-8 + alpha = 0.00393 + T0 = 20.0 + Top = 25.0 + R = calc_strip_resistance(thickness, width, rho, alpha, T0, Top) + @test isapprox(R, 0.00017579785649999996, atol=TEST_TOL) + end + + # --- Edge Cases --- + @testset "Edge Cases" begin + # Zero thickness (should return Inf or error in physical context, but function will return Inf) + R = calc_strip_resistance(0.0, 0.05, 1.7241e-8, 0.00393, 20.0, 25.0) + @test isinf(R) + # Zero width + R = calc_strip_resistance(0.002, 0.0, 1.7241e-8, 0.00393, 20.0, 25.0) + @test isinf(R) + # Large temperature difference (within asserted range) + R = calc_strip_resistance(0.002, 0.05, 1.7241e-8, 0.00393, 20.0, 100.0) + @test R > 0.00017579785649999996 + end + + # --- Numerical Consistency --- + @testset "Numerical Consistency" begin + # Float32 + R = calc_strip_resistance(Float32(0.002), Float32(0.05), Float32(1.7241e-8), Float32(0.00393), Float32(20.0), Float32(25.0)) + @test isapprox(R, 0.00017579785649999996, atol=TEST_TOL) + end + + # --- Physical Behavior --- + @testset "Physical Behavior" begin + # Resistance increases with temperature + R1 = calc_strip_resistance(0.002, 0.05, 1.7241e-8, 0.00393, 20.0, 25.0) + R2 = calc_strip_resistance(0.002, 0.05, 1.7241e-8, 0.00393, 20.0, 75.0) + @test R2 > R1 + # Resistance decreases with increasing cross-section + R3 = calc_strip_resistance(0.002, 0.05, 1.7241e-8, 0.00393, 20.0, 25.0) + R4 = calc_strip_resistance(0.004, 0.05, 1.7241e-8, 0.00393, 20.0, 25.0) + @test R4 < R3 + end + + # --- Type Stability & Promotion --- + @testset "Type Stability & Promotion" begin + # All Float64 + R = calc_strip_resistance(0.002, 0.05, 1.7241e-8, 0.00393, 20.0, 25.0) + @test typeof(R) == Float64 + # All Measurement + Rm = calc_strip_resistance(measurement(0.002, 1e-6), measurement(0.05, 1e-5), measurement(1.7241e-8, 1e-10), measurement(0.00393, 1e-6), measurement(20.0, 0.1), measurement(25.0, 0.1)) + @test Rm isa Measurement{Float64} + # Mixed: thickness as Measurement + R1 = calc_strip_resistance(measurement(0.002, 1e-6), 0.05, 1.7241e-8, 0.00393, 20.0, 25.0) + @test R1 isa Measurement{Float64} + # Mixed: alpha as Measurement + R2 = calc_strip_resistance(0.002, 0.05, 1.7241e-8, measurement(0.00393, 1e-6), 20.0, 25.0) + @test R2 isa Measurement{Float64} + end + + # --- Uncertainty Quantification --- + @testset "Uncertainty Quantification" begin + t = measurement(0.002, 1e-6) + w = measurement(0.05, 1e-5) + r = measurement(1.7241e-8, 1e-10) + a = measurement(0.00393, 1e-6) + t0 = measurement(20.0, 0.1) + top = measurement(25.0, 0.1) + R = calc_strip_resistance(t, w, r, a, t0, top) + @test uncertainty(R) > 0 + end +end diff --git a/test/unit_BaseParams/test_calc_temperature_correction.jl b/test/unit_BaseParams/test_calc_temperature_correction.jl index b573a13c..61a219af 100644 --- a/test/unit_BaseParams/test_calc_temperature_correction.jl +++ b/test/unit_BaseParams/test_calc_temperature_correction.jl @@ -1,79 +1,79 @@ -@testitem "BaseParams: calc_temperature_correction unit tests" setup = [defaults] begin - using Measurements - # Basic Functionality - @testset "Basic Functionality" begin - # Example from docstring: alpha = 0.00393, Top = 75.0, T0 = 20.0 - k = calc_temperature_correction(0.00393, 75.0, 20.0) - @test isapprox(k, 1.2161, atol=1e-4) - - # Default T0 (should use T₀ constant) - k2 = calc_temperature_correction(0.00393, 75.0) - k2_ref = calc_temperature_correction(0.00393, 75.0, T₀) - @test isapprox(k2, k2_ref, atol=TEST_TOL) - end - - # Edge Cases - @testset "Edge Cases" begin - # Zero temperature difference - @test isapprox(calc_temperature_correction(0.00393, 20.0, 20.0), 1.0, atol=TEST_TOL) - # Negative alpha (unusual, but mathematically valid) - @test isapprox(calc_temperature_correction(-0.001, 30.0, 20.0), 0.99, atol=TEST_TOL) - # Large temperature difference within ΔTmax - @test isapprox(calc_temperature_correction(0.00393, 20.0 + (ΔTmax - 1), 20.0), 1 + 0.00393 * (ΔTmax - 1), atol=TEST_TOL) - end - - # Numerical Consistency - @testset "Numerical Consistency" begin - # Float32 - kf = calc_temperature_correction(Float32(0.00393), Float32(75.0), Float32(20.0)) - @test isapprox(kf, 1.2161f0, atol=Float32(1e-4)) - end - - # Physical Behavior - @testset "Physical Behavior" begin - # Correction increases with temperature for positive alpha - k1 = calc_temperature_correction(0.00393, 50.0, 20.0) - k2 = calc_temperature_correction(0.00393, 80.0, 20.0) - @test k2 > k1 - # Correction decreases with temperature for negative alpha - k3 = calc_temperature_correction(-0.001, 50.0, 20.0) - k4 = calc_temperature_correction(-0.001, 80.0, 20.0) - @test k4 < k3 - end - - # Type Stability & Promotion - @testset "Type Stability & Promotion" begin - # All Float64 - kf = calc_temperature_correction(0.00393, 75.0, 20.0) - @test typeof(kf) == Float64 - # All Measurement - αm = measurement(0.00393, 1e-5) - Topm = measurement(75.0, 0.1) - T0m = measurement(20.0, 0.1) - km = calc_temperature_correction(αm, Topm, T0m) - @test km isa Measurement{Float64} - # Mixed: alpha as Measurement - kmix1 = calc_temperature_correction(αm, 75.0, 20.0) - @test kmix1 isa Measurement{Float64} - # Mixed: Top as Measurement - kmix2 = calc_temperature_correction(0.00393, Topm, 20.0) - @test kmix2 isa Measurement{Float64} - # Mixed: T0 as Measurement - kmix3 = calc_temperature_correction(0.00393, 75.0, T0m) - @test kmix3 isa Measurement{Float64} - end - - # Uncertainty Quantification - @testset "Uncertainty Quantification" begin - αm = measurement(0.00393, 1e-5) - Topm = measurement(75.0, 0.1) - T0m = measurement(20.0, 0.1) - km = calc_temperature_correction(αm, Topm, T0m) - # Analytical propagation: k = 1 + α*(Top-T0) - # σ² = (Top-T0)²*σ_α² + α²*σ_Top² + α²*σ_T0² - μ = 1 + 0.00393 * (75.0 - 20.0) - σ2 = (75.0 - 20.0)^2 * 1e-5^2 + 0.00393^2 * 0.1^2 + 0.00393^2 * 0.1^2 - @test isapprox(value(km), μ, atol=TEST_TOL) - @test isapprox(uncertainty(km), sqrt(σ2), atol=TEST_TOL) - end -end +@testitem "BaseParams: calc_temperature_correction unit tests" setup = [defaults] begin + using Measurements + # Basic Functionality + @testset "Basic Functionality" begin + # Example from docstring: alpha = 0.00393, Top = 75.0, T0 = 20.0 + k = calc_temperature_correction(0.00393, 75.0, 20.0) + @test isapprox(k, 1.2161, atol=1e-4) + + # Default T0 (should use T₀ constant) + k2 = calc_temperature_correction(0.00393, 75.0) + k2_ref = calc_temperature_correction(0.00393, 75.0, T₀) + @test isapprox(k2, k2_ref, atol=TEST_TOL) + end + + # Edge Cases + @testset "Edge Cases" begin + # Zero temperature difference + @test isapprox(calc_temperature_correction(0.00393, 20.0, 20.0), 1.0, atol=TEST_TOL) + # Negative alpha (unusual, but mathematically valid) + @test isapprox(calc_temperature_correction(-0.001, 30.0, 20.0), 0.99, atol=TEST_TOL) + # Large temperature difference within ΔTmax + @test isapprox(calc_temperature_correction(0.00393, 20.0 + (ΔTmax - 1), 20.0), 1 + 0.00393 * (ΔTmax - 1), atol=TEST_TOL) + end + + # Numerical Consistency + @testset "Numerical Consistency" begin + # Float32 + kf = calc_temperature_correction(Float32(0.00393), Float32(75.0), Float32(20.0)) + @test isapprox(kf, 1.2161f0, atol=Float32(1e-4)) + end + + # Physical Behavior + @testset "Physical Behavior" begin + # Correction increases with temperature for positive alpha + k1 = calc_temperature_correction(0.00393, 50.0, 20.0) + k2 = calc_temperature_correction(0.00393, 80.0, 20.0) + @test k2 > k1 + # Correction decreases with temperature for negative alpha + k3 = calc_temperature_correction(-0.001, 50.0, 20.0) + k4 = calc_temperature_correction(-0.001, 80.0, 20.0) + @test k4 < k3 + end + + # Type Stability & Promotion + @testset "Type Stability & Promotion" begin + # All Float64 + kf = calc_temperature_correction(0.00393, 75.0, 20.0) + @test typeof(kf) == Float64 + # All Measurement + αm = measurement(0.00393, 1e-5) + Topm = measurement(75.0, 0.1) + T0m = measurement(20.0, 0.1) + km = calc_temperature_correction(αm, Topm, T0m) + @test km isa Measurement{Float64} + # Mixed: alpha as Measurement + kmix1 = calc_temperature_correction(αm, 75.0, 20.0) + @test kmix1 isa Measurement{Float64} + # Mixed: Top as Measurement + kmix2 = calc_temperature_correction(0.00393, Topm, 20.0) + @test kmix2 isa Measurement{Float64} + # Mixed: T0 as Measurement + kmix3 = calc_temperature_correction(0.00393, 75.0, T0m) + @test kmix3 isa Measurement{Float64} + end + + # Uncertainty Quantification + @testset "Uncertainty Quantification" begin + αm = measurement(0.00393, 1e-5) + Topm = measurement(75.0, 0.1) + T0m = measurement(20.0, 0.1) + km = calc_temperature_correction(αm, Topm, T0m) + # Analytical propagation: k = 1 + α*(Top-T0) + # σ² = (Top-T0)²*σ_α² + α²*σ_Top² + α²*σ_T0² + μ = 1 + 0.00393 * (75.0 - 20.0) + σ2 = (75.0 - 20.0)^2 * 1e-5^2 + 0.00393^2 * 0.1^2 + 0.00393^2 * 0.1^2 + @test isapprox(value(km), μ, atol=TEST_TOL) + @test isapprox(uncertainty(km), sqrt(σ2), atol=TEST_TOL) + end +end diff --git a/test/unit_BaseParams/test_calc_tubular_gmr.jl b/test/unit_BaseParams/test_calc_tubular_gmr.jl index af4cdf25..f0ae8f7a 100644 --- a/test/unit_BaseParams/test_calc_tubular_gmr.jl +++ b/test/unit_BaseParams/test_calc_tubular_gmr.jl @@ -1,96 +1,96 @@ -@testitem "BaseParams: calc_tubular_gmr unit tests" setup = [defaults] begin - using Measurements: measurement, value, uncertainty - - @testset "Basic Functionality" begin - # Example from docstring - radius_ext = 0.02 - radius_in = 0.01 - mu_r = 1.0 - gmr = calc_tubular_gmr(radius_ext, radius_in, mu_r) - # Manual calculation for expected value - term1 = (radius_in^4 / (radius_ext^2 - radius_in^2)^2) * log(radius_ext / radius_in) - term2 = (3 * radius_in^2 - radius_ext^2) / (4 * (radius_ext^2 - radius_in^2)) - Lin = (μ₀ * mu_r / (2 * π)) * (term1 - term2) - expected = exp(log(radius_ext) - (2 * π / μ₀) * Lin) - @test isapprox(gmr, expected; atol=TEST_TOL) - @test gmr > 0 - @test_throws ArgumentError calc_tubular_gmr(radius_in, radius_ext, mu_r) - @test_throws ArgumentError calc_tubular_gmr(0.0, radius_in, mu_r) - end - - @testset "Edge Cases" begin - # Thin shell: radius_ext ≈ radius_in - radius_ext = 0.01 - radius_in = 0.01 - mu_r = 1.0 - gmr = calc_tubular_gmr(radius_ext, radius_in, mu_r) - @test isapprox(gmr, radius_ext; atol=TEST_TOL) - - # Infinitely thick tube: radius_in ≫ 0, radius_in / radius_ext ≈ 0 - radius_ext = 1.0 - radius_in = 1e-12 - mu_r = 1.0 - gmr = calc_tubular_gmr(radius_ext, radius_in, mu_r) - @test isapprox(gmr, 0.7788; atol=1e-4) - - # radius_in = 0 (solid cylinder) - radius_ext = 0.02 - radius_in = 0.0 - mu_r = 1.0 - gmr = calc_tubular_gmr(radius_ext, radius_in, mu_r) - @test isapprox(gmr, 0.7788 * radius_ext; atol=1e-4) - - # radius_ext < radius_in (should throw) - radius_ext = 0.01 - radius_in = 0.02 - mu_r = 1.0 - @test_throws ArgumentError calc_tubular_gmr(radius_ext, radius_in, mu_r) - end - - @testset "Numerical Consistency" begin - # Float64 - gmr1 = calc_tubular_gmr(0.02, 0.01, 1.0) - # Measurement{Float64} - gmr2 = calc_tubular_gmr(measurement(0.02, 1e-4), measurement(0.01, 1e-4), measurement(1.0, 0.01)) - @test isapprox(value(gmr2), gmr1; atol=TEST_TOL) - @test uncertainty(gmr2) > 0 - end - - @testset "Physical Behavior" begin - # GMR increases with radius_ext - gmr1 = calc_tubular_gmr(0.01, 0.005, 1.0) - gmr2 = calc_tubular_gmr(0.02, 0.005, 1.0) - @test gmr2 > gmr1 - # GMR decreases with mu_r - gmr1 = calc_tubular_gmr(0.02, 0.01, 0.5) - gmr2 = calc_tubular_gmr(0.02, 0.01, 2.0) - @test gmr2 < gmr1 - end - - @testset "Type Stability & Promotion" begin - # All Float64 - gmr = calc_tubular_gmr(0.02, 0.01, 1.0) - @test typeof(gmr) == Float64 - # All Measurement - gmr = calc_tubular_gmr(measurement(0.02, 1e-4), measurement(0.01, 1e-4), measurement(1.0, 0.01)) - @test gmr isa Measurement{Float64} - # Mixed: radius_ext as Measurement - gmr = calc_tubular_gmr(measurement(0.02, 1e-4), 0.01, 1.0) - @test gmr isa Measurement{Float64} - # Mixed: radius_in as Measurement - gmr = calc_tubular_gmr(0.02, measurement(0.01, 1e-4), 1.0) - @test gmr isa Measurement{Float64} - # Mixed: mu_r as Measurement - gmr = calc_tubular_gmr(0.02, 0.01, measurement(1.0, 0.01)) - @test gmr isa Measurement{Float64} - end - - @testset "Uncertainty Quantification" begin - radius_ext = measurement(0.02, 1e-4) - radius_in = measurement(0.01, 1e-4) - mu_r = measurement(1.0, 0.01) - gmr = calc_tubular_gmr(radius_ext, radius_in, mu_r) - @test gmr isa Measurement{Float64} - @test uncertainty(gmr) > 0 - end -end +@testitem "BaseParams: calc_tubular_gmr unit tests" setup = [defaults] begin + using Measurements: measurement, value, uncertainty + + @testset "Basic Functionality" begin + # Example from docstring + radius_ext = 0.02 + radius_in = 0.01 + mu_r = 1.0 + gmr = calc_tubular_gmr(radius_ext, radius_in, mu_r) + # Manual calculation for expected value + term1 = (radius_in^4 / (radius_ext^2 - radius_in^2)^2) * log(radius_ext / radius_in) + term2 = (3 * radius_in^2 - radius_ext^2) / (4 * (radius_ext^2 - radius_in^2)) + Lin = (μ₀ * mu_r / (2 * π)) * (term1 - term2) + expected = exp(log(radius_ext) - (2 * π / μ₀) * Lin) + @test isapprox(gmr, expected; atol=TEST_TOL) + @test gmr > 0 + @test_throws ArgumentError calc_tubular_gmr(radius_in, radius_ext, mu_r) + @test_throws ArgumentError calc_tubular_gmr(0.0, radius_in, mu_r) + end + + @testset "Edge Cases" begin + # Thin shell: radius_ext ≈ radius_in + radius_ext = 0.01 + radius_in = 0.01 + mu_r = 1.0 + gmr = calc_tubular_gmr(radius_ext, radius_in, mu_r) + @test isapprox(gmr, radius_ext; atol=TEST_TOL) + + # Infinitely thick tube: radius_in ≫ 0, radius_in / radius_ext ≈ 0 + radius_ext = 1.0 + radius_in = 1e-12 + mu_r = 1.0 + gmr = calc_tubular_gmr(radius_ext, radius_in, mu_r) + @test isapprox(gmr, 0.7788; atol=1e-4) + + # radius_in = 0 (solid cylinder) + radius_ext = 0.02 + radius_in = 0.0 + mu_r = 1.0 + gmr = calc_tubular_gmr(radius_ext, radius_in, mu_r) + @test isapprox(gmr, 0.7788 * radius_ext; atol=1e-4) + + # radius_ext < radius_in (should throw) + radius_ext = 0.01 + radius_in = 0.02 + mu_r = 1.0 + @test_throws ArgumentError calc_tubular_gmr(radius_ext, radius_in, mu_r) + end + + @testset "Numerical Consistency" begin + # Float64 + gmr1 = calc_tubular_gmr(0.02, 0.01, 1.0) + # Measurement{Float64} + gmr2 = calc_tubular_gmr(measurement(0.02, 1e-4), measurement(0.01, 1e-4), measurement(1.0, 0.01)) + @test isapprox(value(gmr2), gmr1; atol=TEST_TOL) + @test uncertainty(gmr2) > 0 + end + + @testset "Physical Behavior" begin + # GMR increases with radius_ext + gmr1 = calc_tubular_gmr(0.01, 0.005, 1.0) + gmr2 = calc_tubular_gmr(0.02, 0.005, 1.0) + @test gmr2 > gmr1 + # GMR decreases with mu_r + gmr1 = calc_tubular_gmr(0.02, 0.01, 0.5) + gmr2 = calc_tubular_gmr(0.02, 0.01, 2.0) + @test gmr2 < gmr1 + end + + @testset "Type Stability & Promotion" begin + # All Float64 + gmr = calc_tubular_gmr(0.02, 0.01, 1.0) + @test typeof(gmr) == Float64 + # All Measurement + gmr = calc_tubular_gmr(measurement(0.02, 1e-4), measurement(0.01, 1e-4), measurement(1.0, 0.01)) + @test gmr isa Measurement{Float64} + # Mixed: radius_ext as Measurement + gmr = calc_tubular_gmr(measurement(0.02, 1e-4), 0.01, 1.0) + @test gmr isa Measurement{Float64} + # Mixed: radius_in as Measurement + gmr = calc_tubular_gmr(0.02, measurement(0.01, 1e-4), 1.0) + @test gmr isa Measurement{Float64} + # Mixed: mu_r as Measurement + gmr = calc_tubular_gmr(0.02, 0.01, measurement(1.0, 0.01)) + @test gmr isa Measurement{Float64} + end + + @testset "Uncertainty Quantification" begin + radius_ext = measurement(0.02, 1e-4) + radius_in = measurement(0.01, 1e-4) + mu_r = measurement(1.0, 0.01) + gmr = calc_tubular_gmr(radius_ext, radius_in, mu_r) + @test gmr isa Measurement{Float64} + @test uncertainty(gmr) > 0 + end +end diff --git a/test/unit_BaseParams/test_calc_tubular_inductance.jl b/test/unit_BaseParams/test_calc_tubular_inductance.jl index b9d2a281..1925cbb3 100644 --- a/test/unit_BaseParams/test_calc_tubular_inductance.jl +++ b/test/unit_BaseParams/test_calc_tubular_inductance.jl @@ -1,91 +1,91 @@ -@testitem "BaseParams: calc_tubular_inductance unit tests" setup = [defaults] begin - using Measurements - # Basic Functionality - @testset "Basic Functionality" begin - # Example from docstring: radius_in = 0.01, radius_ext = 0.02, mu_r = 1.0 - L = calc_tubular_inductance(0.01, 0.02, 1.0) - expected = 1.0 * μ₀ / (2 * π) * log(0.02 / 0.01) - @test isapprox(L, expected, atol=TEST_TOL) - end - - # Edge Cases - @testset "Edge Cases" begin - # Very thin tube (radius_ext ≈ radius_in) - r_in = 0.01 - r_ext = 0.010001 - L_thin = calc_tubular_inductance(r_in, r_ext, 1.0) - expected_thin = 1.0 * μ₀ / (2 * π) * log(r_ext / r_in) - @test isapprox(L_thin, expected_thin, atol=TEST_TOL) - # Large radii - L_large = calc_tubular_inductance(1e3, 2e3, 1.0) - expected_large = 1.0 * μ₀ / (2 * π) * log(2e3 / 1e3) - @test isapprox(L_large, expected_large, atol=TEST_TOL) - # mu_r = 0 (non-magnetic) - @test isapprox(calc_tubular_inductance(0.01, 0.02, 0.0), 0.0, atol=TEST_TOL) - end - - # Numerical Consistency - @testset "Numerical Consistency" begin - # Float32 - Lf = calc_tubular_inductance(Float32(0.01), Float32(0.02), Float32(1.0)) - expectedf = Float32(μ₀) / (2f0 * Float32(π)) * log(Float32(0.02) / Float32(0.01)) - @test isapprox(Lf, expectedf, atol=Float32(TEST_TOL)) - # Rational - Lr = calc_tubular_inductance(1 // 100, 1 // 50, 1 // 1) - expectedr = (1 // 1) * μ₀ / (2 * π) * log((1 // 50) / (1 // 100)) - @test isapprox(Lr, expectedr, atol=TEST_TOL) - end - - # Physical Behavior - @testset "Physical Behavior" begin - # L increases with mu_r - L1 = calc_tubular_inductance(0.01, 0.02, 1.0) - L2 = calc_tubular_inductance(0.01, 0.02, 2.0) - @test L2 > L1 - # L increases with radius_ext - L3 = calc_tubular_inductance(0.01, 0.03, 1.0) - @test L3 > L1 - # L decreases with radius_in - L4 = calc_tubular_inductance(0.02, 0.03, 1.0) - @test L4 < L3 - end - - # Type Stability & Promotion - @testset "Type Stability & Promotion" begin - # All Float64 - Lf = calc_tubular_inductance(0.01, 0.02, 1.0) - @test typeof(Lf) == Float64 - # All Measurement - rinm = measurement(0.01, 1e-5) - rextm = measurement(0.02, 1e-5) - murm = measurement(1.0, 1e-3) - Lm = calc_tubular_inductance(rinm, rextm, murm) - @test Lm isa Measurement{Float64} - # Mixed: radius_in as Measurement - Lmix1 = calc_tubular_inductance(rinm, 0.02, 1.0) - @test Lmix1 isa Measurement{Float64} - # Mixed: radius_ext as Measurement - Lmix2 = calc_tubular_inductance(0.01, rextm, 1.0) - @test Lmix2 isa Measurement{Float64} - # Mixed: mu_r as Measurement - Lmix3 = calc_tubular_inductance(0.01, 0.02, murm) - @test Lmix3 isa Measurement{Float64} - end - - # Uncertainty Quantification - @testset "Uncertainty Quantification" begin - rinm = measurement(0.01, 1e-5) - rextm = measurement(0.02, 1e-5) - murm = measurement(1.0, 1e-3) - Lm = calc_tubular_inductance(rinm, rextm, murm) - # Analytical propagation: L = mu_r * μ₀ / (2π) * log(r_ext/r_in) - μ = 1.0 * μ₀ / (2 * π) * log(0.02 / 0.01) - # Partial derivatives - dL_drin = -murm * μ₀ / (2 * π) * (1 / rinm) / (rextm / rinm) - dL_drext = murm * μ₀ / (2 * π) * (1 / rextm) / (rextm / rinm) - dL_dmurm = μ₀ / (2 * π) * log(0.02 / 0.01) - σ2 = (value(dL_drin) * uncertainty(rinm))^2 + (value(dL_drext) * uncertainty(rextm))^2 + (value(dL_dmurm) * uncertainty(murm))^2 - @test isapprox(value(Lm), μ, atol=TEST_TOL) - @test isapprox(uncertainty(Lm), sqrt(σ2), atol=TEST_TOL) - end -end +@testitem "BaseParams: calc_tubular_inductance unit tests" setup = [defaults] begin + using Measurements + # Basic Functionality + @testset "Basic Functionality" begin + # Example from docstring: radius_in = 0.01, radius_ext = 0.02, mu_r = 1.0 + L = calc_tubular_inductance(0.01, 0.02, 1.0) + expected = 1.0 * μ₀ / (2 * π) * log(0.02 / 0.01) + @test isapprox(L, expected, atol=TEST_TOL) + end + + # Edge Cases + @testset "Edge Cases" begin + # Very thin tube (radius_ext ≈ radius_in) + r_in = 0.01 + r_ext = 0.010001 + L_thin = calc_tubular_inductance(r_in, r_ext, 1.0) + expected_thin = 1.0 * μ₀ / (2 * π) * log(r_ext / r_in) + @test isapprox(L_thin, expected_thin, atol=TEST_TOL) + # Large radii + L_large = calc_tubular_inductance(1e3, 2e3, 1.0) + expected_large = 1.0 * μ₀ / (2 * π) * log(2e3 / 1e3) + @test isapprox(L_large, expected_large, atol=TEST_TOL) + # mu_r = 0 (non-magnetic) + @test isapprox(calc_tubular_inductance(0.01, 0.02, 0.0), 0.0, atol=TEST_TOL) + end + + # Numerical Consistency + @testset "Numerical Consistency" begin + # Float32 + Lf = calc_tubular_inductance(Float32(0.01), Float32(0.02), Float32(1.0)) + expectedf = Float32(μ₀) / (2f0 * Float32(π)) * log(Float32(0.02) / Float32(0.01)) + @test isapprox(Lf, expectedf, atol=Float32(TEST_TOL)) + # Rational + Lr = calc_tubular_inductance(1 // 100, 1 // 50, 1 // 1) + expectedr = (1 // 1) * μ₀ / (2 * π) * log((1 // 50) / (1 // 100)) + @test isapprox(Lr, expectedr, atol=TEST_TOL) + end + + # Physical Behavior + @testset "Physical Behavior" begin + # L increases with mu_r + L1 = calc_tubular_inductance(0.01, 0.02, 1.0) + L2 = calc_tubular_inductance(0.01, 0.02, 2.0) + @test L2 > L1 + # L increases with radius_ext + L3 = calc_tubular_inductance(0.01, 0.03, 1.0) + @test L3 > L1 + # L decreases with radius_in + L4 = calc_tubular_inductance(0.02, 0.03, 1.0) + @test L4 < L3 + end + + # Type Stability & Promotion + @testset "Type Stability & Promotion" begin + # All Float64 + Lf = calc_tubular_inductance(0.01, 0.02, 1.0) + @test typeof(Lf) == Float64 + # All Measurement + rinm = measurement(0.01, 1e-5) + rextm = measurement(0.02, 1e-5) + murm = measurement(1.0, 1e-3) + Lm = calc_tubular_inductance(rinm, rextm, murm) + @test Lm isa Measurement{Float64} + # Mixed: radius_in as Measurement + Lmix1 = calc_tubular_inductance(rinm, 0.02, 1.0) + @test Lmix1 isa Measurement{Float64} + # Mixed: radius_ext as Measurement + Lmix2 = calc_tubular_inductance(0.01, rextm, 1.0) + @test Lmix2 isa Measurement{Float64} + # Mixed: mu_r as Measurement + Lmix3 = calc_tubular_inductance(0.01, 0.02, murm) + @test Lmix3 isa Measurement{Float64} + end + + # Uncertainty Quantification + @testset "Uncertainty Quantification" begin + rinm = measurement(0.01, 1e-5) + rextm = measurement(0.02, 1e-5) + murm = measurement(1.0, 1e-3) + Lm = calc_tubular_inductance(rinm, rextm, murm) + # Analytical propagation: L = mu_r * μ₀ / (2π) * log(r_ext/r_in) + μ = 1.0 * μ₀ / (2 * π) * log(0.02 / 0.01) + # Partial derivatives + dL_drin = -murm * μ₀ / (2 * π) * (1 / rinm) / (rextm / rinm) + dL_drext = murm * μ₀ / (2 * π) * (1 / rextm) / (rextm / rinm) + dL_dmurm = μ₀ / (2 * π) * log(0.02 / 0.01) + σ2 = (value(dL_drin) * uncertainty(rinm))^2 + (value(dL_drext) * uncertainty(rextm))^2 + (value(dL_dmurm) * uncertainty(murm))^2 + @test isapprox(value(Lm), μ, atol=TEST_TOL) + @test isapprox(uncertainty(Lm), sqrt(σ2), atol=TEST_TOL) + end +end diff --git a/test/unit_BaseParams/test_calc_tubular_resistance.jl b/test/unit_BaseParams/test_calc_tubular_resistance.jl index a36b99ae..4605e09f 100644 --- a/test/unit_BaseParams/test_calc_tubular_resistance.jl +++ b/test/unit_BaseParams/test_calc_tubular_resistance.jl @@ -1,113 +1,113 @@ -@testitem "BaseParams: calc_tubular_resistance unit tests" setup = [defaults] begin - # Basic Functionality - @testset "Basic Functionality" begin - # Example from docstring - radius_in = 0.01 - radius_ext = 0.02 - rho = 1.7241e-8 - alpha = 0.00393 - T0 = 20.0 - Top = 25.0 - expected = calc_temperature_correction(alpha, Top, T0) * rho / (π * (radius_ext^2 - radius_in^2)) - R = calc_tubular_resistance(radius_in, radius_ext, rho, alpha, T0, Top) - @test isapprox(R, expected, atol=TEST_TOL) - end - - # Edge Cases - @testset "Edge Cases" begin - # Zero thickness (radius_in == radius_ext): cross-section = 0, expect Inf or error - r_in = 0.01 - r_ext = 0.01 - rho = 1.7241e-8 - alpha = 0.00393 - T0 = 20.0 - Top = 25.0 - # Should return Inf (division by zero) - R = calc_tubular_resistance(r_in, r_ext, rho, alpha, T0, Top) - @test isinf(R) - # Very thin tube (radius_ext - radius_in ≈ eps) - r_in2 = 0.01 - r_ext2 = 0.01 + eps() - R2 = calc_tubular_resistance(r_in2, r_ext2, rho, alpha, T0, Top) - @test R2 > 0 - # Large radii - R3 = calc_tubular_resistance(1.0, 2.0, rho, alpha, T0, Top) - @test R3 < 1e-8 - # Negative temperature coefficient (mathematically valid) - R4 = calc_tubular_resistance(0.01, 0.02, rho, -0.001, T0, Top) - expected4 = calc_temperature_correction(-0.001, Top, T0) * rho / (π * (0.02^2 - 0.01^2)) - @test isapprox(R4, expected4, atol=TEST_TOL) - end - - # Numerical Consistency - @testset "Numerical Consistency" begin - # Float32 - Rf = calc_tubular_resistance(Float32(0.01), Float32(0.02), Float32(1.7241e-8), Float32(0.00393), Float32(20.0), Float32(25.0)) - expectedf = calc_temperature_correction(Float32(0.00393), Float32(25.0), Float32(20.0)) * Float32(1.7241e-8) / (π * (Float32(0.02)^2 - Float32(0.01)^2)) - @test isapprox(Rf, expectedf, atol=Float32(TEST_TOL)) - end - - # Physical Behavior - @testset "Physical Behavior" begin - rho = 1.7241e-8 - alpha = 0.00393 - T0 = 20.0 - Top = 25.0 - # Resistance decreases as cross-section increases - R_small = calc_tubular_resistance(0.01, 0.015, rho, alpha, T0, Top) - R_large = calc_tubular_resistance(0.01, 0.03, rho, alpha, T0, Top) - @test R_large < R_small - # Resistance increases with increasing resistivity - R_lowrho = calc_tubular_resistance(0.01, 0.02, 1e-8, alpha, T0, Top) - R_highrho = calc_tubular_resistance(0.01, 0.02, 1e-7, alpha, T0, Top) - @test R_highrho > R_lowrho - # Resistance increases with increasing temperature (for positive alpha) - R_T1 = calc_tubular_resistance(0.01, 0.02, rho, alpha, T0, 25.0) - R_T2 = calc_tubular_resistance(0.01, 0.02, rho, alpha, T0, 75.0) - @test R_T2 > R_T1 - end - - # Type Stability & Promotion - @testset "Type Stability & Promotion" begin - # All Float64 - Rf = calc_tubular_resistance(0.01, 0.02, 1.7241e-8, 0.00393, 20.0, 25.0) - @test typeof(Rf) == Float64 - # All Measurement - using Measurements - rin_m = measurement(0.01, 1e-6) - rext_m = measurement(0.02, 1e-6) - rho_m = measurement(1.7241e-8, 1e-10) - alpha_m = measurement(0.00393, 1e-5) - T0_m = measurement(20.0, 0.1) - Top_m = measurement(25.0, 0.1) - Rm = calc_tubular_resistance(rin_m, rext_m, rho_m, alpha_m, T0_m, Top_m) - @test Rm isa Measurement{Float64} - # Mixed: first argument as Measurement - Rmix1 = calc_tubular_resistance(rin_m, 0.02, 1.7241e-8, 0.00393, 20.0, 25.0) - @test Rmix1 isa Measurement{Float64} - # Mixed: middle argument as Measurement - Rmix2 = calc_tubular_resistance(0.01, 0.02, rho_m, 0.00393, 20.0, 25.0) - @test Rmix2 isa Measurement{Float64} - # Mixed: last argument as Measurement - Rmix3 = calc_tubular_resistance(0.01, 0.02, 1.7241e-8, 0.00393, 20.0, Top_m) - @test Rmix3 isa Measurement{Float64} - end - - # Uncertainty Quantification - @testset "Uncertainty Quantification" begin - rin_m = measurement(0.01, 1e-6) - rext_m = measurement(0.02, 1e-6) - rho_m = measurement(1.7241e-8, 1e-10) - alpha_m = measurement(0.00393, 1e-5) - T0_m = measurement(20.0, 0.1) - Top_m = measurement(25.0, 0.1) - Rm = calc_tubular_resistance(rin_m, rext_m, rho_m, alpha_m, T0_m, Top_m) - # Analytical propagation (approximate, neglecting correlations): - ΔA = π * (value(rext_m)^2 - value(rin_m)^2) - k = value(calc_temperature_correction(alpha_m, Top_m, T0_m)) - μ = k * value(rho_m) / ΔA - @test isapprox(value(Rm), μ, atol=TEST_TOL) - # Uncertainty should be nonzero and scale with input uncertainties - @test uncertainty(Rm) > 0 - end +@testitem "BaseParams: calc_tubular_resistance unit tests" setup = [defaults] begin + # Basic Functionality + @testset "Basic Functionality" begin + # Example from docstring + radius_in = 0.01 + radius_ext = 0.02 + rho = 1.7241e-8 + alpha = 0.00393 + T0 = 20.0 + Top = 25.0 + expected = calc_temperature_correction(alpha, Top, T0) * rho / (π * (radius_ext^2 - radius_in^2)) + R = calc_tubular_resistance(radius_in, radius_ext, rho, alpha, T0, Top) + @test isapprox(R, expected, atol=TEST_TOL) + end + + # Edge Cases + @testset "Edge Cases" begin + # Zero thickness (radius_in == radius_ext): cross-section = 0, expect Inf or error + r_in = 0.01 + r_ext = 0.01 + rho = 1.7241e-8 + alpha = 0.00393 + T0 = 20.0 + Top = 25.0 + # Should return Inf (division by zero) + R = calc_tubular_resistance(r_in, r_ext, rho, alpha, T0, Top) + @test isinf(R) + # Very thin tube (radius_ext - radius_in ≈ eps) + r_in2 = 0.01 + r_ext2 = 0.01 + eps() + R2 = calc_tubular_resistance(r_in2, r_ext2, rho, alpha, T0, Top) + @test R2 > 0 + # Large radii + R3 = calc_tubular_resistance(1.0, 2.0, rho, alpha, T0, Top) + @test R3 < 1e-8 + # Negative temperature coefficient (mathematically valid) + R4 = calc_tubular_resistance(0.01, 0.02, rho, -0.001, T0, Top) + expected4 = calc_temperature_correction(-0.001, Top, T0) * rho / (π * (0.02^2 - 0.01^2)) + @test isapprox(R4, expected4, atol=TEST_TOL) + end + + # Numerical Consistency + @testset "Numerical Consistency" begin + # Float32 + Rf = calc_tubular_resistance(Float32(0.01), Float32(0.02), Float32(1.7241e-8), Float32(0.00393), Float32(20.0), Float32(25.0)) + expectedf = calc_temperature_correction(Float32(0.00393), Float32(25.0), Float32(20.0)) * Float32(1.7241e-8) / (π * (Float32(0.02)^2 - Float32(0.01)^2)) + @test isapprox(Rf, expectedf, atol=Float32(TEST_TOL)) + end + + # Physical Behavior + @testset "Physical Behavior" begin + rho = 1.7241e-8 + alpha = 0.00393 + T0 = 20.0 + Top = 25.0 + # Resistance decreases as cross-section increases + R_small = calc_tubular_resistance(0.01, 0.015, rho, alpha, T0, Top) + R_large = calc_tubular_resistance(0.01, 0.03, rho, alpha, T0, Top) + @test R_large < R_small + # Resistance increases with increasing resistivity + R_lowrho = calc_tubular_resistance(0.01, 0.02, 1e-8, alpha, T0, Top) + R_highrho = calc_tubular_resistance(0.01, 0.02, 1e-7, alpha, T0, Top) + @test R_highrho > R_lowrho + # Resistance increases with increasing temperature (for positive alpha) + R_T1 = calc_tubular_resistance(0.01, 0.02, rho, alpha, T0, 25.0) + R_T2 = calc_tubular_resistance(0.01, 0.02, rho, alpha, T0, 75.0) + @test R_T2 > R_T1 + end + + # Type Stability & Promotion + @testset "Type Stability & Promotion" begin + # All Float64 + Rf = calc_tubular_resistance(0.01, 0.02, 1.7241e-8, 0.00393, 20.0, 25.0) + @test typeof(Rf) == Float64 + # All Measurement + using Measurements + rin_m = measurement(0.01, 1e-6) + rext_m = measurement(0.02, 1e-6) + rho_m = measurement(1.7241e-8, 1e-10) + alpha_m = measurement(0.00393, 1e-5) + T0_m = measurement(20.0, 0.1) + Top_m = measurement(25.0, 0.1) + Rm = calc_tubular_resistance(rin_m, rext_m, rho_m, alpha_m, T0_m, Top_m) + @test Rm isa Measurement{Float64} + # Mixed: first argument as Measurement + Rmix1 = calc_tubular_resistance(rin_m, 0.02, 1.7241e-8, 0.00393, 20.0, 25.0) + @test Rmix1 isa Measurement{Float64} + # Mixed: middle argument as Measurement + Rmix2 = calc_tubular_resistance(0.01, 0.02, rho_m, 0.00393, 20.0, 25.0) + @test Rmix2 isa Measurement{Float64} + # Mixed: last argument as Measurement + Rmix3 = calc_tubular_resistance(0.01, 0.02, 1.7241e-8, 0.00393, 20.0, Top_m) + @test Rmix3 isa Measurement{Float64} + end + + # Uncertainty Quantification + @testset "Uncertainty Quantification" begin + rin_m = measurement(0.01, 1e-6) + rext_m = measurement(0.02, 1e-6) + rho_m = measurement(1.7241e-8, 1e-10) + alpha_m = measurement(0.00393, 1e-5) + T0_m = measurement(20.0, 0.1) + Top_m = measurement(25.0, 0.1) + Rm = calc_tubular_resistance(rin_m, rext_m, rho_m, alpha_m, T0_m, Top_m) + # Analytical propagation (approximate, neglecting correlations): + ΔA = π * (value(rext_m)^2 - value(rin_m)^2) + k = value(calc_temperature_correction(alpha_m, Top_m, T0_m)) + μ = k * value(rho_m) / ΔA + @test isapprox(value(Rm), μ, atol=TEST_TOL) + # Uncertainty should be nonzero and scale with input uncertainties + @test uncertainty(Rm) > 0 + end end \ No newline at end of file diff --git a/test/unit_BaseParams/test_calc_wirearray_coords.jl b/test/unit_BaseParams/test_calc_wirearray_coords.jl index 45d0d7a9..e70e7ea6 100644 --- a/test/unit_BaseParams/test_calc_wirearray_coords.jl +++ b/test/unit_BaseParams/test_calc_wirearray_coords.jl @@ -1,162 +1,162 @@ -@testitem "BaseParams: calc_wirearray_coords unit tests" setup = [defaults] begin - - @testset "Basic Functionality" begin - @testset "Standard 6-wire array at origin" begin - let num_wires = 6, radius_wire = 0.001, radius_in = 0.01 - lay_radius = radius_in + radius_wire # 0.011 - coords = calc_wirearray_coords(num_wires, radius_wire, radius_in) - - @test length(coords) == num_wires - @test coords isa Vector{Tuple{Float64,Float64}} - - # Expected coordinates for a 6-wire array (angle step = π/3) - expected = [ - (lay_radius, 0.0), # Angle 0 - (lay_radius * cos(π / 3), lay_radius * sin(π / 3)), # Angle π/3 - (lay_radius * cos(2π / 3), lay_radius * sin(2π / 3)), # Angle 2π/3 - (-lay_radius, 0.0), # Angle π - (lay_radius * cos(4π / 3), lay_radius * sin(4π / 3)), # Angle 4π/3 - (lay_radius * cos(5π / 3), lay_radius * sin(5π / 3)), # Angle 5π/3 - ] - - @test length(coords) == length(expected) - for (coord, exp_coord) in zip(coords, expected) - @test isapprox(coord[1], exp_coord[1]; atol=TEST_TOL) - @test isapprox(coord[2], exp_coord[2]; atol=TEST_TOL) - end - end - end - - @testset "4-wire array with non-zero center" begin - let num_wires = 4, radius_wire = 0.002, radius_in = 0.02, C = (0.1, -0.2) - lay_radius = radius_in + radius_wire # 0.022 - coords = calc_wirearray_coords(num_wires, radius_wire, radius_in, C) - - @test length(coords) == num_wires - - # Expected coordinates for a 4-wire array (angle step = π/2) - expected = [ - (C[1] + lay_radius, C[2]), # Angle 0 - (C[1], C[2] + lay_radius), # Angle π/2 - (C[1] - lay_radius, C[2]), # Angle π - (C[1], C[2] - lay_radius), # Angle 3π/2 - ] - @test length(coords) == length(expected) - for (coord, exp_coord) in zip(coords, expected) - @test isapprox(coord[1], exp_coord[1]; atol=TEST_TOL) - @test isapprox(coord[2], exp_coord[2]; atol=TEST_TOL) - end - end - end - end - - @testset "Edge Cases" begin - @testset "Single wire is always at the center" begin - # A single wire's lay radius is defined as 0. - coords = calc_wirearray_coords(1, 0.001, 0.01) - @test coords == [(0.0, 0.0)] - - C = (10.0, -20.0) - coords_C = calc_wirearray_coords(1, 0.001, 0.01, C) - @test coords_C == [C] - end - - @testset "Zero wires returns an empty vector" begin - coords = calc_wirearray_coords(0, 0.001, 0.01) - @test isempty(coords) - @test coords isa Vector - end - - @testset "Zero radii places all wires at the center" begin - # If lay radius is zero, all wires should be at the center C. - num_wires = 7 - coords = calc_wirearray_coords(num_wires, 0.0, 0.0) - @test length(coords) == num_wires - @test all(c -> c == (0.0, 0.0), coords) - - C = (1.0, 1.0) - coords_C = calc_wirearray_coords(num_wires, 0.0, 0.0, C) - @test length(coords_C) == num_wires - @test all(c -> c == C, coords_C) - end - end - - @testset "Type Stability and Promotion" begin - @testset "Base case: Float64 inputs" begin - coords = calc_wirearray_coords(6, 0.001, 0.01) - @test coords isa Vector{Tuple{Float64,Float64}} - @test eltype(first(coords)) == Float64 - end - - @testset "Fully promoted: All inputs are Measurement" begin - num_wires = 3 - rw = 0.001 ± 0.0001 - ri = 0.01 ± 0.0002 - C = (0.1 ± 0.01, -0.2 ± 0.02) - coords = calc_wirearray_coords(num_wires, rw, ri, C) - - @test coords isa Vector{Tuple{Measurement{Float64},Measurement{Float64}}} - @test eltype(first(coords)) == Measurement{Float64} - - # Check value and uncertainty propagation for the first wire (angle=0) - lay_radius = rw + ri - expected_x = C[1] + lay_radius - expected_y = C[2] # sin(0) is 0, so lay_radius term is zero - - @test coords[1][1] ≈ expected_x - @test coords[1][2] ≈ expected_y - end - - @testset "Mixed types: radius_wire is Measurement" begin - num_wires = 4 - rw = 0.001 ± 0.0001 - ri = 0.01 # Float64 - C = (0.1, -0.2) # Tuple{Float64, Float64} - coords = calc_wirearray_coords(num_wires, rw, ri, C=C) - - @test coords isa Vector{Tuple{Measurement{Float64},Measurement{Float64}}} - lay_radius_val = Measurements.value(rw) + ri - - # Wire 1 (angle 0) - @test Measurements.value(coords[1][1]) ≈ C[1] + lay_radius_val atol = TEST_TOL - @test Measurements.value(coords[1][2]) ≈ C[2] atol = TEST_TOL - @test Measurements.uncertainty(coords[1][1]) > 0 - @test Measurements.uncertainty(coords[1][2]) == 0 # sin(0) = 0, no uncertainty propagation - - # Wire 2 (angle π/2) - @test Measurements.value(coords[2][1]) ≈ C[1] atol = TEST_TOL - @test Measurements.value(coords[2][2]) ≈ C[2] + lay_radius_val atol = TEST_TOL - @test isapprox(Measurements.uncertainty(coords[2][1]), 0, atol=TEST_TOL) # cos(π/2) = 0, no uncertainty propagation - @test Measurements.uncertainty(coords[2][2]) > 0 - end - - @testset "Mixed types: radius_in is Measurement" begin - num_wires = 4 - rw = 0.001 # Float64 - ri = 0.01 ± 0.0002 - coords = calc_wirearray_coords(num_wires, rw, ri) - - @test coords isa Vector{Tuple{Measurement{Float64},Measurement{Float64}}} - lay_radius_uncert = Measurements.uncertainty(ri) - @test Measurements.uncertainty(coords[1][1]) ≈ lay_radius_uncert atol = TEST_TOL - end - - @testset "Mixed types: Center C is Measurement" begin - num_wires = 4 - rw = 0.001 # Float64 - ri = 0.01 # Float64 - C = (0.1 ± 0.01, -0.2 ± 0.02) - # Use keyword argument version to test the helper method - coords = calc_wirearray_coords(num_wires, rw, ri; C=C) - - @test coords isa Vector{Tuple{Measurement{Float64},Measurement{Float64}}} - lay_radius = rw + ri - - # Check uncertainty propagation from center C - @test Measurements.value(coords[1][1]) ≈ Measurements.value(C[1]) + lay_radius atol = TEST_TOL - @test Measurements.value(coords[1][2]) ≈ Measurements.value(C[2]) atol = TEST_TOL - @test Measurements.uncertainty(coords[1][1]) ≈ Measurements.uncertainty(C[1]) atol = TEST_TOL - @test Measurements.uncertainty(coords[1][2]) ≈ Measurements.uncertainty(C[2]) atol = TEST_TOL - end - end +@testitem "BaseParams: calc_wirearray_coords unit tests" setup = [defaults] begin + + @testset "Basic Functionality" begin + @testset "Standard 6-wire array at origin" begin + let num_wires = 6, radius_wire = 0.001, radius_in = 0.01 + lay_radius = radius_in + radius_wire # 0.011 + coords = calc_wirearray_coords(num_wires, radius_wire, radius_in) + + @test length(coords) == num_wires + @test coords isa Vector{Tuple{Float64,Float64}} + + # Expected coordinates for a 6-wire array (angle step = π/3) + expected = [ + (lay_radius, 0.0), # Angle 0 + (lay_radius * cos(π / 3), lay_radius * sin(π / 3)), # Angle π/3 + (lay_radius * cos(2π / 3), lay_radius * sin(2π / 3)), # Angle 2π/3 + (-lay_radius, 0.0), # Angle π + (lay_radius * cos(4π / 3), lay_radius * sin(4π / 3)), # Angle 4π/3 + (lay_radius * cos(5π / 3), lay_radius * sin(5π / 3)), # Angle 5π/3 + ] + + @test length(coords) == length(expected) + for (coord, exp_coord) in zip(coords, expected) + @test isapprox(coord[1], exp_coord[1]; atol=TEST_TOL) + @test isapprox(coord[2], exp_coord[2]; atol=TEST_TOL) + end + end + end + + @testset "4-wire array with non-zero center" begin + let num_wires = 4, radius_wire = 0.002, radius_in = 0.02, C = (0.1, -0.2) + lay_radius = radius_in + radius_wire # 0.022 + coords = calc_wirearray_coords(num_wires, radius_wire, radius_in, C) + + @test length(coords) == num_wires + + # Expected coordinates for a 4-wire array (angle step = π/2) + expected = [ + (C[1] + lay_radius, C[2]), # Angle 0 + (C[1], C[2] + lay_radius), # Angle π/2 + (C[1] - lay_radius, C[2]), # Angle π + (C[1], C[2] - lay_radius), # Angle 3π/2 + ] + @test length(coords) == length(expected) + for (coord, exp_coord) in zip(coords, expected) + @test isapprox(coord[1], exp_coord[1]; atol=TEST_TOL) + @test isapprox(coord[2], exp_coord[2]; atol=TEST_TOL) + end + end + end + end + + @testset "Edge Cases" begin + @testset "Single wire is always at the center" begin + # A single wire's lay radius is defined as 0. + coords = calc_wirearray_coords(1, 0.001, 0.01) + @test coords == [(0.0, 0.0)] + + C = (10.0, -20.0) + coords_C = calc_wirearray_coords(1, 0.001, 0.01, C) + @test coords_C == [C] + end + + @testset "Zero wires returns an empty vector" begin + coords = calc_wirearray_coords(0, 0.001, 0.01) + @test isempty(coords) + @test coords isa Vector + end + + @testset "Zero radii places all wires at the center" begin + # If lay radius is zero, all wires should be at the center C. + num_wires = 7 + coords = calc_wirearray_coords(num_wires, 0.0, 0.0) + @test length(coords) == num_wires + @test all(c -> c == (0.0, 0.0), coords) + + C = (1.0, 1.0) + coords_C = calc_wirearray_coords(num_wires, 0.0, 0.0, C) + @test length(coords_C) == num_wires + @test all(c -> c == C, coords_C) + end + end + + @testset "Type Stability and Promotion" begin + @testset "Base case: Float64 inputs" begin + coords = calc_wirearray_coords(6, 0.001, 0.01) + @test coords isa Vector{Tuple{Float64,Float64}} + @test eltype(first(coords)) == Float64 + end + + @testset "Fully promoted: All inputs are Measurement" begin + num_wires = 3 + rw = 0.001 ± 0.0001 + ri = 0.01 ± 0.0002 + C = (0.1 ± 0.01, -0.2 ± 0.02) + coords = calc_wirearray_coords(num_wires, rw, ri, C) + + @test coords isa Vector{Tuple{Measurement{Float64},Measurement{Float64}}} + @test eltype(first(coords)) == Measurement{Float64} + + # Check value and uncertainty propagation for the first wire (angle=0) + lay_radius = rw + ri + expected_x = C[1] + lay_radius + expected_y = C[2] # sin(0) is 0, so lay_radius term is zero + + @test coords[1][1] ≈ expected_x + @test coords[1][2] ≈ expected_y + end + + @testset "Mixed types: radius_wire is Measurement" begin + num_wires = 4 + rw = 0.001 ± 0.0001 + ri = 0.01 # Float64 + C = (0.1, -0.2) # Tuple{Float64, Float64} + coords = calc_wirearray_coords(num_wires, rw, ri, C=C) + + @test coords isa Vector{Tuple{Measurement{Float64},Measurement{Float64}}} + lay_radius_val = Measurements.value(rw) + ri + + # Wire 1 (angle 0) + @test Measurements.value(coords[1][1]) ≈ C[1] + lay_radius_val atol = TEST_TOL + @test Measurements.value(coords[1][2]) ≈ C[2] atol = TEST_TOL + @test Measurements.uncertainty(coords[1][1]) > 0 + @test Measurements.uncertainty(coords[1][2]) == 0 # sin(0) = 0, no uncertainty propagation + + # Wire 2 (angle π/2) + @test Measurements.value(coords[2][1]) ≈ C[1] atol = TEST_TOL + @test Measurements.value(coords[2][2]) ≈ C[2] + lay_radius_val atol = TEST_TOL + @test isapprox(Measurements.uncertainty(coords[2][1]), 0, atol=TEST_TOL) # cos(π/2) = 0, no uncertainty propagation + @test Measurements.uncertainty(coords[2][2]) > 0 + end + + @testset "Mixed types: radius_in is Measurement" begin + num_wires = 4 + rw = 0.001 # Float64 + ri = 0.01 ± 0.0002 + coords = calc_wirearray_coords(num_wires, rw, ri) + + @test coords isa Vector{Tuple{Measurement{Float64},Measurement{Float64}}} + lay_radius_uncert = Measurements.uncertainty(ri) + @test Measurements.uncertainty(coords[1][1]) ≈ lay_radius_uncert atol = TEST_TOL + end + + @testset "Mixed types: Center C is Measurement" begin + num_wires = 4 + rw = 0.001 # Float64 + ri = 0.01 # Float64 + C = (0.1 ± 0.01, -0.2 ± 0.02) + # Use keyword argument version to test the helper method + coords = calc_wirearray_coords(num_wires, rw, ri; C=C) + + @test coords isa Vector{Tuple{Measurement{Float64},Measurement{Float64}}} + lay_radius = rw + ri + + # Check uncertainty propagation from center C + @test Measurements.value(coords[1][1]) ≈ Measurements.value(C[1]) + lay_radius atol = TEST_TOL + @test Measurements.value(coords[1][2]) ≈ Measurements.value(C[2]) atol = TEST_TOL + @test Measurements.uncertainty(coords[1][1]) ≈ Measurements.uncertainty(C[1]) atol = TEST_TOL + @test Measurements.uncertainty(coords[1][2]) ≈ Measurements.uncertainty(C[2]) atol = TEST_TOL + end + end end \ No newline at end of file diff --git a/test/unit_BaseParams/test_calc_wirearray_gmr.jl b/test/unit_BaseParams/test_calc_wirearray_gmr.jl index 4761025d..95b79741 100644 --- a/test/unit_BaseParams/test_calc_wirearray_gmr.jl +++ b/test/unit_BaseParams/test_calc_wirearray_gmr.jl @@ -1,99 +1,99 @@ -@testitem "BaseParams: calc_wirearray_gmr unit tests" setup = [defaults] begin - using Measurements: measurement, value, uncertainty - - @testset "Basic Functionality" begin - # Example from docstring - lay_rad = 0.05 - N = 7 - rad_wire = 0.002 - mu_r = 1.0 - gmr = calc_wirearray_gmr(lay_rad, N, rad_wire, mu_r) - expected = exp((log(rad_wire * exp(-mu_r / 4) * N * lay_rad^(N - 1)) / N)) - @test isapprox(gmr, expected; atol=TEST_TOL) - @test gmr > 0 - end - - @testset "Edge Cases" begin - # N = 1 (single wire) - lay_rad = 0.05 - N = 1 - rad_wire = 0.002 - mu_r = 1.0 - gmr = calc_wirearray_gmr(lay_rad, N, rad_wire, mu_r) - expected = rad_wire * exp(-mu_r / 4) - @test isapprox(gmr, expected; atol=TEST_TOL) - - # mu_r = 0 (non-magnetic) - lay_rad = 0.05 - N = 7 - rad_wire = 0.002 - mu_r = 0.0 - gmr = calc_wirearray_gmr(lay_rad, N, rad_wire, mu_r) - expected = exp((log(rad_wire * N * lay_rad^(N - 1)) / N)) - @test isapprox(gmr, expected; atol=TEST_TOL) - - # rad_wire = 0 (degenerate wire) - lay_rad = 0.05 - N = 7 - rad_wire = 0.0 - mu_r = 1.0 - gmr = calc_wirearray_gmr(lay_rad, N, rad_wire, mu_r) - @test gmr == 0.0 - - # lay_rad = 0 (all wires at center) - lay_rad = 0.0 - N = 7 - rad_wire = 0.002 - mu_r = 1.0 - gmr = calc_wirearray_gmr(lay_rad, N, rad_wire, mu_r) - expected = exp((log(rad_wire * exp(-mu_r / 4) * N * 0.0^(N - 1)) / N)) - @test gmr == 0.0 - end - - @testset "Numerical Consistency" begin - # Float64 - gmr1 = calc_wirearray_gmr(0.05, 7, 0.002, 1.0) - # Measurement{Float64} - gmr2 = calc_wirearray_gmr(measurement(0.05, 1e-4), 7, 0.002, 1.0) - @test isapprox(value(gmr2), gmr1; atol=TEST_TOL) - @test uncertainty(gmr2) > 0 - end - - @testset "Physical Behavior" begin - # GMR increases with lay_rad - gmr1 = calc_wirearray_gmr(0.01, 7, 0.002, 1.0) - gmr2 = calc_wirearray_gmr(0.05, 7, 0.002, 1.0) - @test gmr2 > gmr1 - # GMR decreases with mu_r - gmr1 = calc_wirearray_gmr(0.05, 7, 0.002, 0.5) - gmr2 = calc_wirearray_gmr(0.05, 7, 0.002, 2.0) - @test gmr2 < gmr1 - end - - @testset "Type Stability & Promotion" begin - # All Float64 - gmr = calc_wirearray_gmr(0.05, 7, 0.002, 1.0) - @test typeof(gmr) == Float64 - # All Measurement - gmr = calc_wirearray_gmr(measurement(0.05, 1e-4), 7, measurement(0.002, 1e-5), measurement(1.0, 0.01)) - @test gmr isa Measurement{Float64} - # Mixed: lay_rad as Measurement - gmr = calc_wirearray_gmr(measurement(0.05, 1e-4), 7, 0.002, 1.0) - @test gmr isa Measurement{Float64} - # Mixed: rad_wire as Measurement - gmr = calc_wirearray_gmr(0.05, 7, measurement(0.002, 1e-5), 1.0) - @test gmr isa Measurement{Float64} - # Mixed: mu_r as Measurement - gmr = calc_wirearray_gmr(0.05, 7, 0.002, measurement(1.0, 0.01)) - @test gmr isa Measurement{Float64} - end - - @testset "Uncertainty Quantification" begin - lay_rad = measurement(0.05, 1e-4) - rad_wire = measurement(0.002, 1e-5) - mu_r = measurement(1.0, 0.01) - gmr = calc_wirearray_gmr(lay_rad, 7, rad_wire, mu_r) - @test gmr isa Measurement{Float64} - @test uncertainty(gmr) > 0 - end -end +@testitem "BaseParams: calc_wirearray_gmr unit tests" setup = [defaults] begin + using Measurements: measurement, value, uncertainty + + @testset "Basic Functionality" begin + # Example from docstring + lay_rad = 0.05 + N = 7 + rad_wire = 0.002 + mu_r = 1.0 + gmr = calc_wirearray_gmr(lay_rad, N, rad_wire, mu_r) + expected = exp((log(rad_wire * exp(-mu_r / 4) * N * lay_rad^(N - 1)) / N)) + @test isapprox(gmr, expected; atol=TEST_TOL) + @test gmr > 0 + end + + @testset "Edge Cases" begin + # N = 1 (single wire) + lay_rad = 0.05 + N = 1 + rad_wire = 0.002 + mu_r = 1.0 + gmr = calc_wirearray_gmr(lay_rad, N, rad_wire, mu_r) + expected = rad_wire * exp(-mu_r / 4) + @test isapprox(gmr, expected; atol=TEST_TOL) + + # mu_r = 0 (non-magnetic) + lay_rad = 0.05 + N = 7 + rad_wire = 0.002 + mu_r = 0.0 + gmr = calc_wirearray_gmr(lay_rad, N, rad_wire, mu_r) + expected = exp((log(rad_wire * N * lay_rad^(N - 1)) / N)) + @test isapprox(gmr, expected; atol=TEST_TOL) + + # rad_wire = 0 (degenerate wire) + lay_rad = 0.05 + N = 7 + rad_wire = 0.0 + mu_r = 1.0 + gmr = calc_wirearray_gmr(lay_rad, N, rad_wire, mu_r) + @test gmr == 0.0 + + # lay_rad = 0 (all wires at center) + lay_rad = 0.0 + N = 7 + rad_wire = 0.002 + mu_r = 1.0 + gmr = calc_wirearray_gmr(lay_rad, N, rad_wire, mu_r) + expected = exp((log(rad_wire * exp(-mu_r / 4) * N * 0.0^(N - 1)) / N)) + @test gmr == 0.0 + end + + @testset "Numerical Consistency" begin + # Float64 + gmr1 = calc_wirearray_gmr(0.05, 7, 0.002, 1.0) + # Measurement{Float64} + gmr2 = calc_wirearray_gmr(measurement(0.05, 1e-4), 7, 0.002, 1.0) + @test isapprox(value(gmr2), gmr1; atol=TEST_TOL) + @test uncertainty(gmr2) > 0 + end + + @testset "Physical Behavior" begin + # GMR increases with lay_rad + gmr1 = calc_wirearray_gmr(0.01, 7, 0.002, 1.0) + gmr2 = calc_wirearray_gmr(0.05, 7, 0.002, 1.0) + @test gmr2 > gmr1 + # GMR decreases with mu_r + gmr1 = calc_wirearray_gmr(0.05, 7, 0.002, 0.5) + gmr2 = calc_wirearray_gmr(0.05, 7, 0.002, 2.0) + @test gmr2 < gmr1 + end + + @testset "Type Stability & Promotion" begin + # All Float64 + gmr = calc_wirearray_gmr(0.05, 7, 0.002, 1.0) + @test typeof(gmr) == Float64 + # All Measurement + gmr = calc_wirearray_gmr(measurement(0.05, 1e-4), 7, measurement(0.002, 1e-5), measurement(1.0, 0.01)) + @test gmr isa Measurement{Float64} + # Mixed: lay_rad as Measurement + gmr = calc_wirearray_gmr(measurement(0.05, 1e-4), 7, 0.002, 1.0) + @test gmr isa Measurement{Float64} + # Mixed: rad_wire as Measurement + gmr = calc_wirearray_gmr(0.05, 7, measurement(0.002, 1e-5), 1.0) + @test gmr isa Measurement{Float64} + # Mixed: mu_r as Measurement + gmr = calc_wirearray_gmr(0.05, 7, 0.002, measurement(1.0, 0.01)) + @test gmr isa Measurement{Float64} + end + + @testset "Uncertainty Quantification" begin + lay_rad = measurement(0.05, 1e-4) + rad_wire = measurement(0.002, 1e-5) + mu_r = measurement(1.0, 0.01) + gmr = calc_wirearray_gmr(lay_rad, 7, rad_wire, mu_r) + @test gmr isa Measurement{Float64} + @test uncertainty(gmr) > 0 + end +end diff --git a/test/unit_DataModel/test_CableComponent.jl b/test/unit_DataModel/test_CableComponent.jl index 347d4dce..4c3a2e20 100644 --- a/test/unit_DataModel/test_CableComponent.jl +++ b/test/unit_DataModel/test_CableComponent.jl @@ -1,145 +1,145 @@ -@testitem "DataModel(CableComponent): unit tests" setup = [defaults, deps_datamodel, defs_materials] begin - # Aliases (no `using` per project policy) - const LM = LineCableModels - const DM = LM.DataModel - const MAT = LM.Materials - - # --- Canonical materials (fallbacks in case `defs_materials` lacks a key) --- - copper = get(materials, "copper", MAT.Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393)) - aluminum = get(materials, "aluminum", MAT.Material(2.826e-8, 1.0, 1.0, 20.0, 0.00429)) - polyeth = get(materials, "polyethylene", MAT.Material(1e12, 2.3, 1.0, 20.0, 0.0)) - semimat = get(materials, "semicon", MAT.Material(1e3, 3.0, 1.0, 20.0, 0.0)) - - # --- Helpers --------------------------------------------------------------- - make_conductor_group_F = function () - # core: 1 wire at center (diameter d_w) - d_w = 3e-3 - g = DM.ConductorGroup(DM.WireArray(0.0, DM.Diameter(d_w), 1, 0.0, aluminum)) - # add helical wire layer (defaults: radius_in = group.radius_ext) - DM.add!(g, DM.WireArray, DM.Diameter(d_w), 6, 10.0, aluminum) - # add thin strip layer - DM.add!(g, DM.Strip, DM.Thickness(5e-4), 1.0e-2, 15.0, aluminum) - # add tubular sheath (thickness) - DM.add!(g, DM.Tubular, DM.Thickness(1e-3), aluminum) - return g - end - - make_insulator_group_F = function (rin::Real) - # Build from inner radius `rin` outward - g = DM.InsulatorGroup(DM.Insulator(rin, DM.Thickness(4e-3), polyeth)) - DM.add!(g, DM.Semicon, DM.Thickness(5e-4), semimat) - return g - end - - # Small helper for measurement creation - m = x -> measurement(x, 0.1 * x + (x == 0 ? 1e-6 : 0)) - - # --- Input Validation ------------------------------------------------------ - @testset "Input Validation" begin - gC = make_conductor_group_F() - # make insulator group that *does not* start exactly at gC.radius_ext - bad_rin = gC.radius_ext + 1e-6 - gI_bad = DM.InsulatorGroup(DM.Insulator(bad_rin, DM.Thickness(4e-3), polyeth)) - DM.add!(gI_bad, DM.Semicon, DM.Thickness(5e-4), semimat) - @test_throws ArgumentError DM.CableComponent("bad", gC, gI_bad) - end - - # --- Basic Functionality (Float64 workflow) -------------------------------- - @testset "Basic Functionality (Float64)" begin - gC = make_conductor_group_F() - gI = make_insulator_group_F(gC.radius_ext) - cc = DM.CableComponent("core", gC, gI) - - # Type & identity when no promotion needed - @test eltype(cc) == Float64 - @test cc.id == "core" - @test cc.conductor_group === gC - @test cc.insulator_group === gI - - # Geometric continuity (nominal comparison) - @test isapprox(cc.conductor_group.radius_ext, cc.insulator_group.radius_in; atol=TEST_TOL) - - # Physical sanity - @test cc.conductor_props.rho > 0 - @test cc.conductor_props.mu_r > 0 - @test cc.insulator_props.eps_r > 0 - @test cc.insulator_props.rho > 0 - end - - # --- Edge Cases ------------------------------------------------------------ - @testset "Edge Cases" begin - gC = DM.ConductorGroup(DM.WireArray(0.0, DM.Diameter(2e-3), 1, 0.0, copper)) - # hairline insulator: nearly zero thickness, but non-zero - gI = DM.InsulatorGroup(DM.Insulator(gC.radius_ext, gC.radius_ext + 1e-6, polyeth)) - cc = DM.CableComponent("thin", gC, gI) - @test cc.insulator_group.radius_ext > cc.conductor_group.radius_ext - @test cc.insulator_props.eps_r > 0 - end - - # --- Physical Behavior (relationships that should hold) -------------------- - @testset "Physical Behavior" begin - gC = make_conductor_group_F() - gI = make_insulator_group_F(gC.radius_ext) - cc = DM.CableComponent("phys", gC, gI) - - # Conductor alpha propagated - @test isapprox(cc.conductor_props.alpha, gC.alpha; atol=TEST_TOL) - - gI2 = DM.InsulatorGroup(DM.Insulator(gC.radius_ext, DM.Thickness(6e-3), polyeth)) - DM.add!(gI2, DM.Semicon, DM.Thickness(5e-4), semimat) - cc2 = DM.CableComponent("phys2", gC, gI2) - @test cc2.insulator_group.shunt_capacitance < cc.insulator_group.shunt_capacitance - end - - # --- Type Stability & Promotion ------------------------------------------- - @testset "Type Stability & Promotion" begin - # Base: both Float64 - gC_F = make_conductor_group_F() - gI_F = make_insulator_group_F(gC_F.radius_ext) - cc_F = DM.CableComponent("F", gC_F, gI_F) - @test eltype(cc_F) == Float64 - - # Insulator as Measurement → component promotes - gI_M = DM.coerce_to_T(gI_F, Measurement{Float64}) - cc_PM = DM.CableComponent("PM", gC_F, gI_M) - @test eltype(cc_PM) <: Measurement - @test eltype(cc_PM.conductor_group) <: Measurement - @test eltype(cc_PM.insulator_group) <: Measurement - # original groups untouched - @test eltype(gC_F) == Float64 - @test eltype(gI_F) == Float64 - - # Conductor as Measurement → component promotes - gC_M = DM.coerce_to_T(gC_F, Measurement{Float64}) - cc_MP = DM.CableComponent("MP", gC_M, gI_F) - @test eltype(cc_MP) <: Measurement - - # Both Measurement - cc_MM = DM.CableComponent("MM", gC_M, gI_M) - @test eltype(cc_MM) <: Measurement - - # Mixed raw creation using measurements inside groups - gC_mix = DM.ConductorGroup(DM.WireArray(0.0, DM.Diameter(m(3e-3)), 1, 0.0, aluminum)) - DM.add!(gC_mix, DM.Tubular, DM.Thickness(m(5e-4)), copper) - gI_mix = DM.InsulatorGroup(DM.Insulator(gC_mix.radius_ext, DM.Thickness(2e-3), polyeth)) - cc_mix = DM.CableComponent("mix", gC_mix, gI_mix) - @test eltype(cc_mix) <: Measurement - end - - # --- Combinatorial Type Testing (constructor path inside groups) ----------- - @testset "Combinatorial Type Testing" begin - # Base floats - gC = DM.ConductorGroup(DM.WireArray(0.0, DM.Diameter(2e-3), 1, 0.0, aluminum)) - gI = DM.InsulatorGroup(DM.Insulator(gC.radius_ext, DM.Thickness(3e-3), polyeth)) - - # Case A: Float + Measurement (insulator group) - gI_A = DM.coerce_to_T(gI, Measurement{Float64}) - cc_A = DM.CableComponent("A", gC, gI_A) - @test eltype(cc_A) <: Measurement - - # Case B: Measurement + Float (conductor group) - gC_B = DM.coerce_to_T(gC, Measurements.Measurement{Float64}) - cc_B = DM.CableComponent("B", gC_B, gI) - @test eltype(cc_B) <: Measurement - end -end +@testitem "DataModel(CableComponent): unit tests" setup = [defaults, deps_datamodel, defs_materials] begin + # Aliases (no `using` per project policy) + const LM = LineCableModels + const DM = LM.DataModel + const MAT = LM.Materials + + # --- Canonical materials (fallbacks in case `defs_materials` lacks a key) --- + copper = get(materials, "copper", MAT.Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393, 401.0)) + aluminum = get(materials, "aluminum", MAT.Material(2.826e-8, 1.0, 1.0, 20.0, 0.00429, 237.0)) + polyeth = get(materials, "polyethylene", MAT.Material(1e12, 2.3, 1.0, 20.0, 0.0, 0.44)) + semimat = get(materials, "semicon", MAT.Material(1e3, 3.0, 1.0, 20.0, 0.0, 148.0)) + + # --- Helpers --------------------------------------------------------------- + make_conductor_group_F = function () + # core: 1 wire at center (diameter d_w) + d_w = 3e-3 + g = DM.ConductorGroup(DM.WireArray(0.0, DM.Diameter(d_w), 1, 0.0, aluminum)) + # add helical wire layer (defaults: radius_in = group.radius_ext) + DM.add!(g, DM.WireArray, DM.Diameter(d_w), 6, 10.0, aluminum) + # add thin strip layer + DM.add!(g, DM.Strip, DM.Thickness(5e-4), 1.0e-2, 15.0, aluminum) + # add tubular sheath (thickness) + DM.add!(g, DM.Tubular, DM.Thickness(1e-3), aluminum) + return g + end + + make_insulator_group_F = function (rin::Real) + # Build from inner radius `rin` outward + g = DM.InsulatorGroup(DM.Insulator(rin, DM.Thickness(4e-3), polyeth)) + DM.add!(g, DM.Semicon, DM.Thickness(5e-4), semimat) + return g + end + + # Small helper for measurement creation + m = x -> measurement(x, 0.1 * x + (x == 0 ? 1e-6 : 0)) + + # --- Input Validation ------------------------------------------------------ + @testset "Input Validation" begin + gC = make_conductor_group_F() + # make insulator group that *does not* start exactly at gC.radius_ext + bad_rin = gC.radius_ext + 1e-6 + gI_bad = DM.InsulatorGroup(DM.Insulator(bad_rin, DM.Thickness(4e-3), polyeth)) + DM.add!(gI_bad, DM.Semicon, DM.Thickness(5e-4), semimat) + @test_throws ArgumentError DM.CableComponent("bad", gC, gI_bad) + end + + # --- Basic Functionality (Float64 workflow) -------------------------------- + @testset "Basic Functionality (Float64)" begin + gC = make_conductor_group_F() + gI = make_insulator_group_F(gC.radius_ext) + cc = DM.CableComponent("core", gC, gI) + + # Type & identity when no promotion needed + @test eltype(cc) == Float64 + @test cc.id == "core" + @test cc.conductor_group === gC + @test cc.insulator_group === gI + + # Geometric continuity (nominal comparison) + @test isapprox(cc.conductor_group.radius_ext, cc.insulator_group.radius_in; atol=TEST_TOL) + + # Physical sanity + @test cc.conductor_props.rho > 0 + @test cc.conductor_props.mu_r > 0 + @test cc.insulator_props.eps_r > 0 + @test cc.insulator_props.rho > 0 + end + + # --- Edge Cases ------------------------------------------------------------ + @testset "Edge Cases" begin + gC = DM.ConductorGroup(DM.WireArray(0.0, DM.Diameter(2e-3), 1, 0.0, copper)) + # hairline insulator: nearly zero thickness, but non-zero + gI = DM.InsulatorGroup(DM.Insulator(gC.radius_ext, gC.radius_ext + 1e-6, polyeth)) + cc = DM.CableComponent("thin", gC, gI) + @test cc.insulator_group.radius_ext > cc.conductor_group.radius_ext + @test cc.insulator_props.eps_r > 0 + end + + # --- Physical Behavior (relationships that should hold) -------------------- + @testset "Physical Behavior" begin + gC = make_conductor_group_F() + gI = make_insulator_group_F(gC.radius_ext) + cc = DM.CableComponent("phys", gC, gI) + + # Conductor alpha propagated + @test isapprox(cc.conductor_props.alpha, gC.alpha; atol=TEST_TOL) + + gI2 = DM.InsulatorGroup(DM.Insulator(gC.radius_ext, DM.Thickness(6e-3), polyeth)) + DM.add!(gI2, DM.Semicon, DM.Thickness(5e-4), semimat) + cc2 = DM.CableComponent("phys2", gC, gI2) + @test cc2.insulator_group.shunt_capacitance < cc.insulator_group.shunt_capacitance + end + + # --- Type Stability & Promotion ------------------------------------------- + @testset "Type Stability & Promotion" begin + # Base: both Float64 + gC_F = make_conductor_group_F() + gI_F = make_insulator_group_F(gC_F.radius_ext) + cc_F = DM.CableComponent("F", gC_F, gI_F) + @test eltype(cc_F) == Float64 + + # Insulator as Measurement → component promotes + gI_M = DM.coerce_to_T(gI_F, Measurement{Float64}) + cc_PM = DM.CableComponent("PM", gC_F, gI_M) + @test eltype(cc_PM) <: Measurement + @test eltype(cc_PM.conductor_group) <: Measurement + @test eltype(cc_PM.insulator_group) <: Measurement + # original groups untouched + @test eltype(gC_F) == Float64 + @test eltype(gI_F) == Float64 + + # Conductor as Measurement → component promotes + gC_M = DM.coerce_to_T(gC_F, Measurement{Float64}) + cc_MP = DM.CableComponent("MP", gC_M, gI_F) + @test eltype(cc_MP) <: Measurement + + # Both Measurement + cc_MM = DM.CableComponent("MM", gC_M, gI_M) + @test eltype(cc_MM) <: Measurement + + # Mixed raw creation using measurements inside groups + gC_mix = DM.ConductorGroup(DM.WireArray(0.0, DM.Diameter(m(3e-3)), 1, 0.0, aluminum)) + DM.add!(gC_mix, DM.Tubular, DM.Thickness(m(5e-4)), copper) + gI_mix = DM.InsulatorGroup(DM.Insulator(gC_mix.radius_ext, DM.Thickness(2e-3), polyeth)) + cc_mix = DM.CableComponent("mix", gC_mix, gI_mix) + @test eltype(cc_mix) <: Measurement + end + + # --- Combinatorial Type Testing (constructor path inside groups) ----------- + @testset "Combinatorial Type Testing" begin + # Base floats + gC = DM.ConductorGroup(DM.WireArray(0.0, DM.Diameter(2e-3), 1, 0.0, aluminum)) + gI = DM.InsulatorGroup(DM.Insulator(gC.radius_ext, DM.Thickness(3e-3), polyeth)) + + # Case A: Float + Measurement (insulator group) + gI_A = DM.coerce_to_T(gI, Measurement{Float64}) + cc_A = DM.CableComponent("A", gC, gI_A) + @test eltype(cc_A) <: Measurement + + # Case B: Measurement + Float (conductor group) + gC_B = DM.coerce_to_T(gC, Measurements.Measurement{Float64}) + cc_B = DM.CableComponent("B", gC_B, gI) + @test eltype(cc_B) <: Measurement + end +end diff --git a/test/unit_DataModel/test_CableDesign.jl b/test/unit_DataModel/test_CableDesign.jl index 93d6699e..979be920 100644 --- a/test/unit_DataModel/test_CableDesign.jl +++ b/test/unit_DataModel/test_CableDesign.jl @@ -1,169 +1,169 @@ -# ------------------------- -# Test fixtures (canonical parts) -# ------------------------- -@testsnippet cable_fixtures begin - - # Aliases - const LM = LineCableModels - const DM = LM.DataModel - const MAT = LM.Materials - using Measurements: measurement - - # metals & dielectrics - copper_props = MAT.Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393) - alu_props = MAT.Material(2.82e-8, 1.0, 1.0, 20.0, 0.0039) - xlpe_props = MAT.Material(1e10, 2.3, 1.0, 20.0, 0.0) # insulator-like - semi_props = MAT.Material(1e3, 2.6, 1.0, 20.0, 0.0) # semicon-ish - - # geometry - d_wire = 3e-3 # 3 mm - rin0 = 0.0 - - # One of each conductor type - core_wire = DM.WireArray(rin0, DM.Diameter(d_wire), 1, 0.0, copper_props) - # outer wire layer - outer_wire = DM.WireArray(core_wire.radius_ext, DM.Diameter(d_wire), 6, 10.0, copper_props) - # strip (placed over outer wire layer) - strip1 = DM.Strip(outer_wire.radius_ext, DM.Thickness(0.5e-3), 0.02, 8.0, copper_props) - # tubular (placed over strip) - tube1 = DM.Tubular(strip1.radius_ext, DM.Thickness(0.8e-3), copper_props) - - # Build a conductor group with mixed parts - function make_conductor_group() - g = DM.ConductorGroup(core_wire) - add!(g, DM.WireArray, DM.Diameter(d_wire), 6, 10.0, copper_props) - add!(g, DM.Strip, DM.Thickness(0.5e-3), 0.02, 8.0, copper_props) - add!(g, DM.Tubular, DM.Thickness(0.8e-3), copper_props) - g - end - - # Insulation parts - ins1 = DM.Insulator(tube1.radius_ext, DM.Thickness(2.0e-3), xlpe_props) - semi = DM.Semicon(ins1.radius_ext, DM.Thickness(0.8e-3), semi_props) - ins2 = DM.Insulator(semi.radius_ext, DM.Thickness(2.0e-3), xlpe_props) - - function make_insulator_group() - ig = DM.InsulatorGroup(ins1) - add!(ig, DM.Semicon, DM.Thickness(0.8e-3), semi_props) - add!(ig, DM.Insulator, DM.Thickness(2.0e-3), xlpe_props) - ig - end - - # convenience helpers used in tests - make_groups() = (make_conductor_group(), make_insulator_group()) - m(x, u) = measurement(x, u) -end - -@testitem "DataModel(CableDesign): unit tests" setup = [defaults, deps_datamodel, defs_materials, cable_fixtures] begin - # ------------------------- - # Tests - # ------------------------- - - @testset "Input Validation" begin - # mismatched radii: force an insulator group that doesn't start at conductor rex - g = make_conductor_group() - bad_ins = DM.InsulatorGroup(DM.Insulator(g.radius_ext + 1e-4, DM.Thickness(1e-3), MAT.Material(1e10, 3.0, 1.0, 20.0, 0.0))) - @test_throws ArgumentError DM.CableComponent("core", g, bad_ins) - @test_throws ArgumentError DM.CableDesign("cabA", g, bad_ins) - end - - @testset "Basic Functionality (Float64)" begin - g, ig = make_groups() - @test g.radius_ext ≈ ig.radius_in atol = TEST_TOL - - # direct component then design - cc = DM.CableComponent("core", g, ig) - @test cc isa DM.CableComponent - @test cc.id == "core" - @test cc.conductor_group === g - @test cc.insulator_group === ig - - des = DM.CableDesign("CAB-001", cc) - @test des isa DM.CableDesign - @test des.cable_id == "CAB-001" - @test length(des.components) == 1 - @test des.components[1].id == "core" - - # wrapper constructor from groups - des2 = DM.CableDesign("CAB-002", g, ig; component_id="core") - @test des2 isa DM.CableDesign - @test des2.components[1].id == "core" - end - - @testset "Edge Cases" begin - # tiny interface gap within tolerance should pass (uses isapprox in inner ctor) - g, ig = make_groups() - # Nudge insulator inner radius by a few eps of Float64 - ig.radius_in = ig.radius_in + eps(Float64) * 1 - @test DM.CableComponent("core", g, ig) isa DM.CableComponent - end - - @testset "Physical Behavior" begin - g, ig = make_groups() - cc = DM.CableComponent("core", g, ig) - # Resistivity and GMR-driven equivalent numbers should be positive - @test cc.conductor_props.rho > 0 - @test cc.conductor_group.gmr > 0 - @test cc.insulator_props.eps_r > 0 - @test cc.insulator_props.mu_r > 0 - @test cc.insulator_group.shunt_capacitance > 0 - end - - @testset "Type Stability & Promotion (component creation)" begin - # Build groups in Float64 then promote *only* one group with a Measurement value - gF, igF = make_groups() - - # Create a Measurement insulator by tweaking thickness with uncertainty - igM = DM.coerce_to_T(igF, Measurements.Measurement{Float64}) - ccP = DM.CableComponent("coreM", gF, igM) - @test typeof(ccP.conductor_group.radius_ext) <: Measurements.Measurement - @test typeof(ccP.insulator_group.radius_ext) <: Measurements.Measurement - - # Creating a design with this mixed component works and holds the component - des = DM.CableDesign("CAB-MIXED", ccP) - @test length(des.components) == 1 - @test des.components[1].id == "coreM" - @test typeof(des.components[1].conductor_group.radius_in) <: Measurements.Measurement - end - - @testset "Design add! (by component & by groups) + overwrite semantics" begin - g, ig = make_groups() - cc = DM.CableComponent("core", g, ig) - des = DM.CableDesign("CAB-ADD", cc) - @test length(des.components) == 1 - - # Add a second component by groups - g2, ig2 = make_groups() - DM.add!(des, "sheath", g2, ig2) - @test length(des.components) == 2 - @test any(c -> c.id == "sheath", des.components) - - # Add another with same id -> overwrite warning - @test length(des.components) == 2 - end - - @testset "Combinatorial Type Testing (Measurement vs Float)" begin - g, ig = make_groups() - - # 1) Base: both Float64 - ccF = DM.CableComponent("cF", g, ig) - @test typeof(ccF.conductor_group.radius_in) == Float64 - - # 2) Fully promoted: both Measurement - gM = DM.coerce_to_T(g, Measurements.Measurement{Float64}) - igM = DM.coerce_to_T(ig, Measurements.Measurement{Float64}) - ccM = DM.CableComponent("cM", gM, igM) - @test typeof(ccM.conductor_group.radius_in) <: Measurements.Measurement - @test typeof(ccM.insulator_group.radius_ext) <: Measurements.Measurement - - # 3a) Mixed: conductor carries Measurement - cc1 = DM.CableComponent("c1", gM, ig) - @test typeof(cc1.conductor_group.radius_in) <: Measurements.Measurement - @test typeof(cc1.insulator_group.radius_in) <: Measurements.Measurement - - # 3b) Mixed: insulator carries Measurement - cc2 = DM.CableComponent("c2", g, igM) - @test typeof(cc2.conductor_group.radius_in) <: Measurements.Measurement - @test typeof(cc2.insulator_group.radius_in) <: Measurements.Measurement - end -end +# ------------------------- +# Test fixtures (canonical parts) +# ------------------------- +@testsnippet cable_fixtures begin + + # Aliases + const LM = LineCableModels + const DM = LM.DataModel + const MAT = LM.Materials + using Measurements: measurement + + # metals & dielectrics + copper_props = MAT.Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393, 401.0) + alu_props = MAT.Material(2.82e-8, 1.0, 1.0, 20.0, 0.0039, 237.0) + xlpe_props = MAT.Material(1e10, 2.3, 1.0, 20.0, 0.0, 0.3) # insulator-like + semi_props = MAT.Material(1e3, 2.6, 1.0, 20.0, 0.0, 148.0) # semicon-ish + + # geometry + d_wire = 3e-3 # 3 mm + rin0 = 0.0 + + # One of each conductor type + core_wire = DM.WireArray(rin0, DM.Diameter(d_wire), 1, 0.0, copper_props) + # outer wire layer + outer_wire = DM.WireArray(core_wire.radius_ext, DM.Diameter(d_wire), 6, 10.0, copper_props) + # strip (placed over outer wire layer) + strip1 = DM.Strip(outer_wire.radius_ext, DM.Thickness(0.5e-3), 0.02, 8.0, copper_props) + # tubular (placed over strip) + tube1 = DM.Tubular(strip1.radius_ext, DM.Thickness(0.8e-3), copper_props) + + # Build a conductor group with mixed parts + function make_conductor_group() + g = DM.ConductorGroup(core_wire) + add!(g, DM.WireArray, DM.Diameter(d_wire), 6, 10.0, copper_props) + add!(g, DM.Strip, DM.Thickness(0.5e-3), 0.02, 8.0, copper_props) + add!(g, DM.Tubular, DM.Thickness(0.8e-3), copper_props) + g + end + + # Insulation parts + ins1 = DM.Insulator(tube1.radius_ext, DM.Thickness(2.0e-3), xlpe_props) + semi = DM.Semicon(ins1.radius_ext, DM.Thickness(0.8e-3), semi_props) + ins2 = DM.Insulator(semi.radius_ext, DM.Thickness(2.0e-3), xlpe_props) + + function make_insulator_group() + ig = DM.InsulatorGroup(ins1) + add!(ig, DM.Semicon, DM.Thickness(0.8e-3), semi_props) + add!(ig, DM.Insulator, DM.Thickness(2.0e-3), xlpe_props) + ig + end + + # convenience helpers used in tests + make_groups() = (make_conductor_group(), make_insulator_group()) + m(x, u) = measurement(x, u) +end + +@testitem "DataModel(CableDesign): unit tests" setup = [defaults, deps_datamodel, defs_materials, cable_fixtures] begin + # ------------------------- + # Tests + # ------------------------- + + @testset "Input Validation" begin + # mismatched radii: force an insulator group that doesn't start at conductor rex + g = make_conductor_group() + bad_ins = DM.InsulatorGroup(DM.Insulator(g.radius_ext + 1e-4, DM.Thickness(1e-3), MAT.Material(1e10, 3.0, 1.0, 20.0, 0.0, 0.1))) + @test_throws ArgumentError DM.CableComponent("core", g, bad_ins) + @test_throws ArgumentError DM.CableDesign("cabA", g, bad_ins) + end + + @testset "Basic Functionality (Float64)" begin + g, ig = make_groups() + @test g.radius_ext ≈ ig.radius_in atol = TEST_TOL + + # direct component then design + cc = DM.CableComponent("core", g, ig) + @test cc isa DM.CableComponent + @test cc.id == "core" + @test cc.conductor_group === g + @test cc.insulator_group === ig + + des = DM.CableDesign("CAB-001", cc) + @test des isa DM.CableDesign + @test des.cable_id == "CAB-001" + @test length(des.components) == 1 + @test des.components[1].id == "core" + + # wrapper constructor from groups + des2 = DM.CableDesign("CAB-002", g, ig; component_id="core") + @test des2 isa DM.CableDesign + @test des2.components[1].id == "core" + end + + @testset "Edge Cases" begin + # tiny interface gap within tolerance should pass (uses isapprox in inner ctor) + g, ig = make_groups() + # Nudge insulator inner radius by a few eps of Float64 + ig.radius_in = ig.radius_in + eps(Float64) * 1 + @test DM.CableComponent("core", g, ig) isa DM.CableComponent + end + + @testset "Physical Behavior" begin + g, ig = make_groups() + cc = DM.CableComponent("core", g, ig) + # Resistivity and GMR-driven equivalent numbers should be positive + @test cc.conductor_props.rho > 0 + @test cc.conductor_group.gmr > 0 + @test cc.insulator_props.eps_r > 0 + @test cc.insulator_props.mu_r > 0 + @test cc.insulator_group.shunt_capacitance > 0 + end + + @testset "Type Stability & Promotion (component creation)" begin + # Build groups in Float64 then promote *only* one group with a Measurement value + gF, igF = make_groups() + + # Create a Measurement insulator by tweaking thickness with uncertainty + igM = DM.coerce_to_T(igF, Measurements.Measurement{Float64}) + ccP = DM.CableComponent("coreM", gF, igM) + @test typeof(ccP.conductor_group.radius_ext) <: Measurements.Measurement + @test typeof(ccP.insulator_group.radius_ext) <: Measurements.Measurement + + # Creating a design with this mixed component works and holds the component + des = DM.CableDesign("CAB-MIXED", ccP) + @test length(des.components) == 1 + @test des.components[1].id == "coreM" + @test typeof(des.components[1].conductor_group.radius_in) <: Measurements.Measurement + end + + @testset "Design add! (by component & by groups) + overwrite semantics" begin + g, ig = make_groups() + cc = DM.CableComponent("core", g, ig) + des = DM.CableDesign("CAB-ADD", cc) + @test length(des.components) == 1 + + # Add a second component by groups + g2, ig2 = make_groups() + DM.add!(des, "sheath", g2, ig2) + @test length(des.components) == 2 + @test any(c -> c.id == "sheath", des.components) + + # Add another with same id -> overwrite warning + @test length(des.components) == 2 + end + + @testset "Combinatorial Type Testing (Measurement vs Float)" begin + g, ig = make_groups() + + # 1) Base: both Float64 + ccF = DM.CableComponent("cF", g, ig) + @test typeof(ccF.conductor_group.radius_in) == Float64 + + # 2) Fully promoted: both Measurement + gM = DM.coerce_to_T(g, Measurements.Measurement{Float64}) + igM = DM.coerce_to_T(ig, Measurements.Measurement{Float64}) + ccM = DM.CableComponent("cM", gM, igM) + @test typeof(ccM.conductor_group.radius_in) <: Measurements.Measurement + @test typeof(ccM.insulator_group.radius_ext) <: Measurements.Measurement + + # 3a) Mixed: conductor carries Measurement + cc1 = DM.CableComponent("c1", gM, ig) + @test typeof(cc1.conductor_group.radius_in) <: Measurements.Measurement + @test typeof(cc1.insulator_group.radius_in) <: Measurements.Measurement + + # 3b) Mixed: insulator carries Measurement + cc2 = DM.CableComponent("c2", g, igM) + @test typeof(cc2.conductor_group.radius_in) <: Measurements.Measurement + @test typeof(cc2.insulator_group.radius_in) <: Measurements.Measurement + end +end diff --git a/test/unit_DataModel/test_CablePosition.jl b/test/unit_DataModel/test_CablePosition.jl index 616f942b..695e6d44 100644 --- a/test/unit_DataModel/test_CablePosition.jl +++ b/test/unit_DataModel/test_CablePosition.jl @@ -1,83 +1,83 @@ -@testsnippet defs_cablepos begin - using Test - using LineCableModels - const DM = LineCableModels.DataModel - const MAT = LineCableModels.Materials - using Measurements - - # ---- helpers ---------------------------------------------------------- - - # Minimal Float64 design with matching interface radii - function _make_design_F64() - mC = MAT.Material(1e-8, 1.0, 1.0, 20.0, 0.0) - mI = MAT.Material(1e12, 2.5, 1.0, 20.0, 0.0) - - cg = DM.ConductorGroup(DM.Tubular(0.010, 0.012, mC)) - ig = DM.InsulatorGroup(DM.Insulator(0.012, 0.016, mI)) - - cc = DM.CableComponent("core", cg, ig) - return DM.CableDesign("CAB", cc) - end - - # Outermost radius of the last component (for placement checks) - _out_radius(des) = max( - des.components[end].conductor_group.radius_ext, - des.components[end].insulator_group.radius_ext, - ) -end - -@testitem "DataModel(CablePosition): constructor unit tests" setup = [defaults, defs_cablepos] begin - @testset "Basic construction (Float64)" begin - des = _make_design_F64() - rmax = _out_radius(des) - pos = DM.CablePosition(des, 1.0, rmax + 0.10) # default mapping - - @test pos isa DM.CablePosition - @test DM.eltype(pos) == Float64 - @test pos.design_data === des # no promotion → same object - @test pos.horz == 1.0 - @test pos.vert == rmax + 0.10 - @test length(pos.conn) == length(des.components) - @test any(!iszero, pos.conn) # at least one non-grounded - end - - @testset "Phase mapping (Dict-based)" begin - des = _make_design_F64() - rmax = _out_radius(des) - - # map by component id (unknown ids are rejected, missing ids default to 0) - conn = Dict(des.components[1].id => 1) - pos = DM.CablePosition(des, 0.0, rmax + 0.05, conn) - - @test pos.conn[1] == 1 - @test all(i == 1 ? pos.conn[i] == 1 : pos.conn[i] == 0 for i in 1:length(pos.conn)) - - bad = Dict("does-not-exist" => 1) - @test_throws ArgumentError DM.CablePosition(des, 0.0, rmax + 0.05, bad) - end - - @testset "Geometry validation" begin - des = _make_design_F64() - rmax = _out_radius(des) - - # exactly at z=0 is forbidden - @test_throws ArgumentError DM.CablePosition(des, 0.0, 0.0) - - # inside outer radius (crossing interface) is forbidden - @test_throws ArgumentError DM.CablePosition(des, 0.0, rmax * 0.5) - end - - @testset "Type stability & promotion" begin - desF = _make_design_F64() - rmax = _out_radius(desF) - vertM = measurement(rmax + 0.10, 1e-6) - - posM = DM.CablePosition(desF, 0.0, vertM) - - @test DM.eltype(posM) <: Measurement - @test DM.eltype(posM.design_data) <: Measurement # design promoted with position - @test posM.vert === vertM # identity preserved - @test typeof(posM.horz) <: Measurement # coerced to same scalar type - @test DM.coerce_to_T(posM, DM.eltype(posM)) === posM - end -end +@testsnippet defs_cablepos begin + using Test + using LineCableModels + const DM = LineCableModels.DataModel + const MAT = LineCableModels.Materials + using Measurements + + # ---- helpers ---------------------------------------------------------- + + # Minimal Float64 design with matching interface radii + function _make_design_F64() + mC = MAT.Material(1e-8, 1.0, 1.0, 20.0, 0.0, 100.0) + mI = MAT.Material(1e12, 2.5, 1.0, 20.0, 0.0, 0.5) + + cg = DM.ConductorGroup(DM.Tubular(0.010, 0.012, mC)) + ig = DM.InsulatorGroup(DM.Insulator(0.012, 0.016, mI)) + + cc = DM.CableComponent("core", cg, ig) + return DM.CableDesign("CAB", cc) + end + + # Outermost radius of the last component (for placement checks) + _out_radius(des) = max( + des.components[end].conductor_group.radius_ext, + des.components[end].insulator_group.radius_ext, + ) +end + +@testitem "DataModel(CablePosition): constructor unit tests" setup = [defaults, defs_cablepos] begin + @testset "Basic construction (Float64)" begin + des = _make_design_F64() + rmax = _out_radius(des) + pos = DM.CablePosition(des, 1.0, rmax + 0.10) # default mapping + + @test pos isa DM.CablePosition + @test DM.eltype(pos) == Float64 + @test pos.design_data === des # no promotion → same object + @test pos.horz == 1.0 + @test pos.vert == rmax + 0.10 + @test length(pos.conn) == length(des.components) + @test any(!iszero, pos.conn) # at least one non-grounded + end + + @testset "Phase mapping (Dict-based)" begin + des = _make_design_F64() + rmax = _out_radius(des) + + # map by component id (unknown ids are rejected, missing ids default to 0) + conn = Dict(des.components[1].id => 1) + pos = DM.CablePosition(des, 0.0, rmax + 0.05, conn) + + @test pos.conn[1] == 1 + @test all(i == 1 ? pos.conn[i] == 1 : pos.conn[i] == 0 for i in 1:length(pos.conn)) + + bad = Dict("does-not-exist" => 1) + @test_throws ArgumentError DM.CablePosition(des, 0.0, rmax + 0.05, bad) + end + + @testset "Geometry validation" begin + des = _make_design_F64() + rmax = _out_radius(des) + + # exactly at z=0 is forbidden + @test_throws ArgumentError DM.CablePosition(des, 0.0, 0.0) + + # inside outer radius (crossing interface) is forbidden + @test_throws ArgumentError DM.CablePosition(des, 0.0, rmax * 0.5) + end + + @testset "Type stability & promotion" begin + desF = _make_design_F64() + rmax = _out_radius(desF) + vertM = measurement(rmax + 0.10, 1e-6) + + posM = DM.CablePosition(desF, 0.0, vertM) + + @test DM.eltype(posM) <: Measurement + @test DM.eltype(posM.design_data) <: Measurement # design promoted with position + @test posM.vert === vertM # identity preserved + @test typeof(posM.horz) <: Measurement # coerced to same scalar type + @test DM.coerce_to_T(posM, DM.eltype(posM)) === posM + end +end diff --git a/test/unit_DataModel/test_ConductorGroup.jl b/test/unit_DataModel/test_ConductorGroup.jl index f68dbebf..b234b7b9 100644 --- a/test/unit_DataModel/test_ConductorGroup.jl +++ b/test/unit_DataModel/test_ConductorGroup.jl @@ -1,151 +1,151 @@ -@testsnippet defs_con_group begin - # Canonical geometry and helpers reused across tests - # Materials come from `defs_materials` (e.g., `copper_props`, `materials`) - using Measurements - - const rad_wire0 = 0.0015 # 1.5 mm wire radius - const lay_ratio0 = 12.0 # arbitrary, > 0 - - # A fresh single‑wire core (center conductor) - make_core_group() = ConductorGroup( - WireArray(0.0, rad_wire0, 1, 0.0, copper_props; temperature=20.0, lay_direction=1) - ) - - # Convenience: a Float64 Tubular sleeve over the given inner radius - make_tubular_over(rin, t, mat) = Tubular(rin, Thickness(t), mat; temperature=20.0) - - # Measurement helpers - m(x, u) = measurement(x, u) -end - -@testitem "DataModel(ConductorGroup.add!): unit tests" setup = [defaults, deps_datamodel, defs_materials, defs_con_group] begin - using Measurements - - @testset "Input Validation (wrapper triggers validate!)" begin - g = make_core_group() - - # Missing required args for WireArray (radius_wire, num_wires, lay_ratio, material) - @test_throws ArgumentError add!(g, WireArray) - @test_throws ArgumentError add!(g, WireArray, rad_wire0) - @test_throws ArgumentError add!(g, WireArray, rad_wire0, 6) - @test_throws ArgumentError add!(g, WireArray, rad_wire0, 6, lay_ratio0) - - # Invalid types forwarded to part validator - @test_throws ArgumentError add!(g, WireArray, "bad", 6, lay_ratio0, copper_props) - @test_throws ArgumentError add!(g, WireArray, rad_wire0, "bad", lay_ratio0, copper_props) - @test_throws ArgumentError add!(g, WireArray, rad_wire0, 6, "bad", copper_props) - @test_throws ArgumentError add!(g, WireArray, rad_wire0, 6, lay_ratio0, "not_a_material") - - # Out of range / geometry violations caught by rules - @test_throws ArgumentError add!(g, WireArray, -rad_wire0, 6, lay_ratio0, copper_props) - @test_throws ArgumentError add!(g, WireArray, 0.0, 6, lay_ratio0, copper_props) # radius_wire > 0 - @test_throws ArgumentError add!(g, WireArray, rad_wire0, 0, lay_ratio0, copper_props) # num_wires > 0 - - # Unknown keyword should be rejected by sanitize/keyword_fields policy (if enforced upstream) - # NOTE: If this currently passes, add rejection in `sanitize` for unknown keywords. - @test_throws ArgumentError add!(g, WireArray, rad_wire0, 6, lay_ratio0, copper_props; not_a_kw=1) - end - - @testset "Basic Functionality (Float64)" begin - g = make_core_group() - @test g isa ConductorGroup - @test length(g.layers) == 1 - @test g.radius_in == 0.0 - @test g.radius_ext ≈ rad_wire0 atol = TEST_TOL - - # Add another wire layer using Diameter convenience + defaults (radius_in auto = g.radius_ext) - d_w = 2 * rad_wire0 - g = add!(g, WireArray, Diameter(d_w), 6, 15.0, copper_props) # lay_direction defaults to 1 - @test g isa ConductorGroup - @test length(g.layers) == 2 - @test g.layers[end] isa WireArray - @test g.layers[end].lay_direction == 1 # came from keyword_defaults for WireArray - - # Geometry stacks outward and resistance decreases (parallel) - @test g.radius_ext > rad_wire0 - @test g.resistance < g.layers[1].resistance - - # Add an outer tubular sleeve by thickness proxy - outer_before = g.radius_ext - g = add!(g, Tubular, Thickness(0.002), copper_props) # temperature default from keyword_defaults(Tubular) - @test g.layers[end] isa Tubular - @test g.radius_ext ≈ outer_before + 0.002 atol = TEST_TOL - end - - @testset "Edge Cases" begin - # Very thin sleeve - g = make_core_group() - outer0 = g.radius_ext - g = add!(g, Tubular, Thickness(1e-6), copper_props) - @test g.radius_ext ≈ outer0 + 1e-6 atol = TEST_TOL - - # WireArray with lay_ratio very small but positive - g = make_core_group() - g = add!(g, WireArray, rad_wire0, 3, 1e-6, copper_props) - @test g.layers[end] isa WireArray - end - - @testset "Physical Behavior" begin - g = make_core_group() - # Add two conductor layers: resistance should drop further - R0 = g.resistance - g = add!(g, WireArray, rad_wire0, 6, 10.0, copper_props) - R1 = g.resistance - g = add!(g, WireArray, rad_wire0, 12, 10.0, copper_props) - R2 = g.resistance - @test R1 < R0 - @test R2 < R1 - - # Cross-section should be monotone increasing - @test g.cross_section > 0 - cs = [p.cross_section for p in g.layers if p isa LineCableModels.DataModel.AbstractConductorPart] - @test all(>(0), cs) - end - - @testset "Type Stability & Promotion (group)" begin - # Base: purely Float64 group - gF = make_core_group() - @test eltype(gF) == Float64 - @test typeof(gF.radius_ext) == Float64 - - # Promote by adding a Measurement argument (e.g., temperature) - gF_before_id = objectid(gF) - gP = add!(gF, WireArray, rad_wire0, 6, 10.0, copper_props; temperature=m(20.0, 0.1)) - @test gP !== gF # returned a promoted group - @test eltype(gP) <: Measurement - @test typeof(gP.radius_ext) <: Measurement - # Original left intact - @test objectid(gF) == gF_before_id - @test length(gF.layers) == 1 - - # In‑place when already Measurement - gM = LineCableModels.DataModel.coerce_to_T(make_core_group(), Measurement{Float64}) - id_before = objectid(gM) - gM2 = add!(gM, WireArray, m(rad_wire0, 1e-6), 6, 10.0, copper_props) - @test gM2 === gM # mutated in place - @test objectid(gM) == id_before - @test eltype(gM) <: Measurement - end - - @testset "Combinatorial Type Testing (constructor path of added part)" begin - # All Float64 - g = make_core_group() - g1 = add!(g, WireArray, rad_wire0, 6, 10.0, copper_props) - @test eltype(g1) == Float64 - - # All Measurement (radius_wire, lay_ratio, temperature) - g = make_core_group() - g2 = add!(g, WireArray, m(rad_wire0, 1e-6), 6, m(10.0, 0.1), copper_props; temperature=m(20.0, 0.1)) - @test eltype(g2) <: Measurement - - # Mixed case A: first numeric arg is Measurement - g = make_core_group() - g3 = add!(g, WireArray, m(rad_wire0, 1e-6), 6, 10.0, copper_props) - @test eltype(g3) <: Measurement - - # Mixed case B: middle arg (lay_ratio) is Measurement - g = make_core_group() - g4 = add!(g, WireArray, rad_wire0, 6, m(10.0, 0.1), copper_props) - @test eltype(g4) <: Measurement - end -end +@testsnippet defs_con_group begin + # Canonical geometry and helpers reused across tests + # Materials come from `defs_materials` (e.g., `copper_props`, `materials`) + using Measurements + + const rad_wire0 = 0.0015 # 1.5 mm wire radius + const lay_ratio0 = 12.0 # arbitrary, > 0 + + # A fresh single‑wire core (center conductor) + make_core_group() = ConductorGroup( + WireArray(0.0, rad_wire0, 1, 0.0, copper_props; temperature=20.0, lay_direction=1) + ) + + # Convenience: a Float64 Tubular sleeve over the given inner radius + make_tubular_over(rin, t, mat) = Tubular(rin, Thickness(t), mat; temperature=20.0) + + # Measurement helpers + m(x, u) = measurement(x, u) +end + +@testitem "DataModel(ConductorGroup.add!): unit tests" setup = [defaults, deps_datamodel, defs_materials, defs_con_group] begin + using Measurements + + @testset "Input Validation (wrapper triggers validate!)" begin + g = make_core_group() + + # Missing required args for WireArray (radius_wire, num_wires, lay_ratio, material) + @test_throws ArgumentError add!(g, WireArray) + @test_throws ArgumentError add!(g, WireArray, rad_wire0) + @test_throws ArgumentError add!(g, WireArray, rad_wire0, 6) + @test_throws ArgumentError add!(g, WireArray, rad_wire0, 6, lay_ratio0) + + # Invalid types forwarded to part validator + @test_throws ArgumentError add!(g, WireArray, "bad", 6, lay_ratio0, copper_props) + @test_throws ArgumentError add!(g, WireArray, rad_wire0, "bad", lay_ratio0, copper_props) + @test_throws ArgumentError add!(g, WireArray, rad_wire0, 6, "bad", copper_props) + @test_throws ArgumentError add!(g, WireArray, rad_wire0, 6, lay_ratio0, "not_a_material") + + # Out of range / geometry violations caught by rules + @test_throws ArgumentError add!(g, WireArray, -rad_wire0, 6, lay_ratio0, copper_props) + @test_throws ArgumentError add!(g, WireArray, 0.0, 6, lay_ratio0, copper_props) # radius_wire > 0 + @test_throws ArgumentError add!(g, WireArray, rad_wire0, 0, lay_ratio0, copper_props) # num_wires > 0 + + # Unknown keyword should be rejected by sanitize/keyword_fields policy (if enforced upstream) + # NOTE: If this currently passes, add rejection in `sanitize` for unknown keywords. + @test_throws ArgumentError add!(g, WireArray, rad_wire0, 6, lay_ratio0, copper_props; not_a_kw=1) + end + + @testset "Basic Functionality (Float64)" begin + g = make_core_group() + @test g isa ConductorGroup + @test length(g.layers) == 1 + @test g.radius_in == 0.0 + @test g.radius_ext ≈ rad_wire0 atol = TEST_TOL + + # Add another wire layer using Diameter convenience + defaults (radius_in auto = g.radius_ext) + d_w = 2 * rad_wire0 + g = add!(g, WireArray, Diameter(d_w), 6, 15.0, copper_props) # lay_direction defaults to 1 + @test g isa ConductorGroup + @test length(g.layers) == 2 + @test g.layers[end] isa WireArray + @test g.layers[end].lay_direction == 1 # came from keyword_defaults for WireArray + + # Geometry stacks outward and resistance decreases (parallel) + @test g.radius_ext > rad_wire0 + @test g.resistance < g.layers[1].resistance + + # Add an outer tubular sleeve by thickness proxy + outer_before = g.radius_ext + g = add!(g, Tubular, Thickness(0.002), copper_props) # temperature default from keyword_defaults(Tubular) + @test g.layers[end] isa Tubular + @test g.radius_ext ≈ outer_before + 0.002 atol = TEST_TOL + end + + @testset "Edge Cases" begin + # Very thin sleeve + g = make_core_group() + outer0 = g.radius_ext + g = add!(g, Tubular, Thickness(1e-6), copper_props) + @test g.radius_ext ≈ outer0 + 1e-6 atol = TEST_TOL + + # WireArray with lay_ratio very small but positive + g = make_core_group() + g = add!(g, WireArray, rad_wire0, 3, 1e-6, copper_props) + @test g.layers[end] isa WireArray + end + + @testset "Physical Behavior" begin + g = make_core_group() + # Add two conductor layers: resistance should drop further + R0 = g.resistance + g = add!(g, WireArray, rad_wire0, 6, 10.0, copper_props) + R1 = g.resistance + g = add!(g, WireArray, rad_wire0, 12, 10.0, copper_props) + R2 = g.resistance + @test R1 < R0 + @test R2 < R1 + + # Cross-section should be monotone increasing + @test g.cross_section > 0 + cs = [p.cross_section for p in g.layers if p isa LineCableModels.DataModel.AbstractConductorPart] + @test all(>(0), cs) + end + + @testset "Type Stability & Promotion (group)" begin + # Base: purely Float64 group + gF = make_core_group() + @test eltype(gF) == Float64 + @test typeof(gF.radius_ext) == Float64 + + # Promote by adding a Measurement argument (e.g., temperature) + gF_before_id = objectid(gF) + gP = add!(gF, WireArray, rad_wire0, 6, 10.0, copper_props; temperature=m(20.0, 0.1)) + @test gP !== gF # returned a promoted group + @test eltype(gP) <: Measurement + @test typeof(gP.radius_ext) <: Measurement + # Original left intact + @test objectid(gF) == gF_before_id + @test length(gF.layers) == 1 + + # In‑place when already Measurement + gM = LineCableModels.DataModel.coerce_to_T(make_core_group(), Measurement{Float64}) + id_before = objectid(gM) + gM2 = add!(gM, WireArray, m(rad_wire0, 1e-6), 6, 10.0, copper_props) + @test gM2 === gM # mutated in place + @test objectid(gM) == id_before + @test eltype(gM) <: Measurement + end + + @testset "Combinatorial Type Testing (constructor path of added part)" begin + # All Float64 + g = make_core_group() + g1 = add!(g, WireArray, rad_wire0, 6, 10.0, copper_props) + @test eltype(g1) == Float64 + + # All Measurement (radius_wire, lay_ratio, temperature) + g = make_core_group() + g2 = add!(g, WireArray, m(rad_wire0, 1e-6), 6, m(10.0, 0.1), copper_props; temperature=m(20.0, 0.1)) + @test eltype(g2) <: Measurement + + # Mixed case A: first numeric arg is Measurement + g = make_core_group() + g3 = add!(g, WireArray, m(rad_wire0, 1e-6), 6, 10.0, copper_props) + @test eltype(g3) <: Measurement + + # Mixed case B: middle arg (lay_ratio) is Measurement + g = make_core_group() + g4 = add!(g, WireArray, rad_wire0, 6, m(10.0, 0.1), copper_props) + @test eltype(g4) <: Measurement + end +end diff --git a/test/unit_DataModel/test_Insulator.jl b/test/unit_DataModel/test_Insulator.jl index f0a56ee8..7ac28557 100644 --- a/test/unit_DataModel/test_Insulator.jl +++ b/test/unit_DataModel/test_Insulator.jl @@ -1,81 +1,81 @@ -@testitem "DataModel(Insulator): constructor unit tests" setup = [defaults, deps_datamodel, defs_materials] begin - - using Measurements - - @testset "Input Validation" begin - # Missing required arguments - @test_throws ArgumentError Insulator() - @test_throws ArgumentError Insulator(radius_in=0.01) - @test_throws ArgumentError Insulator(radius_in=0.01, radius_ext=0.015) - - # Invalid types - @test_throws ArgumentError Insulator("foo", 0.015, insulator_props, temperature=20.0) - @test_throws ArgumentError Insulator(0.01, "bar", insulator_props, temperature=20.0) - @test_throws ArgumentError Insulator(0.01, 0.015, "not_a_material", temperature=20.0) - @test_throws ArgumentError Insulator(0.01, 0.015, insulator_props, temperature="not_a_temp") - - # Out-of-range values - @test_throws ArgumentError Insulator(-0.01, 0.015, insulator_props, temperature=20.0) - @test_throws ArgumentError Insulator(0.01, -0.015, insulator_props, temperature=20.0) - - # Geometrically impossible values - @test_throws ArgumentError Insulator(0.015, 0.01, insulator_props, temperature=20.0) - @test_throws ArgumentError Insulator(0.01, 0.01, insulator_props, temperature=20.0) - - # Invalid nothing/missing - @test_throws ArgumentError Insulator(nothing, 0.015, insulator_props, temperature=20.0) - @test_throws ArgumentError Insulator(0.01, nothing, insulator_props, temperature=20.0) - @test_throws ArgumentError Insulator(0.01, 0.015, nothing, temperature=20.0) - @test_throws ArgumentError Insulator(0.01, 0.015, insulator_props, temperature=nothing) - end - - @testset "Basic Functionality" begin - i = Insulator(0.01, 0.015, insulator_props, temperature=20.0) - @test i isa Insulator - @test i.radius_in ≈ 0.01 atol = TEST_TOL - @test i.radius_ext ≈ 0.015 atol = TEST_TOL - @test i.material_props === insulator_props - @test i.temperature ≈ 20.0 atol = TEST_TOL - @test i.cross_section ≈ π * (0.015^2 - 0.01^2) atol = TEST_TOL - # Measurement type - i2 = Insulator(measurement(0.01, 1e-5), measurement(0.015, 1e-5), insulator_props, temperature=measurement(20.0, 0.1)) - @test i2 isa Insulator - @test value(i2.radius_in) ≈ 0.01 atol = TEST_TOL - @test value(i2.radius_ext) ≈ 0.015 atol = TEST_TOL - @test value(i2.temperature) ≈ 20.0 atol = TEST_TOL - end - - @testset "Edge Cases" begin - # radius_in very close to radius_ext - i = Insulator(1e-6, 1.0001e-6, insulator_props, temperature=20.0) - @test i.radius_in ≈ 1e-6 atol = TEST_TOL - end - - @testset "Physical Behavior" begin - # Cross-section increases with radius_ext - i_small = Insulator(0.01, 0.012, insulator_props, temperature=20.0) - i_large = Insulator(0.01, 0.018, insulator_props, temperature=20.0) - @test i_large.cross_section > i_small.cross_section - # but the capacitance decreases (2 pi eps / log(rex/rin)) - @test i_large.shunt_capacitance < i_small.shunt_capacitance - end - - @testset "Type Stability & Promotion" begin - # All Float64 - i = Insulator(0.01, 0.015, insulator_props, temperature=20.0) - @test typeof(i.radius_in) == Float64 - # All Measurement - iM = Insulator(measurement(0.01, 1e-5), measurement(0.015, 1e-5), insulator_props, temperature=measurement(20.0, 0.1)) - @test typeof(iM.radius_in) <: Measurement - # Mixed: radius_in as Measurement - iMix1 = Insulator(measurement(0.01, 1e-5), 0.015, insulator_props, temperature=20.0) - @test typeof(iMix1.radius_in) <: Measurement - # Mixed: temperature as Measurement - iMix2 = Insulator(0.01, 0.015, insulator_props, temperature=measurement(20.0, 0.1)) - @test typeof(iMix2.temperature) <: Measurement - mmat = Material(1e14, measurement(5.0, 0.1), 1.0, 20.0, 0.0) - iMix3 = Insulator(0.01, 0.015, mmat, temperature=20.0) - @test typeof(iMix3.shunt_conductance) <: Measurement - end - -end +@testitem "DataModel(Insulator): constructor unit tests" setup = [defaults, deps_datamodel, defs_materials] begin + + using Measurements + + @testset "Input Validation" begin + # Missing required arguments + @test_throws ArgumentError Insulator() + @test_throws ArgumentError Insulator(radius_in=0.01) + @test_throws ArgumentError Insulator(radius_in=0.01, radius_ext=0.015) + + # Invalid types + @test_throws ArgumentError Insulator("foo", 0.015, insulator_props, temperature=20.0) + @test_throws ArgumentError Insulator(0.01, "bar", insulator_props, temperature=20.0) + @test_throws ArgumentError Insulator(0.01, 0.015, "not_a_material", temperature=20.0) + @test_throws ArgumentError Insulator(0.01, 0.015, insulator_props, temperature="not_a_temp") + + # Out-of-range values + @test_throws ArgumentError Insulator(-0.01, 0.015, insulator_props, temperature=20.0) + @test_throws ArgumentError Insulator(0.01, -0.015, insulator_props, temperature=20.0) + + # Geometrically impossible values + @test_throws ArgumentError Insulator(0.015, 0.01, insulator_props, temperature=20.0) + @test_throws ArgumentError Insulator(0.01, 0.01, insulator_props, temperature=20.0) + + # Invalid nothing/missing + @test_throws ArgumentError Insulator(nothing, 0.015, insulator_props, temperature=20.0) + @test_throws ArgumentError Insulator(0.01, nothing, insulator_props, temperature=20.0) + @test_throws ArgumentError Insulator(0.01, 0.015, nothing, temperature=20.0) + @test_throws ArgumentError Insulator(0.01, 0.015, insulator_props, temperature=nothing) + end + + @testset "Basic Functionality" begin + i = Insulator(0.01, 0.015, insulator_props, temperature=20.0) + @test i isa Insulator + @test i.radius_in ≈ 0.01 atol = TEST_TOL + @test i.radius_ext ≈ 0.015 atol = TEST_TOL + @test i.material_props === insulator_props + @test i.temperature ≈ 20.0 atol = TEST_TOL + @test i.cross_section ≈ π * (0.015^2 - 0.01^2) atol = TEST_TOL + # Measurement type + i2 = Insulator(measurement(0.01, 1e-5), measurement(0.015, 1e-5), insulator_props, temperature=measurement(20.0, 0.1)) + @test i2 isa Insulator + @test value(i2.radius_in) ≈ 0.01 atol = TEST_TOL + @test value(i2.radius_ext) ≈ 0.015 atol = TEST_TOL + @test value(i2.temperature) ≈ 20.0 atol = TEST_TOL + end + + @testset "Edge Cases" begin + # radius_in very close to radius_ext + i = Insulator(1e-6, 1.0001e-6, insulator_props, temperature=20.0) + @test i.radius_in ≈ 1e-6 atol = TEST_TOL + end + + @testset "Physical Behavior" begin + # Cross-section increases with radius_ext + i_small = Insulator(0.01, 0.012, insulator_props, temperature=20.0) + i_large = Insulator(0.01, 0.018, insulator_props, temperature=20.0) + @test i_large.cross_section > i_small.cross_section + # but the capacitance decreases (2 pi eps / log(rex/rin)) + @test i_large.shunt_capacitance < i_small.shunt_capacitance + end + + @testset "Type Stability & Promotion" begin + # All Float64 + i = Insulator(0.01, 0.015, insulator_props, temperature=20.0) + @test typeof(i.radius_in) == Float64 + # All Measurement + iM = Insulator(measurement(0.01, 1e-5), measurement(0.015, 1e-5), insulator_props, temperature=measurement(20.0, 0.1)) + @test typeof(iM.radius_in) <: Measurement + # Mixed: radius_in as Measurement + iMix1 = Insulator(measurement(0.01, 1e-5), 0.015, insulator_props, temperature=20.0) + @test typeof(iMix1.radius_in) <: Measurement + # Mixed: temperature as Measurement + iMix2 = Insulator(0.01, 0.015, insulator_props, temperature=measurement(20.0, 0.1)) + @test typeof(iMix2.temperature) <: Measurement + mmat = Material(1e14, measurement(5.0, 0.1), 1.0, 20.0, 0.0, 0.1) + iMix3 = Insulator(0.01, 0.015, mmat, temperature=20.0) + @test typeof(iMix3.shunt_conductance) <: Measurement + end + +end diff --git a/test/unit_DataModel/test_InsulatorGroup.jl b/test/unit_DataModel/test_InsulatorGroup.jl index 2c7ea4e3..cc177fbe 100644 --- a/test/unit_DataModel/test_InsulatorGroup.jl +++ b/test/unit_DataModel/test_InsulatorGroup.jl @@ -1,113 +1,113 @@ -@testsnippet defs_ins_group begin - using Measurements - # Canonical dielectric material for tests - const ins_props = Material(1e10, 3.0, 1.0, 20.0, 0.0) - - # Fresh inner insulator (Float64) - make_ins_group() = InsulatorGroup( - Insulator(0.02, 0.025, ins_props; temperature=20.0) - ) - - # Measurement helper - m(x, u) = measurement(x, u) -end - -@testitem "DataModel(InsulatorGroup.add!): unit tests" setup = [defaults, deps_datamodel, defs_materials, defs_ins_group] begin - using Measurements - - @testset "Input Validation (wrapper triggers validate!)" begin - g = make_ins_group() - - # Missing required args for Insulator: (radius_in provided by wrapper), need radius_ext, material - @test_throws ArgumentError add!(g, Insulator) - @test_throws ArgumentError add!(g, Insulator, 0.03) - - # Invalid types - @test_throws ArgumentError add!(g, Insulator, "bad", ins_props) - @test_throws ArgumentError add!(g, Insulator, 0.03, "not_a_material") - - # Geometry violations - @test_throws ArgumentError add!(g, Insulator, 0.0, ins_props) # outer cannot be 0 beyond rin - end - - @testset "Basic Functionality (Float64)" begin - g = make_ins_group() - @test g isa InsulatorGroup - @test length(g.layers) == 1 - @test g.radius_in ≈ 0.02 atol = TEST_TOL - @test g.radius_ext ≈ 0.025 atol = TEST_TOL - - # Add a Semicon by thickness proxy (outer radius = rin + t). radius_in defaults to group.radius_ext - t = 0.002 - rin_before = g.radius_ext - g = add!(g, Semicon, Thickness(t), ins_props; f=60.0) - @test g.layers[end] isa Semicon - @test g.radius_ext ≈ rin_before + t atol = TEST_TOL - end - - @testset "Edge Cases" begin - g = make_ins_group() - tsmall = 1e-6 - re0 = g.radius_ext - g = add!(g, Semicon, Thickness(tsmall), ins_props; f=60.0) - @test g.radius_ext ≈ re0 + tsmall atol = TEST_TOL - end - - @testset "Physical Behavior (admittance parallel update)" begin - g = make_ins_group() - # Capture before - C0 = g.shunt_capacitance - G0 = g.shunt_conductance - - # Add another dielectric shell; admittances should combine → values typically decrease - g = add!(g, Insulator, 0.03, ins_props; f=60.0) - @test g.shunt_capacitance <= C0 # decreasing typical - @test g.shunt_conductance <= G0 # decreasing typical - end - - @testset "Type Stability & Promotion (group)" begin - # Base Float64 group - gF = make_ins_group() - @test eltype(gF) == Float64 - @test typeof(gF.radius_ext) == Float64 - - # Promote by Measurement temperature in part defaults - gF_before = objectid(gF) - gP = add!(gF, Insulator, 0.03, ins_props; f=60.0, temperature=m(20.0, 0.2)) - @test gP !== gF - @test eltype(gP) <: Measurement - @test typeof(gP.radius_ext) <: Measurement - @test objectid(gF) == gF_before - @test length(gF.layers) == 1 - - # Already Measurement → in place - gM = LineCableModels.DataModel.coerce_to_T(make_ins_group(), Measurement{Float64}) - id0 = objectid(gM) - gM2 = add!(gM, Semicon, Thickness(m(0.001, 1e-6)), ins_props; f=60.0) - @test gM2 === gM - @test objectid(gM) == id0 - @test eltype(gM) <: Measurement - end - - @testset "Combinatorial Type Testing" begin - # All Float64 - g = make_ins_group() - g1 = add!(g, Insulator, 0.03, ins_props; f=60.0) - @test eltype(g1) == Float64 - - # All Measurement - g = make_ins_group() - g2 = add!(g, Insulator, m(0.03, 1e-6), ins_props; f=60.0, temperature=m(20.0, 0.1)) - @test eltype(g2) <: Measurement - - # Mixed A: radius_ext is Measurement - g = make_ins_group() - g3 = add!(g, Insulator, m(0.03, 1e-6), ins_props; f=60.0) - @test eltype(g3) <: Measurement - - # Mixed B: pass Measurement frequency (promotes group by wrapper decision) - g = make_ins_group() - g4 = add!(g, Insulator, 0.03, ins_props; f=m(60.0, 0.5)) - @test eltype(g4) <: Measurement - end -end +@testsnippet defs_ins_group begin + using Measurements + # Canonical dielectric material for tests + const ins_props = Material(1e10, 3.0, 1.0, 20.0, 0.0, 0.5) + + # Fresh inner insulator (Float64) + make_ins_group() = InsulatorGroup( + Insulator(0.02, 0.025, ins_props; temperature=20.0) + ) + + # Measurement helper + m(x, u) = measurement(x, u) +end + +@testitem "DataModel(InsulatorGroup.add!): unit tests" setup = [defaults, deps_datamodel, defs_materials, defs_ins_group] begin + using Measurements + + @testset "Input Validation (wrapper triggers validate!)" begin + g = make_ins_group() + + # Missing required args for Insulator: (radius_in provided by wrapper), need radius_ext, material + @test_throws ArgumentError add!(g, Insulator) + @test_throws ArgumentError add!(g, Insulator, 0.03) + + # Invalid types + @test_throws ArgumentError add!(g, Insulator, "bad", ins_props) + @test_throws ArgumentError add!(g, Insulator, 0.03, "not_a_material") + + # Geometry violations + @test_throws ArgumentError add!(g, Insulator, 0.0, ins_props) # outer cannot be 0 beyond rin + end + + @testset "Basic Functionality (Float64)" begin + g = make_ins_group() + @test g isa InsulatorGroup + @test length(g.layers) == 1 + @test g.radius_in ≈ 0.02 atol = TEST_TOL + @test g.radius_ext ≈ 0.025 atol = TEST_TOL + + # Add a Semicon by thickness proxy (outer radius = rin + t). radius_in defaults to group.radius_ext + t = 0.002 + rin_before = g.radius_ext + g = add!(g, Semicon, Thickness(t), ins_props; f=60.0) + @test g.layers[end] isa Semicon + @test g.radius_ext ≈ rin_before + t atol = TEST_TOL + end + + @testset "Edge Cases" begin + g = make_ins_group() + tsmall = 1e-6 + re0 = g.radius_ext + g = add!(g, Semicon, Thickness(tsmall), ins_props; f=60.0) + @test g.radius_ext ≈ re0 + tsmall atol = TEST_TOL + end + + @testset "Physical Behavior (admittance parallel update)" begin + g = make_ins_group() + # Capture before + C0 = g.shunt_capacitance + G0 = g.shunt_conductance + + # Add another dielectric shell; admittances should combine → values typically decrease + g = add!(g, Insulator, 0.03, ins_props; f=60.0) + @test g.shunt_capacitance <= C0 # decreasing typical + @test g.shunt_conductance <= G0 # decreasing typical + end + + @testset "Type Stability & Promotion (group)" begin + # Base Float64 group + gF = make_ins_group() + @test eltype(gF) == Float64 + @test typeof(gF.radius_ext) == Float64 + + # Promote by Measurement temperature in part defaults + gF_before = objectid(gF) + gP = add!(gF, Insulator, 0.03, ins_props; f=60.0, temperature=m(20.0, 0.2)) + @test gP !== gF + @test eltype(gP) <: Measurement + @test typeof(gP.radius_ext) <: Measurement + @test objectid(gF) == gF_before + @test length(gF.layers) == 1 + + # Already Measurement → in place + gM = LineCableModels.DataModel.coerce_to_T(make_ins_group(), Measurement{Float64}) + id0 = objectid(gM) + gM2 = add!(gM, Semicon, Thickness(m(0.001, 1e-6)), ins_props; f=60.0) + @test gM2 === gM + @test objectid(gM) == id0 + @test eltype(gM) <: Measurement + end + + @testset "Combinatorial Type Testing" begin + # All Float64 + g = make_ins_group() + g1 = add!(g, Insulator, 0.03, ins_props; f=60.0) + @test eltype(g1) == Float64 + + # All Measurement + g = make_ins_group() + g2 = add!(g, Insulator, m(0.03, 1e-6), ins_props; f=60.0, temperature=m(20.0, 0.1)) + @test eltype(g2) <: Measurement + + # Mixed A: radius_ext is Measurement + g = make_ins_group() + g3 = add!(g, Insulator, m(0.03, 1e-6), ins_props; f=60.0) + @test eltype(g3) <: Measurement + + # Mixed B: pass Measurement frequency (promotes group by wrapper decision) + g = make_ins_group() + g4 = add!(g, Insulator, 0.03, ins_props; f=m(60.0, 0.5)) + @test eltype(g4) <: Measurement + end +end diff --git a/test/unit_DataModel/test_LineCableSystem.jl b/test/unit_DataModel/test_LineCableSystem.jl index 83b75e0c..fe70e8bb 100644 --- a/test/unit_DataModel/test_LineCableSystem.jl +++ b/test/unit_DataModel/test_LineCableSystem.jl @@ -1,241 +1,241 @@ -@testsnippet defs_linesys begin - using Test - using LineCableModels - const DM = LineCableModels.DataModel - const MAT = LineCableModels.Materials - using Measurements - - # --- helpers ---------------------------------------------------------------- - - # Minimal Float64 design with matching interface radii - function _make_design_F64(; id="CAB") - mC = MAT.Material(1e-8, 1.0, 1.0, 20.0, 0.0) - mI = MAT.Material(1e12, 2.5, 1.0, 20.0, 0.0) - - cg = DM.ConductorGroup(DM.Tubular(0.010, 0.012, mC)) - ig = DM.InsulatorGroup(DM.Insulator(0.012, 0.016, mI)) - - cc = DM.CableComponent("core", cg, ig) - return DM.CableDesign(id, cc) - end - - # Promote a design to Measurement{Float64} - function _make_design_M(; id="CABM") - des = _make_design_F64(; id) - return DM.coerce_to_T(des, Measurement{Float64}) - end - - # Outermost radius of the last component (for placement checks) - _out_radius(des) = max( - des.components[end].conductor_group.radius_ext, - des.components[end].insulator_group.radius_ext, - ) - - # Position with explicit mapping (phase 1 by default) - function _make_position_F64(des; phase::Int=1) - rmax = _out_radius(des) - conn = Dict(des.components[1].id => phase) - return DM.CablePosition(des, 0.0, rmax + 0.20, conn) - end - - # Measurement position (promotes design through the constructor) - function _make_position_M(desF; phase::Int=1) - rmax = _out_radius(desF) - vertM = measurement(rmax + 0.25, 1e-6) - conn = Dict(desF.components[1].id => phase) - return DM.CablePosition(desF, 0.0, vertM, conn) - end -end - -@testitem "DataModel(LineCableSystem): constructor & add! unit tests" setup = [defaults, defs_linesys] begin - # 1) Basic construction from CablePosition (Float64) - @testset "Basic construction (from CablePosition)" begin - des = _make_design_F64() - posF = _make_position_F64(des; phase=1) - sys = DM.LineCableSystem("SYS", 1000.0, posF) - - @test sys isa DM.LineCableSystem - @test DM.eltype(sys) == Float64 - @test sys.line_length == 1000.0 - @test sys.num_cables == 1 - @test sys.num_phases == 1 - @test sys.cables[1] === posF # identity preserved - @test DM.coerce_to_T(sys, Float64) === sys # no-op coercion - end - - # 2) Loose constructor from CableDesign + coordinates - @testset "Loose constructor (from CableDesign + coords)" begin - des = _make_design_F64() - rmax = _out_radius(des) - conn = Dict(des.components[1].id => 1) - sys2 = DM.LineCableSystem("SYS2", 500.0, des, 0.10, rmax + 0.30, conn) - - @test DM.eltype(sys2) == Float64 - @test sys2.num_cables == 1 - @test sys2.num_phases == 1 - @test sys2.cables[1].design_data === des # built via position constructor - @test typeof(sys2.cables[1].horz) == Float64 - @test typeof(sys2.cables[1].vert) == Float64 - end - - # 3) Phase counting across multiple positions - @testset "Phase accounting" begin - des = _make_design_F64() - pos1 = _make_position_F64(des; phase=1) - sys = DM.LineCableSystem("SYS-PH", 100.0, pos1) - - # Add a second cable mapped to phase 2 at a non-overlapping position - des2 = _make_design_F64(; id="CAB2") - r1 = _out_radius(des) - r2 = _out_radius(des2) - dx = r1 + r2 + 0.05 # strictly beyond contact - y = max(r1, r2) + 0.20 - conn2 = Dict(des2.components[1].id => 2) - - pos2 = DM.CablePosition(des2, dx, y, conn2) - sys = DM.add!(sys, pos2) - - @test sys.num_cables == 2 - @test sys.num_phases == 2 - @test all(p -> any(x -> x > 0, p.conn), sys.cables) - end - - # 4) Promotion on add! (Float64 system + Measurement position → promoted system) - @testset "Promotion on add! (Float64 → Measurement)" begin - desF = _make_design_F64() - posF = _make_position_F64(desF) - sysF = DM.LineCableSystem("SYS-PR", 200.0, posF) - - # Measurement position created from a Float64 design (promotes inside) - posM = _make_position_M(desF) - sysP = DM.add!(sysF, posM) # returns promoted system - - @test DM.eltype(sysF) == Float64 - @test DM.eltype(sysP) <: Measurement - @test sysP !== sysF - @test sysP.num_cables == 2 - @test typeof(sysP.cables[1].horz) <: Measurement # existing coerced during promotion - @test typeof(sysP.cables[end].vert) <: Measurement - end - - # 5) No-op add! when already Measurement (system mutates in place) - @testset "No-op add! when types match (Measurement system)" begin - desF = _make_design_F64() - posM0 = _make_position_M(desF) # Measurement position - sysM0 = DM.LineCableSystem("SYS-M", measurement(1000.0, 1e-6), posM0) - - # Add a Float64 position → coerced to Measurement; system should mutate in place - des2 = _make_design_F64(; id="CAB-F2") - posF2 = _make_position_F64(des2) - sysM1 = DM.add!(sysM0, posF2) - - @test sysM1 === sysM0 - @test DM.eltype(sysM0) <: Measurement - @test sysM0.num_cables == 2 - @test typeof(sysM0.cables[end].horz) <: Measurement - @test typeof(sysM0.line_length) <: Measurement - end - - # 6) Combinatorial type testing (length × cable position) - @testset "Combinatorial type testing (constructors)" begin - desF = _make_design_F64() - posF = _make_position_F64(desF) - posM = _make_position_M(desF) - - lengths = ( - 250.0, - measurement(250.0, 1e-6), - ) - - positions = ( - posF, - posM, - ) - - for L in lengths, p in positions - sys = DM.LineCableSystem("SYS-COMB", L, p) - if (L isa Measurement) || (DM.eltype(p) <: Measurement) - @test DM.eltype(sys) <: Measurement - else - @test DM.eltype(sys) == Float64 - end - # Round-trip no-op coercion at current T - @test DM.coerce_to_T(sys, DM.eltype(sys)) === sys - end - end -end - -@testitem "DataModel(LineCableSystem): promotion safety (intern-proof)" setup = [defaults, defs_linesys] begin - using Test - using Measurements - - # local helper: make N Float64 positions spaced along x - function _many_positions(des, N::Int) - rmax = _out_radius(des) - conn = Dict(des.components[1].id => 1) - [DM.CablePosition(des, 0.1 * i, rmax + 0.20, conn) for i in 1:N] - end - - @testset "Promote whole system when a single Measurement cable is added" begin - # Build a big Float64 system (N deterministic cables) - N = 200 - desF = _make_design_F64() - possF = _many_positions(desF, N) - - # Build system by adding positions incrementally - sysF = DM.LineCableSystem("SYS-BIG", 1000.0, possF[1]) - for i in 2:N - sysF = DM.add!(sysF, possF[i]) # no promotion; mutates in place - end - - @test DM.eltype(sysF) == Float64 - @test sysF.num_cables == N - @test all(p -> DM.eltype(p) == Float64, sysF.cables) - - # Now the intern adds ONE measurement-typed cable position - posM = _make_position_M(desF) # promotes design inside position ctor - - # Expect: add! returns a promoted system, original unchanged - local sysP - @test_logs (:warn, r"promoted") (sysP = DM.add!(sysF, posM)) - - @test sysP !== sysF - @test DM.eltype(sysP) <: Measurement - - # Original remains Float64 and unchanged - @test DM.eltype(sysF) == Float64 - @test sysF.num_cables == N - - # New system is Measurement everywhere - @test DM.eltype(sysP) <: Measurement - @test sysP !== sysF - @test sysP.num_cables == N + 1 - @test all(p -> DM.eltype(p) <: Measurement, sysP.cables) - @test typeof(sysP.line_length) <: Measurement - - # The new position inside the promoted system is exactly the object we added - @test sysP.cables[end] === posM - @test DM.eltype(sysP.cables[end].design_data) <: Measurement - - # Existing positions were coerced during promotion - @test typeof(sysP.cables[1].vert) <: Measurement - @test DM.eltype(sysP.cables[1].design_data) <: Measurement - end - - @testset "No-op add! when already Measurement" begin - # Start with a Measurement system - desF = _make_design_F64() - posM0 = _make_position_M(desF) - sysM = DM.LineCableSystem("SYS-M", measurement(500.0, 1e-6), posM0) - - # Add a Float64 position → it should be coerced to Measurement and mutate in place - posF1 = _make_position_F64(_make_design_F64()) - sysM2 = DM.add!(sysM, posF1) - - @test sysM2 === sysM - @test DM.eltype(sysM) <: Measurement - @test sysM.num_cables == 2 - @test typeof(sysM.cables[end].horz) <: Measurement - end -end - +@testsnippet defs_linesys begin + using Test + using LineCableModels + const DM = LineCableModels.DataModel + const MAT = LineCableModels.Materials + using Measurements + + # --- helpers ---------------------------------------------------------------- + + # Minimal Float64 design with matching interface radii + function _make_design_F64(; id="CAB") + mC = MAT.Material(1e-8, 1.0, 1.0, 20.0, 0.0, 100.0) + mI = MAT.Material(1e12, 2.5, 1.0, 20.0, 0.0, 0.5) + + cg = DM.ConductorGroup(DM.Tubular(0.010, 0.012, mC)) + ig = DM.InsulatorGroup(DM.Insulator(0.012, 0.016, mI)) + + cc = DM.CableComponent("core", cg, ig) + return DM.CableDesign(id, cc) + end + + # Promote a design to Measurement{Float64} + function _make_design_M(; id="CABM") + des = _make_design_F64(; id) + return DM.coerce_to_T(des, Measurement{Float64}) + end + + # Outermost radius of the last component (for placement checks) + _out_radius(des) = max( + des.components[end].conductor_group.radius_ext, + des.components[end].insulator_group.radius_ext, + ) + + # Position with explicit mapping (phase 1 by default) + function _make_position_F64(des; phase::Int=1) + rmax = _out_radius(des) + conn = Dict(des.components[1].id => phase) + return DM.CablePosition(des, 0.0, rmax + 0.20, conn) + end + + # Measurement position (promotes design through the constructor) + function _make_position_M(desF; phase::Int=1) + rmax = _out_radius(desF) + vertM = measurement(rmax + 0.25, 1e-6) + conn = Dict(desF.components[1].id => phase) + return DM.CablePosition(desF, 0.0, vertM, conn) + end +end + +@testitem "DataModel(LineCableSystem): constructor & add! unit tests" setup = [defaults, defs_linesys] begin + # 1) Basic construction from CablePosition (Float64) + @testset "Basic construction (from CablePosition)" begin + des = _make_design_F64() + posF = _make_position_F64(des; phase=1) + sys = DM.LineCableSystem("SYS", 1000.0, posF) + + @test sys isa DM.LineCableSystem + @test DM.eltype(sys) == Float64 + @test sys.line_length == 1000.0 + @test sys.num_cables == 1 + @test sys.num_phases == 1 + @test sys.cables[1] === posF # identity preserved + @test DM.coerce_to_T(sys, Float64) === sys # no-op coercion + end + + # 2) Loose constructor from CableDesign + coordinates + @testset "Loose constructor (from CableDesign + coords)" begin + des = _make_design_F64() + rmax = _out_radius(des) + conn = Dict(des.components[1].id => 1) + sys2 = DM.LineCableSystem("SYS2", 500.0, des, 0.10, rmax + 0.30, conn) + + @test DM.eltype(sys2) == Float64 + @test sys2.num_cables == 1 + @test sys2.num_phases == 1 + @test sys2.cables[1].design_data === des # built via position constructor + @test typeof(sys2.cables[1].horz) == Float64 + @test typeof(sys2.cables[1].vert) == Float64 + end + + # 3) Phase counting across multiple positions + @testset "Phase accounting" begin + des = _make_design_F64() + pos1 = _make_position_F64(des; phase=1) + sys = DM.LineCableSystem("SYS-PH", 100.0, pos1) + + # Add a second cable mapped to phase 2 at a non-overlapping position + des2 = _make_design_F64(; id="CAB2") + r1 = _out_radius(des) + r2 = _out_radius(des2) + dx = r1 + r2 + 0.05 # strictly beyond contact + y = max(r1, r2) + 0.20 + conn2 = Dict(des2.components[1].id => 2) + + pos2 = DM.CablePosition(des2, dx, y, conn2) + sys = DM.add!(sys, pos2) + + @test sys.num_cables == 2 + @test sys.num_phases == 2 + @test all(p -> any(x -> x > 0, p.conn), sys.cables) + end + + # 4) Promotion on add! (Float64 system + Measurement position → promoted system) + @testset "Promotion on add! (Float64 → Measurement)" begin + desF = _make_design_F64() + posF = _make_position_F64(desF) + sysF = DM.LineCableSystem("SYS-PR", 200.0, posF) + + # Measurement position created from a Float64 design (promotes inside) + posM = _make_position_M(desF) + sysP = DM.add!(sysF, posM) # returns promoted system + + @test DM.eltype(sysF) == Float64 + @test DM.eltype(sysP) <: Measurement + @test sysP !== sysF + @test sysP.num_cables == 2 + @test typeof(sysP.cables[1].horz) <: Measurement # existing coerced during promotion + @test typeof(sysP.cables[end].vert) <: Measurement + end + + # 5) No-op add! when already Measurement (system mutates in place) + @testset "No-op add! when types match (Measurement system)" begin + desF = _make_design_F64() + posM0 = _make_position_M(desF) # Measurement position + sysM0 = DM.LineCableSystem("SYS-M", measurement(1000.0, 1e-6), posM0) + + # Add a Float64 position → coerced to Measurement; system should mutate in place + des2 = _make_design_F64(; id="CAB-F2") + posF2 = _make_position_F64(des2) + sysM1 = DM.add!(sysM0, posF2) + + @test sysM1 === sysM0 + @test DM.eltype(sysM0) <: Measurement + @test sysM0.num_cables == 2 + @test typeof(sysM0.cables[end].horz) <: Measurement + @test typeof(sysM0.line_length) <: Measurement + end + + # 6) Combinatorial type testing (length × cable position) + @testset "Combinatorial type testing (constructors)" begin + desF = _make_design_F64() + posF = _make_position_F64(desF) + posM = _make_position_M(desF) + + lengths = ( + 250.0, + measurement(250.0, 1e-6), + ) + + positions = ( + posF, + posM, + ) + + for L in lengths, p in positions + sys = DM.LineCableSystem("SYS-COMB", L, p) + if (L isa Measurement) || (DM.eltype(p) <: Measurement) + @test DM.eltype(sys) <: Measurement + else + @test DM.eltype(sys) == Float64 + end + # Round-trip no-op coercion at current T + @test DM.coerce_to_T(sys, DM.eltype(sys)) === sys + end + end +end + +@testitem "DataModel(LineCableSystem): promotion safety (intern-proof)" setup = [defaults, defs_linesys] begin + using Test + using Measurements + + # local helper: make N Float64 positions spaced along x + function _many_positions(des, N::Int) + rmax = _out_radius(des) + conn = Dict(des.components[1].id => 1) + [DM.CablePosition(des, 0.1 * i, rmax + 0.20, conn) for i in 1:N] + end + + @testset "Promote whole system when a single Measurement cable is added" begin + # Build a big Float64 system (N deterministic cables) + N = 200 + desF = _make_design_F64() + possF = _many_positions(desF, N) + + # Build system by adding positions incrementally + sysF = DM.LineCableSystem("SYS-BIG", 1000.0, possF[1]) + for i in 2:N + sysF = DM.add!(sysF, possF[i]) # no promotion; mutates in place + end + + @test DM.eltype(sysF) == Float64 + @test sysF.num_cables == N + @test all(p -> DM.eltype(p) == Float64, sysF.cables) + + # Now the intern adds ONE measurement-typed cable position + posM = _make_position_M(desF) # promotes design inside position ctor + + # Expect: add! returns a promoted system, original unchanged + local sysP + @test_logs (:warn, r"promoted") (sysP = DM.add!(sysF, posM)) + + @test sysP !== sysF + @test DM.eltype(sysP) <: Measurement + + # Original remains Float64 and unchanged + @test DM.eltype(sysF) == Float64 + @test sysF.num_cables == N + + # New system is Measurement everywhere + @test DM.eltype(sysP) <: Measurement + @test sysP !== sysF + @test sysP.num_cables == N + 1 + @test all(p -> DM.eltype(p) <: Measurement, sysP.cables) + @test typeof(sysP.line_length) <: Measurement + + # The new position inside the promoted system is exactly the object we added + @test sysP.cables[end] === posM + @test DM.eltype(sysP.cables[end].design_data) <: Measurement + + # Existing positions were coerced during promotion + @test typeof(sysP.cables[1].vert) <: Measurement + @test DM.eltype(sysP.cables[1].design_data) <: Measurement + end + + @testset "No-op add! when already Measurement" begin + # Start with a Measurement system + desF = _make_design_F64() + posM0 = _make_position_M(desF) + sysM = DM.LineCableSystem("SYS-M", measurement(500.0, 1e-6), posM0) + + # Add a Float64 position → it should be coerced to Measurement and mutate in place + posF1 = _make_position_F64(_make_design_F64()) + sysM2 = DM.add!(sysM, posF1) + + @test sysM2 === sysM + @test DM.eltype(sysM) <: Measurement + @test sysM.num_cables == 2 + @test typeof(sysM.cables[end].horz) <: Measurement + end +end + diff --git a/test/unit_DataModel/test_Semicon.jl b/test/unit_DataModel/test_Semicon.jl index b79602ea..2d231769 100644 --- a/test/unit_DataModel/test_Semicon.jl +++ b/test/unit_DataModel/test_Semicon.jl @@ -1,79 +1,79 @@ -@testitem "DataModel(Semicon): constructor unit tests" setup = [defaults, deps_datamodel, defs_materials] begin - - using Measurements - - @testset "Input Validation" begin - # Missing required arguments - @test_throws ArgumentError Semicon() - @test_throws ArgumentError Semicon(radius_in=0.01) - @test_throws ArgumentError Semicon(radius_in=0.01, radius_ext=0.012) - - # Invalid types - @test_throws ArgumentError Semicon("foo", 0.012, semicon_props, temperature=20.0) - @test_throws ArgumentError Semicon(0.01, "bar", semicon_props, temperature=20.0) - @test_throws ArgumentError Semicon(0.01, 0.012, "not_a_material", temperature=20.0) - @test_throws ArgumentError Semicon(0.01, 0.012, semicon_props, temperature="not_a_temp") - - # Out-of-range values - @test_throws ArgumentError Semicon(-0.01, 0.012, semicon_props, temperature=20.0) - @test_throws ArgumentError Semicon(0.01, -0.012, semicon_props, temperature=20.0) - - # Geometrically impossible values - @test_throws ArgumentError Semicon(0.012, 0.01, semicon_props, temperature=20.0) - @test_throws ArgumentError Semicon(0.01, 0.01, semicon_props, temperature=20.0) - - # Invalid nothing/missing - @test_throws ArgumentError Semicon(nothing, 0.012, semicon_props, temperature=20.0) - @test_throws ArgumentError Semicon(0.01, nothing, semicon_props, temperature=20.0) - @test_throws ArgumentError Semicon(0.01, 0.012, nothing, temperature=20.0) - @test_throws ArgumentError Semicon(0.01, 0.012, semicon_props, nothing) - end - - @testset "Basic Functionality" begin - s = Semicon(0.01, 0.012, semicon_props, temperature=20.0) - @test s isa Semicon - @test s.radius_in ≈ 0.01 atol = TEST_TOL - @test s.radius_ext ≈ 0.012 atol = TEST_TOL - @test s.material_props === semicon_props - @test s.temperature ≈ 20.0 atol = TEST_TOL - @test s.cross_section ≈ π * (0.012^2 - 0.01^2) atol = TEST_TOL - # Measurement type - s2 = Semicon(measurement(0.01, 1e-5), measurement(0.012, 1e-5), semicon_props, temperature=measurement(20.0, 0.1)) - @test s2 isa Semicon - @test value(s2.radius_in) ≈ 0.01 atol = TEST_TOL - @test value(s2.radius_ext) ≈ 0.012 atol = TEST_TOL - @test value(s2.temperature) ≈ 20.0 atol = TEST_TOL - end - - @testset "Edge Cases" begin - # radius_in very close to radius_ext - s = Semicon(1e-6, 1.0001e-6, semicon_props, temperature=20.0) - @test s.radius_in ≈ 1e-6 atol = TEST_TOL - end - - @testset "Physical Behavior" begin - # Cross-section increases with radius_ext - s_small = Semicon(0.01, 0.011, semicon_props, temperature=20.0) - s_large = Semicon(0.01, 0.013, semicon_props, temperature=20.0) - @test s_large.cross_section > s_small.cross_section - end - - @testset "Type Stability & Promotion" begin - # All Float64 - s = Semicon(0.01, 0.012, semicon_props, temperature=20.0) - @test typeof(s.radius_in) == Float64 - # All Measurement - sM = Semicon(measurement(0.01, 1e-5), measurement(0.012, 1e-5), semicon_props, temperature=measurement(20.0, 0.1)) - @test typeof(sM.radius_in) <: Measurement - # Mixed: radius_in as Measurement - sMix1 = Semicon(measurement(0.01, 1e-5), 0.012, semicon_props, temperature=20.0) - @test typeof(sMix1.radius_in) <: Measurement - # Mixed: temperature as Measurement - sMix2 = Semicon(0.01, 0.012, semicon_props, temperature=measurement(20.0, 0.1)) - @test typeof(sMix2.temperature) <: Measurement - mmat = Material(1000.0, measurement(1000.0, 0.1), 1.0, 20.0, 0.0) - sMix3 = Semicon(0.01, 0.012, mmat, temperature=20.0) - @test typeof(sMix3.shunt_capacitance) <: Measurement - end - -end +@testitem "DataModel(Semicon): constructor unit tests" setup = [defaults, deps_datamodel, defs_materials] begin + + using Measurements + + @testset "Input Validation" begin + # Missing required arguments + @test_throws ArgumentError Semicon() + @test_throws ArgumentError Semicon(radius_in=0.01) + @test_throws ArgumentError Semicon(radius_in=0.01, radius_ext=0.012) + + # Invalid types + @test_throws ArgumentError Semicon("foo", 0.012, semicon_props, temperature=20.0) + @test_throws ArgumentError Semicon(0.01, "bar", semicon_props, temperature=20.0) + @test_throws ArgumentError Semicon(0.01, 0.012, "not_a_material", temperature=20.0) + @test_throws ArgumentError Semicon(0.01, 0.012, semicon_props, temperature="not_a_temp") + + # Out-of-range values + @test_throws ArgumentError Semicon(-0.01, 0.012, semicon_props, temperature=20.0) + @test_throws ArgumentError Semicon(0.01, -0.012, semicon_props, temperature=20.0) + + # Geometrically impossible values + @test_throws ArgumentError Semicon(0.012, 0.01, semicon_props, temperature=20.0) + @test_throws ArgumentError Semicon(0.01, 0.01, semicon_props, temperature=20.0) + + # Invalid nothing/missing + @test_throws ArgumentError Semicon(nothing, 0.012, semicon_props, temperature=20.0) + @test_throws ArgumentError Semicon(0.01, nothing, semicon_props, temperature=20.0) + @test_throws ArgumentError Semicon(0.01, 0.012, nothing, temperature=20.0) + @test_throws ArgumentError Semicon(0.01, 0.012, semicon_props, nothing) + end + + @testset "Basic Functionality" begin + s = Semicon(0.01, 0.012, semicon_props, temperature=20.0) + @test s isa Semicon + @test s.radius_in ≈ 0.01 atol = TEST_TOL + @test s.radius_ext ≈ 0.012 atol = TEST_TOL + @test s.material_props === semicon_props + @test s.temperature ≈ 20.0 atol = TEST_TOL + @test s.cross_section ≈ π * (0.012^2 - 0.01^2) atol = TEST_TOL + # Measurement type + s2 = Semicon(measurement(0.01, 1e-5), measurement(0.012, 1e-5), semicon_props, temperature=measurement(20.0, 0.1)) + @test s2 isa Semicon + @test value(s2.radius_in) ≈ 0.01 atol = TEST_TOL + @test value(s2.radius_ext) ≈ 0.012 atol = TEST_TOL + @test value(s2.temperature) ≈ 20.0 atol = TEST_TOL + end + + @testset "Edge Cases" begin + # radius_in very close to radius_ext + s = Semicon(1e-6, 1.0001e-6, semicon_props, temperature=20.0) + @test s.radius_in ≈ 1e-6 atol = TEST_TOL + end + + @testset "Physical Behavior" begin + # Cross-section increases with radius_ext + s_small = Semicon(0.01, 0.011, semicon_props, temperature=20.0) + s_large = Semicon(0.01, 0.013, semicon_props, temperature=20.0) + @test s_large.cross_section > s_small.cross_section + end + + @testset "Type Stability & Promotion" begin + # All Float64 + s = Semicon(0.01, 0.012, semicon_props, temperature=20.0) + @test typeof(s.radius_in) == Float64 + # All Measurement + sM = Semicon(measurement(0.01, 1e-5), measurement(0.012, 1e-5), semicon_props, temperature=measurement(20.0, 0.1)) + @test typeof(sM.radius_in) <: Measurement + # Mixed: radius_in as Measurement + sMix1 = Semicon(measurement(0.01, 1e-5), 0.012, semicon_props, temperature=20.0) + @test typeof(sMix1.radius_in) <: Measurement + # Mixed: temperature as Measurement + sMix2 = Semicon(0.01, 0.012, semicon_props, temperature=measurement(20.0, 0.1)) + @test typeof(sMix2.temperature) <: Measurement + mmat = Material(1000.0, measurement(1000.0, 0.1), 1.0, 20.0, 0.0, 148.0) + sMix3 = Semicon(0.01, 0.012, mmat, temperature=20.0) + @test typeof(sMix3.shunt_capacitance) <: Measurement + end + +end diff --git a/test/unit_DataModel/test_Strip.jl b/test/unit_DataModel/test_Strip.jl index ef29910f..0e66562a 100644 --- a/test/unit_DataModel/test_Strip.jl +++ b/test/unit_DataModel/test_Strip.jl @@ -1,102 +1,102 @@ -@testitem "DataModel(Strip): constructor unit tests" setup = [defaults, deps_datamodel, defs_materials] begin - - using Measurements - - @testset "Input Validation" begin - # Missing required arguments - @test_throws ArgumentError Strip() - @test_throws ArgumentError Strip(radius_in=0.01) - @test_throws ArgumentError Strip(radius_in=0.01, radius_ext=0.012) - @test_throws ArgumentError Strip(radius_in=0.01, radius_ext=0.012, width=0.05) - @test_throws ArgumentError Strip(radius_in=0.01, radius_ext=0.012, width=0.05, lay_ratio=10) - - # Invalid types - @test_throws ArgumentError Strip("foo", 0.012, 0.05, 10, copper_props) - @test_throws ArgumentError Strip(0.01, "bar", 0.05, 10, copper_props) - @test_throws ArgumentError Strip(0.01, 0.012, "baz", 10, copper_props) - @test_throws ArgumentError Strip(0.01, 0.012, 0.05, "qux", copper_props) - @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, "not_a_material") - @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, "not_a_temp", 1) - @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=20.0, "not_a_dir") - - # Out-of-range values - @test_throws ArgumentError Strip(-0.01, 0.012, 0.05, 10, copper_props) - @test_throws ArgumentError Strip(0.01, -0.012, 0.05, 10, copper_props) - @test_throws ArgumentError Strip(0.01, 0.012, -0.05, 10, copper_props) - @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=20.0, lay_direction=0) - @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=20.0, lay_direction=2) - @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=20.0, lay_direction=-2) - - # Geometrically impossible values - @test_throws ArgumentError Strip(0.012, 0.01, 0.05, 10, copper_props) - @test_throws ArgumentError Strip(0.01, 0.01, 0.05, 10, copper_props) - - # Invalid nothing/missing - @test_throws ArgumentError Strip(nothing, 0.012, 0.05, 10, copper_props) - @test_throws ArgumentError Strip(0.01, nothing, 0.05, 10, copper_props) - @test_throws ArgumentError Strip(0.01, 0.012, nothing, 10, copper_props) - @test_throws ArgumentError Strip(0.01, 0.012, 0.05, nothing, copper_props) - @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, nothing) - @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=nothing, lay_direction=1) - @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=20.0, lay_direction=nothing) - end - - @testset "Basic Functionality" begin - s = Strip(0.01, 0.012, 0.05, 10, copper_props) - @test s isa Strip - @test s.radius_in ≈ 0.01 atol = TEST_TOL - @test s.radius_ext ≈ 0.012 atol = TEST_TOL - @test s.width ≈ 0.05 atol = TEST_TOL - @test s.lay_ratio ≈ 10 atol = TEST_TOL - @test s.material_props === copper_props - @test s.temperature ≈ 20.0 atol = TEST_TOL - @test s.lay_direction == 1 - @test s.cross_section ≈ (0.012 - 0.01) * 0.05 atol = TEST_TOL - # measurement type - s2 = Strip(measurement(0.01, 1e-5), measurement(0.012, 1e-5), measurement(0.05, 1e-4), 10, copper_props, temperature=measurement(20.0, 0.1)) - @test s2 isa Strip - @test value(s2.radius_in) ≈ 0.01 atol = TEST_TOL - @test value(s2.radius_ext) ≈ 0.012 atol = TEST_TOL - @test value(s2.width) ≈ 0.05 atol = TEST_TOL - @test value(s2.temperature) ≈ 20.0 atol = TEST_TOL - end - - @testset "Edge Cases" begin - # radius_in very close to radius_ext - s = Strip(1e-6, 1.0001e-6, 0.05, 10, copper_props) - @test s.radius_in ≈ 1e-6 atol = TEST_TOL - # width very small - s2 = Strip(0.01, 0.012, 1e-6, 10, copper_props) - @test s2.width ≈ 1e-6 atol = TEST_TOL - end - - @testset "Physical Behavior" begin - # Resistance should increase with temperature - s20 = Strip(0.01, 0.012, 0.05, 10, copper_props) - s80 = Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=80.0) - @test s80.resistance > s20.resistance - # Cross-section increases with width - s_small = Strip(0.01, 0.012, 0.01, 10, copper_props) - s_large = Strip(0.01, 0.012, 0.1, 10, copper_props) - @test s_large.cross_section > s_small.cross_section - end - - @testset "Type Stability & Promotion" begin - # All Float64 - s = Strip(0.01, 0.012, 0.05, 10.0, copper_props) - @test typeof(s.radius_in) == Float64 - # All measurement - sM = Strip(measurement(0.01, 1e-5), measurement(0.012, 1e-5), measurement(0.05, 1e-4), measurement(10.0, 0.1), copper_props, temperature=measurement(20.0, 0.1), lay_direction=1) - @test typeof(sM.radius_in) <: Measurement - # Mixed: radius_in as measurement - sMix1 = Strip(measurement(0.01, 1e-5), 0.012, 0.05, 10.0, copper_props) - @test typeof(sMix1.radius_in) <: Measurement - # Mixed: width as measurement - sMix2 = Strip(0.01, 0.012, measurement(0.05, 1e-4), 10.0, copper_props) - @test typeof(sMix2.width) <: Measurement - mmat = Material(measurement(1.7241e-8, 1e-10), 1.0, 1.0, 20.0, 0.00393) - sMix3 = Strip(0.01, 0.012, 0.05, 10.0, mmat, temperature=20.0, lay_direction=1) - @test typeof(sMix3.resistance) <: Measurement - end - -end +@testitem "DataModel(Strip): constructor unit tests" setup = [defaults, deps_datamodel, defs_materials] begin + + using Measurements + + @testset "Input Validation" begin + # Missing required arguments + @test_throws ArgumentError Strip() + @test_throws ArgumentError Strip(radius_in=0.01) + @test_throws ArgumentError Strip(radius_in=0.01, radius_ext=0.012) + @test_throws ArgumentError Strip(radius_in=0.01, radius_ext=0.012, width=0.05) + @test_throws ArgumentError Strip(radius_in=0.01, radius_ext=0.012, width=0.05, lay_ratio=10) + + # Invalid types + @test_throws ArgumentError Strip("foo", 0.012, 0.05, 10, copper_props) + @test_throws ArgumentError Strip(0.01, "bar", 0.05, 10, copper_props) + @test_throws ArgumentError Strip(0.01, 0.012, "baz", 10, copper_props) + @test_throws ArgumentError Strip(0.01, 0.012, 0.05, "qux", copper_props) + @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, "not_a_material") + @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, "not_a_temp", 1) + @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=20.0, "not_a_dir") + + # Out-of-range values + @test_throws ArgumentError Strip(-0.01, 0.012, 0.05, 10, copper_props) + @test_throws ArgumentError Strip(0.01, -0.012, 0.05, 10, copper_props) + @test_throws ArgumentError Strip(0.01, 0.012, -0.05, 10, copper_props) + @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=20.0, lay_direction=0) + @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=20.0, lay_direction=2) + @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=20.0, lay_direction=-2) + + # Geometrically impossible values + @test_throws ArgumentError Strip(0.012, 0.01, 0.05, 10, copper_props) + @test_throws ArgumentError Strip(0.01, 0.01, 0.05, 10, copper_props) + + # Invalid nothing/missing + @test_throws ArgumentError Strip(nothing, 0.012, 0.05, 10, copper_props) + @test_throws ArgumentError Strip(0.01, nothing, 0.05, 10, copper_props) + @test_throws ArgumentError Strip(0.01, 0.012, nothing, 10, copper_props) + @test_throws ArgumentError Strip(0.01, 0.012, 0.05, nothing, copper_props) + @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, nothing) + @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=nothing, lay_direction=1) + @test_throws ArgumentError Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=20.0, lay_direction=nothing) + end + + @testset "Basic Functionality" begin + s = Strip(0.01, 0.012, 0.05, 10, copper_props) + @test s isa Strip + @test s.radius_in ≈ 0.01 atol = TEST_TOL + @test s.radius_ext ≈ 0.012 atol = TEST_TOL + @test s.width ≈ 0.05 atol = TEST_TOL + @test s.lay_ratio ≈ 10 atol = TEST_TOL + @test s.material_props === copper_props + @test s.temperature ≈ 20.0 atol = TEST_TOL + @test s.lay_direction == 1 + @test s.cross_section ≈ (0.012 - 0.01) * 0.05 atol = TEST_TOL + # measurement type + s2 = Strip(measurement(0.01, 1e-5), measurement(0.012, 1e-5), measurement(0.05, 1e-4), 10, copper_props, temperature=measurement(20.0, 0.1)) + @test s2 isa Strip + @test value(s2.radius_in) ≈ 0.01 atol = TEST_TOL + @test value(s2.radius_ext) ≈ 0.012 atol = TEST_TOL + @test value(s2.width) ≈ 0.05 atol = TEST_TOL + @test value(s2.temperature) ≈ 20.0 atol = TEST_TOL + end + + @testset "Edge Cases" begin + # radius_in very close to radius_ext + s = Strip(1e-6, 1.0001e-6, 0.05, 10, copper_props) + @test s.radius_in ≈ 1e-6 atol = TEST_TOL + # width very small + s2 = Strip(0.01, 0.012, 1e-6, 10, copper_props) + @test s2.width ≈ 1e-6 atol = TEST_TOL + end + + @testset "Physical Behavior" begin + # Resistance should increase with temperature + s20 = Strip(0.01, 0.012, 0.05, 10, copper_props) + s80 = Strip(0.01, 0.012, 0.05, 10, copper_props, temperature=80.0) + @test s80.resistance > s20.resistance + # Cross-section increases with width + s_small = Strip(0.01, 0.012, 0.01, 10, copper_props) + s_large = Strip(0.01, 0.012, 0.1, 10, copper_props) + @test s_large.cross_section > s_small.cross_section + end + + @testset "Type Stability & Promotion" begin + # All Float64 + s = Strip(0.01, 0.012, 0.05, 10.0, copper_props) + @test typeof(s.radius_in) == Float64 + # All measurement + sM = Strip(measurement(0.01, 1e-5), measurement(0.012, 1e-5), measurement(0.05, 1e-4), measurement(10.0, 0.1), copper_props, temperature=measurement(20.0, 0.1), lay_direction=1) + @test typeof(sM.radius_in) <: Measurement + # Mixed: radius_in as measurement + sMix1 = Strip(measurement(0.01, 1e-5), 0.012, 0.05, 10.0, copper_props) + @test typeof(sMix1.radius_in) <: Measurement + # Mixed: width as measurement + sMix2 = Strip(0.01, 0.012, measurement(0.05, 1e-4), 10.0, copper_props) + @test typeof(sMix2.width) <: Measurement + mmat = Material(measurement(1.7241e-8, 1e-10), 1.0, 1.0, 20.0, 0.00393, 401.0) + sMix3 = Strip(0.01, 0.012, 0.05, 10.0, mmat, temperature=20.0, lay_direction=1) + @test typeof(sMix3.resistance) <: Measurement + end + +end diff --git a/test/unit_DataModel/test_Tubular.jl b/test/unit_DataModel/test_Tubular.jl index 652c0721..8afb2e0c 100644 --- a/test/unit_DataModel/test_Tubular.jl +++ b/test/unit_DataModel/test_Tubular.jl @@ -1,108 +1,108 @@ -@testitem "DataModel(Tubular): constructor unit tests" setup = [defaults, deps_datamodel, defs_materials] begin - # Input Validation - @testset "Input Validation" begin - material = Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393) - - # Missing required arguments - @test_throws ArgumentError Tubular() - @test_throws ArgumentError Tubular(0.01) - @test_throws ArgumentError Tubular(0.01, 0.02) - # Invalid types - @test_throws ArgumentError Tubular("0.01", 0.02, material) - @test_throws ArgumentError Tubular(0.01, "0.02", material) - @test_throws ArgumentError Tubular(0.01, 0.02, "material") - @test_throws ArgumentError Tubular(0.01, 0.02, material, temperature="25") - @test_throws ArgumentError Tubular(-0.01, 0.02, material) - @test_throws ArgumentError Tubular(0.01, -0.02, material) - @test_throws ArgumentError Tubular(0.03, 0.02, material) - # Invalid nothing/missing - @test_throws ArgumentError Tubular(nothing, 0.02, material) - @test_throws ArgumentError Tubular(0.01, nothing, material) - @test_throws ArgumentError Tubular(0.01, 0.02, nothing) - @test_throws ArgumentError Tubular(missing, 0.02, material) - @test_throws ArgumentError Tubular(0.01, missing, material) - @test_throws ArgumentError Tubular(0.01, 0.02, material, temperature=missing) - end - - # Basic Functionality - @testset "Basic Functionality" begin - material = Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393) - t = Tubular(0.01, 0.02, material) - @test t isa Tubular - @test isapprox(t.radius_in, 0.01, atol=TEST_TOL) - @test isapprox(t.radius_ext, 0.02, atol=TEST_TOL) - @test t.material_props == material - @test isapprox(t.temperature, 20.0, atol=TEST_TOL) - @test isapprox(t.cross_section, π * (0.02^2 - 0.01^2), atol=TEST_TOL) - t2 = Tubular(t, Thickness(0.02), material) - @test t2 isa Tubular - @test isapprox(t2.radius_in, t.radius_ext, atol=TEST_TOL) - end - - # Edge Cases - @testset "Edge Cases" begin - material = Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393) - # Very small but positive thickness - eps = 1e-12 - t = Tubular(0.01, 0.01 + eps, material) - @test t.radius_ext > t.radius_in - @test t.cross_section > 0 - # Inf radii (should error) - @test_throws DomainError Tubular(0.01, Inf, material) - end - - # Physical Behavior - @testset "Physical Behavior" begin - material = Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393) - t1 = Tubular(0.01, 0.02, material) - t2 = Tubular(0.01, 0.03, material) - @test t2.cross_section > t1.cross_section - @test t2.resistance < t1.resistance - end - - # Type Stability & Promotion - @testset "Type Stability & Promotion" begin - material = Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393) - m = measurement(0.01, 0.001) - # All Float64 - t1 = Tubular(0.01, 0.02, material) - @test t1.radius_in isa Float64 - # All Measurement - mmat = Material(measurement(1.7241e-8, 1e-10), 1.0, 1.0, 20.0, 0.00393) - t2 = Tubular(0.011, 0.021, mmat) - @test t2.radius_in isa Measurement - # Mixed: radius_in as Measurement - t3 = Tubular(m, 0.02, material) - @test t3.radius_in isa Measurement - # Mixed: radius_ext as Measurement - t4 = Tubular(0.001, m, material) - @test t4.radius_ext isa Measurement - # Mixed: material_props as Measurement - t5 = Tubular(0.01, 0.02, mmat) - @test t5.material_props.rho isa Measurement - end - - @testset "Radius Input Parsing" begin - import LineCableModels.DataModel: _normalize_radii - # inner:Number, outer:Thickness - @test _normalize_radii(Tubular, 0.01, Thickness(0.02)) == (0.01, 0.03) - - # inner:Thickness, outer:Number - rin, rex = _normalize_radii(Tubular, Thickness(0.002), 0.02) - @test isapprox(rin, 0.018; atol=TEST_TOL) - @test isapprox(rex, 0.02; atol=TEST_TOL) - - - # inner:Thickness too large - @test_throws ArgumentError _normalize_radii(Tubular, Thickness(0.03), 0.02) - - # both Thickness → error - @test_throws ArgumentError _normalize_radii(Tubular, Thickness(0.001), Thickness(0.002)) - - # diameter on either side collapses in parse: - @test _normalize_radii(Tubular, Diameter(0.02), 0.03) == (0.01, 0.03) - @test _normalize_radii(Tubular, 0.01, Diameter(0.02)) == (0.01, 0.01) - - - end -end +@testitem "DataModel(Tubular): constructor unit tests" setup = [defaults, deps_datamodel, defs_materials] begin + # Input Validation + @testset "Input Validation" begin + material = Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393, 401.0) + + # Missing required arguments + @test_throws ArgumentError Tubular() + @test_throws ArgumentError Tubular(0.01) + @test_throws ArgumentError Tubular(0.01, 0.02) + # Invalid types + @test_throws ArgumentError Tubular("0.01", 0.02, material) + @test_throws ArgumentError Tubular(0.01, "0.02", material) + @test_throws ArgumentError Tubular(0.01, 0.02, "material") + @test_throws ArgumentError Tubular(0.01, 0.02, material, temperature="25") + @test_throws ArgumentError Tubular(-0.01, 0.02, material) + @test_throws ArgumentError Tubular(0.01, -0.02, material) + @test_throws ArgumentError Tubular(0.03, 0.02, material) + # Invalid nothing/missing + @test_throws ArgumentError Tubular(nothing, 0.02, material) + @test_throws ArgumentError Tubular(0.01, nothing, material) + @test_throws ArgumentError Tubular(0.01, 0.02, nothing) + @test_throws ArgumentError Tubular(missing, 0.02, material) + @test_throws ArgumentError Tubular(0.01, missing, material) + @test_throws ArgumentError Tubular(0.01, 0.02, material, temperature=missing) + end + + # Basic Functionality + @testset "Basic Functionality" begin + material = Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393, 401.0) + t = Tubular(0.01, 0.02, material) + @test t isa Tubular + @test isapprox(t.radius_in, 0.01, atol=TEST_TOL) + @test isapprox(t.radius_ext, 0.02, atol=TEST_TOL) + @test t.material_props == material + @test isapprox(t.temperature, 20.0, atol=TEST_TOL) + @test isapprox(t.cross_section, π * (0.02^2 - 0.01^2), atol=TEST_TOL) + t2 = Tubular(t, Thickness(0.02), material) + @test t2 isa Tubular + @test isapprox(t2.radius_in, t.radius_ext, atol=TEST_TOL) + end + + # Edge Cases + @testset "Edge Cases" begin + material = Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393, 401.0) + # Very small but positive thickness + eps = 1e-12 + t = Tubular(0.01, 0.01 + eps, material) + @test t.radius_ext > t.radius_in + @test t.cross_section > 0 + # Inf radii (should error) + @test_throws DomainError Tubular(0.01, Inf, material) + end + + # Physical Behavior + @testset "Physical Behavior" begin + material = Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393, 401.0) + t1 = Tubular(0.01, 0.02, material) + t2 = Tubular(0.01, 0.03, material) + @test t2.cross_section > t1.cross_section + @test t2.resistance < t1.resistance + end + + # Type Stability & Promotion + @testset "Type Stability & Promotion" begin + material = Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393, 401.0) + m = measurement(0.01, 0.001) + # All Float64 + t1 = Tubular(0.01, 0.02, material) + @test t1.radius_in isa Float64 + # All Measurement + mmat = Material(measurement(1.7241e-8, 1e-10), 1.0, 1.0, 20.0, 0.00393, 401.0) + t2 = Tubular(0.011, 0.021, mmat) + @test t2.radius_in isa Measurement + # Mixed: radius_in as Measurement + t3 = Tubular(m, 0.02, material) + @test t3.radius_in isa Measurement + # Mixed: radius_ext as Measurement + t4 = Tubular(0.001, m, material) + @test t4.radius_ext isa Measurement + # Mixed: material_props as Measurement + t5 = Tubular(0.01, 0.02, mmat) + @test t5.material_props.rho isa Measurement + end + + @testset "Radius Input Parsing" begin + import LineCableModels.DataModel: _normalize_radii + # inner:Number, outer:Thickness + @test _normalize_radii(Tubular, 0.01, Thickness(0.02)) == (0.01, 0.03) + + # inner:Thickness, outer:Number + rin, rex = _normalize_radii(Tubular, Thickness(0.002), 0.02) + @test isapprox(rin, 0.018; atol=TEST_TOL) + @test isapprox(rex, 0.02; atol=TEST_TOL) + + + # inner:Thickness too large + @test_throws ArgumentError _normalize_radii(Tubular, Thickness(0.03), 0.02) + + # both Thickness → error + @test_throws ArgumentError _normalize_radii(Tubular, Thickness(0.001), Thickness(0.002)) + + # diameter on either side collapses in parse: + @test _normalize_radii(Tubular, Diameter(0.02), 0.03) == (0.01, 0.03) + @test _normalize_radii(Tubular, 0.01, Diameter(0.02)) == (0.01, 0.01) + + + end +end diff --git a/test/unit_DataModel/test_WireArray.jl b/test/unit_DataModel/test_WireArray.jl index 49a7de1f..629b96f3 100644 --- a/test/unit_DataModel/test_WireArray.jl +++ b/test/unit_DataModel/test_WireArray.jl @@ -1,101 +1,101 @@ -@testitem "DataModel(WireArray): constructor unit tests" setup = [defaults, deps_datamodel, defs_materials] begin - - using Measurements - - @testset "Input Validation" begin - # Missing required arguments - @test_throws ArgumentError WireArray() - @test_throws ArgumentError WireArray(radius_in=0.01) - @test_throws ArgumentError WireArray(radius_in=0.01, radius_wire=0.002) - @test_throws ArgumentError WireArray(radius_in=0.01, radius_wire=0.002, num_wires=7) - @test_throws ArgumentError WireArray(radius_in=0.01, radius_wire=0.002, num_wires=7, lay_ratio=10) - - # Invalid types - @test_throws ArgumentError WireArray("foo", 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, "bar", 7, 10, copper_props, temperature=20.0, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, 0.002, "baz", 10, copper_props, temperature=20.0, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, 0.002, 7, "qux", copper_props, temperature=20.0, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, "not_a_material", temperature=20.0, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, "not_a_temp", lay_direction=1) - @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, "not_a_dir") - - # Out-of-range values - @test_throws ArgumentError WireArray(-0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, -0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, 0.002, 0, 10, copper_props, temperature=20.0, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=0) - @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=2) - @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=-2) - - # Geometrically impossible values - @test_throws ArgumentError WireArray(0.01, 0.0, 7, 10, copper_props, temperature=20.0, lay_direction=1) - - # Invalid nothing/missing - @test_throws ArgumentError WireArray(nothing, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, nothing, 7, 10, copper_props, temperature=20.0, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, 0.002, nothing, 10, copper_props, temperature=20.0, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, 0.002, 7, nothing, copper_props, temperature=20.0, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, nothing, temperature=20.0, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, temperature=nothing, lay_direction=1) - @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=nothing) - end - - @testset "Basic Functionality" begin - w = WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) - @test w isa WireArray - @test w.radius_in ≈ 0.01 atol = TEST_TOL - @test w.radius_wire ≈ 0.002 atol = TEST_TOL - @test w.num_wires == 7 - @test w.lay_ratio ≈ 10 atol = TEST_TOL - @test w.material_props === copper_props - @test w.temperature ≈ 20.0 atol = TEST_TOL - @test w.lay_direction == 1 - @test w.cross_section ≈ 7 * π * 0.002^2 atol = TEST_TOL - # Measurement type - w2 = WireArray(measurement(0.01, 1e-5), measurement(0.002, 1e-6), 7, 10, copper_props, temperature=measurement(20.0, 0.1), lay_direction=1) - @test w2 isa WireArray - @test value(w2.radius_in) ≈ 0.01 atol = TEST_TOL - @test value(w2.radius_wire) ≈ 0.002 atol = TEST_TOL - @test value(w2.temperature) ≈ 20.0 atol = TEST_TOL - end - - @testset "Edge Cases" begin - # radius_in very close to radius_ext - w = WireArray(1e-6, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) - @test w.radius_in ≈ 1e-6 atol = TEST_TOL - # num_wires = 1 (should set radius_ext = radius_wire) - w1 = WireArray(0.0, 0.002, 1, 10, copper_props, temperature=20.0, lay_direction=1) - @test w1.radius_ext ≈ 0.002 atol = TEST_TOL - end - - @testset "Physical Behavior" begin - # Resistance should increase with temperature - w20 = WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) - w80 = WireArray(0.01, 0.002, 7, 10, copper_props, temperature=80.0, lay_direction=1) - @test w80.resistance > w20.resistance - # Cross-section increases with wire radius - w_small = WireArray(0.01, 0.001, 7, 10, copper_props, temperature=20.0, lay_direction=1) - w_large = WireArray(0.01, 0.003, 7, 10, copper_props, temperature=20.0, lay_direction=1) - @test w_large.cross_section > w_small.cross_section - end - - @testset "Type Stability & Promotion" begin - # All Float64 - w = WireArray(0.01, 0.002, 7, 10.0, copper_props, temperature=20.0, lay_direction=1) - @test typeof(w.radius_in) == Float64 - # All Measurement - wM = WireArray(measurement(0.01, 1e-5), measurement(0.002, 1e-6), 7, measurement(10.0, 0.1), copper_props, temperature=measurement(20.0, 0.1), lay_direction=1) - @test typeof(wM.radius_in) <: Measurement - # Mixed: radius_in as Measurement - wMix1 = WireArray(measurement(0.01, 1e-5), 0.002, 7, 10.0, copper_props, temperature=20.0, lay_direction=1) - @test typeof(wMix1.radius_in) <: Measurement - # Mixed: lay_ratio as Measurement - wMix2 = WireArray(0.01, 0.002, 7, measurement(10.0, 0.1), copper_props, temperature=20.0, lay_direction=1) - @test typeof(wMix2.lay_ratio) <: Measurement - # material as measurement - mmat = Material(measurement(1.7241e-8, 1e-10), 1.0, 1.0, 20.0, 0.00393) - wMix3 = WireArray(0.01, 0.002, 7, 10.0, mmat, temperature=20.0, lay_direction=1) - @test typeof(wMix3.resistance) <: Measurement - end - -end +@testitem "DataModel(WireArray): constructor unit tests" setup = [defaults, deps_datamodel, defs_materials] begin + + using Measurements + + @testset "Input Validation" begin + # Missing required arguments + @test_throws ArgumentError WireArray() + @test_throws ArgumentError WireArray(radius_in=0.01) + @test_throws ArgumentError WireArray(radius_in=0.01, radius_wire=0.002) + @test_throws ArgumentError WireArray(radius_in=0.01, radius_wire=0.002, num_wires=7) + @test_throws ArgumentError WireArray(radius_in=0.01, radius_wire=0.002, num_wires=7, lay_ratio=10) + + # Invalid types + @test_throws ArgumentError WireArray("foo", 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, "bar", 7, 10, copper_props, temperature=20.0, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, 0.002, "baz", 10, copper_props, temperature=20.0, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, 0.002, 7, "qux", copper_props, temperature=20.0, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, "not_a_material", temperature=20.0, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, "not_a_temp", lay_direction=1) + @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, "not_a_dir") + + # Out-of-range values + @test_throws ArgumentError WireArray(-0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, -0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, 0.002, 0, 10, copper_props, temperature=20.0, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=0) + @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=2) + @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=-2) + + # Geometrically impossible values + @test_throws ArgumentError WireArray(0.01, 0.0, 7, 10, copper_props, temperature=20.0, lay_direction=1) + + # Invalid nothing/missing + @test_throws ArgumentError WireArray(nothing, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, nothing, 7, 10, copper_props, temperature=20.0, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, 0.002, nothing, 10, copper_props, temperature=20.0, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, 0.002, 7, nothing, copper_props, temperature=20.0, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, nothing, temperature=20.0, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, temperature=nothing, lay_direction=1) + @test_throws ArgumentError WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=nothing) + end + + @testset "Basic Functionality" begin + w = WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) + @test w isa WireArray + @test w.radius_in ≈ 0.01 atol = TEST_TOL + @test w.radius_wire ≈ 0.002 atol = TEST_TOL + @test w.num_wires == 7 + @test w.lay_ratio ≈ 10 atol = TEST_TOL + @test w.material_props === copper_props + @test w.temperature ≈ 20.0 atol = TEST_TOL + @test w.lay_direction == 1 + @test w.cross_section ≈ 7 * π * 0.002^2 atol = TEST_TOL + # Measurement type + w2 = WireArray(measurement(0.01, 1e-5), measurement(0.002, 1e-6), 7, 10, copper_props, temperature=measurement(20.0, 0.1), lay_direction=1) + @test w2 isa WireArray + @test value(w2.radius_in) ≈ 0.01 atol = TEST_TOL + @test value(w2.radius_wire) ≈ 0.002 atol = TEST_TOL + @test value(w2.temperature) ≈ 20.0 atol = TEST_TOL + end + + @testset "Edge Cases" begin + # radius_in very close to radius_ext + w = WireArray(1e-6, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) + @test w.radius_in ≈ 1e-6 atol = TEST_TOL + # num_wires = 1 (should set radius_ext = radius_wire) + w1 = WireArray(0.0, 0.002, 1, 10, copper_props, temperature=20.0, lay_direction=1) + @test w1.radius_ext ≈ 0.002 atol = TEST_TOL + end + + @testset "Physical Behavior" begin + # Resistance should increase with temperature + w20 = WireArray(0.01, 0.002, 7, 10, copper_props, temperature=20.0, lay_direction=1) + w80 = WireArray(0.01, 0.002, 7, 10, copper_props, temperature=80.0, lay_direction=1) + @test w80.resistance > w20.resistance + # Cross-section increases with wire radius + w_small = WireArray(0.01, 0.001, 7, 10, copper_props, temperature=20.0, lay_direction=1) + w_large = WireArray(0.01, 0.003, 7, 10, copper_props, temperature=20.0, lay_direction=1) + @test w_large.cross_section > w_small.cross_section + end + + @testset "Type Stability & Promotion" begin + # All Float64 + w = WireArray(0.01, 0.002, 7, 10.0, copper_props, temperature=20.0, lay_direction=1) + @test typeof(w.radius_in) == Float64 + # All Measurement + wM = WireArray(measurement(0.01, 1e-5), measurement(0.002, 1e-6), 7, measurement(10.0, 0.1), copper_props, temperature=measurement(20.0, 0.1), lay_direction=1) + @test typeof(wM.radius_in) <: Measurement + # Mixed: radius_in as Measurement + wMix1 = WireArray(measurement(0.01, 1e-5), 0.002, 7, 10.0, copper_props, temperature=20.0, lay_direction=1) + @test typeof(wMix1.radius_in) <: Measurement + # Mixed: lay_ratio as Measurement + wMix2 = WireArray(0.01, 0.002, 7, measurement(10.0, 0.1), copper_props, temperature=20.0, lay_direction=1) + @test typeof(wMix2.lay_ratio) <: Measurement + # material as measurement + mmat = Material(measurement(1.7241e-8, 1e-10), 1.0, 1.0, 20.0, 0.00393, 401.0) + wMix3 = WireArray(0.01, 0.002, 7, 10.0, mmat, temperature=20.0, lay_direction=1) + @test typeof(wMix3.resistance) <: Measurement + end + +end diff --git a/test/unit_DataModel/test_equivalent.jl b/test/unit_DataModel/test_equivalent.jl index 2cac90e5..e55bb08f 100644 --- a/test/unit_DataModel/test_equivalent.jl +++ b/test/unit_DataModel/test_equivalent.jl @@ -1,193 +1,193 @@ -@testsnippet simplify_fixtures begin - # Aliases - const LM = LineCableModels - const DM = LM.DataModel - const MAT = LM.Materials - using Measurements: measurement - - # Basic materials - copper_props = MAT.Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393) - xlpe_props = MAT.Material(1e10, 2.3, 1.0, 20.0, 0.0) - semi_props = MAT.Material(1e3, 2.6, 1.0, 20.0, 0.0) - - # Geometry helpers - d_wire = 3e-3 - rin0 = 0.0 - - function make_conductor_group() - core = DM.WireArray(rin0, DM.Diameter(d_wire), 1, 0.0, copper_props) - g = DM.ConductorGroup(core) - add!(g, DM.WireArray, DM.Diameter(d_wire), 6, 10.0, copper_props) - add!(g, DM.Strip, DM.Thickness(0.5e-3), 0.02, 8.0, copper_props) - add!(g, DM.Tubular, DM.Thickness(0.8e-3), copper_props) - g - end - - function make_insulator_group(conductor_group) - ins1 = DM.Insulator(conductor_group.radius_ext, DM.Thickness(2.0e-3), xlpe_props) - ig = DM.InsulatorGroup(ins1) - add!(ig, DM.Semicon, DM.Thickness(0.8e-3), semi_props) - add!(ig, DM.Insulator, DM.Thickness(2.0e-3), xlpe_props) - ig - end - - function make_component(id::AbstractString) - g = make_conductor_group() - ig = make_insulator_group(g) - DM.CableComponent(String(id), g, ig) - end - - function make_design(id::AbstractString; ncomponents::Int = 1) - comps = [make_component(n == 1 ? "core" : "comp$(n)") for n in 1:ncomponents] - des = DM.CableDesign(String(id), comps[1]) - for c in comps[2:end] - add!(des, c.id, c.conductor_group, c.insulator_group) - end - des - end -end - -@testitem "simplify unit tests" setup = - [defaults, deps_datamodel, defs_materials, simplify_fixtures] begin - const DM = LineCableModels.DataModel - - @testset "Input Validation" begin - des = make_design("CAB-V-0") - - # Missing required positional argument - @test_throws MethodError DM.equivalent() - - # Invalid first argument type - @test_throws MethodError DM.equivalent(42) - - # Invalid keyword type for new_id - @test_throws TypeError DM.equivalent(des; new_id = 123) - - - end - - @testset "Basic Functionality" begin - des = make_design("CAB-BASIC"; ncomponents = 2) - des_s = DM.equivalent(des) - - @test des_s isa DM.CableDesign - @test des_s.cable_id == "CAB-BASIC_equivalent" - @test length(des_s.components) == length(des.components) - - # Geometry continuity: outer radius preserved by equivalence - for (orig, simp) in zip(des.components, des_s.components) - @test simp.conductor_group.radius_in ≈ orig.conductor_group.radius_in atol = - TEST_TOL - @test simp.conductor_group.radius_ext ≈ orig.conductor_group.radius_ext atol = - TEST_TOL - @test simp.insulator_group.radius_ext ≈ orig.insulator_group.radius_ext atol = - TEST_TOL - @test simp.id == orig.id - end - - # new_id override - des_s2 = DM.equivalent(des; new_id = "CAB-SIMPLE") - @test des_s2.cable_id == "CAB-SIMPLE" - end - - @testset "Equivalence Preservation" begin - # The simplified design must preserve the component-equivalent properties - des = make_design("CAB-EQ"; ncomponents = 2) - des_s = DM.equivalent(des) - - for (orig, simp) in zip(des.components, des_s.components) - # Compare equivalent material properties (conductor) - @test simp.conductor_props.rho ≈ orig.conductor_props.rho atol = TEST_TOL - @test simp.conductor_props.eps_r ≈ orig.conductor_props.eps_r atol = TEST_TOL - @test simp.conductor_props.mu_r ≈ orig.conductor_props.mu_r atol = TEST_TOL - @test simp.conductor_props.T0 ≈ orig.conductor_props.T0 atol = TEST_TOL - @test simp.conductor_props.alpha ≈ orig.conductor_props.alpha atol = TEST_TOL - - # Compare equivalent material properties (insulator) - @test simp.insulator_props.rho ≈ orig.insulator_props.rho atol = TEST_TOL - @test simp.insulator_props.eps_r ≈ orig.insulator_props.eps_r atol = TEST_TOL - @test simp.insulator_props.mu_r ≈ orig.insulator_props.mu_r atol = TEST_TOL - @test simp.insulator_props.T0 ≈ orig.insulator_props.T0 atol = TEST_TOL - @test simp.insulator_props.alpha ≈ orig.insulator_props.alpha atol = TEST_TOL - - # Compare group lumped parameters (should be preserved by construction) - @test simp.conductor_group.resistance ≈ orig.conductor_group.resistance atol = - TEST_TOL - @test simp.conductor_group.gmr ≈ orig.conductor_group.gmr atol = TEST_TOL - @test simp.insulator_group.shunt_capacitance ≈ - orig.insulator_group.shunt_capacitance atol = TEST_TOL - @test simp.insulator_group.shunt_conductance ≈ - orig.insulator_group.shunt_conductance atol = TEST_TOL - end - end - - @testset "Edge Cases" begin - # Use Measurement geometry to ensure robustness with promoted numeric types - des = make_design("CAB-EDGE") - desM = DM.CableDesign( - "CAB-EDGE-M", - DM.CableComponent( - des.components[1].id, - DM.coerce_to_T( - des.components[1].conductor_group, - Measurements.Measurement{Float64}, - ), - DM.coerce_to_T( - des.components[1].insulator_group, - Measurements.Measurement{Float64}, - ), - ), - ) - - des_sM = DM.equivalent(desM) - @test typeof(des_sM.components[1].conductor_group.radius_in) <: - Measurements.Measurement - @test typeof(des_sM.components[1].conductor_group.radius_ext) <: - Measurements.Measurement - @test typeof(des_sM.components[1].insulator_group.radius_in) <: - Measurements.Measurement - @test typeof(des_sM.components[1].insulator_group.radius_ext) <: - Measurements.Measurement - end - - @testset "Physical Behavior" begin - des = make_design("CAB-PHYS") - des_s = DM.equivalent(des) - for comp in des_s.components - @test comp.conductor_props.rho > 0 - @test comp.conductor_group.gmr > 0 - @test comp.insulator_props.eps_r > 0 - @test comp.insulator_group.shunt_capacitance > 0 - end - end - - @testset "Type Stability & Promotion" begin - des = make_design("CAB-TYPES") - cF = des.components[1] - - # Base: Float64 -> Float64 - desF = DM.CableDesign("CAB-F", cF) - sF = DM.equivalent(desF) - @test eltype([sF.components[1].conductor_group.radius_in]) == Float64 - - # Fully promoted: Measurement -> Measurement - gM = DM.coerce_to_T(cF.conductor_group, Measurements.Measurement{Float64}) - igM = DM.coerce_to_T(cF.insulator_group, Measurements.Measurement{Float64}) - desM = DM.CableDesign("CAB-M", DM.CableComponent("coreM", gM, igM)) - sM = DM.equivalent(desM) - @test typeof(sM.components[1].conductor_group.radius_in) <: Measurements.Measurement - @test typeof(sM.components[1].insulator_group.radius_ext) <: - Measurements.Measurement - - # Mixed cases - desC = DM.CableDesign("CAB-C", DM.CableComponent("c1", gM, cF.insulator_group)) - sC = DM.equivalent(desC) - @test typeof(sC.components[1].conductor_group.radius_in) <: Measurements.Measurement - @test typeof(sC.components[1].insulator_group.radius_in) <: Measurements.Measurement - - desI = DM.CableDesign("CAB-I", DM.CableComponent("c2", cF.conductor_group, igM)) - sI = DM.equivalent(desI) - @test typeof(sI.components[1].conductor_group.radius_in) <: Measurements.Measurement - @test typeof(sI.components[1].insulator_group.radius_in) <: Measurements.Measurement - end -end +@testsnippet simplify_fixtures begin + # Aliases + const LM = LineCableModels + const DM = LM.DataModel + const MAT = LM.Materials + using Measurements: measurement + + # Basic materials + copper_props = MAT.Material(1.7241e-8, 1.0, 1.0, 20.0, 0.00393, 401.0) + xlpe_props = MAT.Material(1e10, 2.3, 1.0, 20.0, 0.0, 0.3) + semi_props = MAT.Material(1e3, 2.6, 1.0, 20.0, 0.0, 148.0) + + # Geometry helpers + d_wire = 3e-3 + rin0 = 0.0 + + function make_conductor_group() + core = DM.WireArray(rin0, DM.Diameter(d_wire), 1, 0.0, copper_props) + g = DM.ConductorGroup(core) + add!(g, DM.WireArray, DM.Diameter(d_wire), 6, 10.0, copper_props) + add!(g, DM.Strip, DM.Thickness(0.5e-3), 0.02, 8.0, copper_props) + add!(g, DM.Tubular, DM.Thickness(0.8e-3), copper_props) + g + end + + function make_insulator_group(conductor_group) + ins1 = DM.Insulator(conductor_group.radius_ext, DM.Thickness(2.0e-3), xlpe_props) + ig = DM.InsulatorGroup(ins1) + add!(ig, DM.Semicon, DM.Thickness(0.8e-3), semi_props) + add!(ig, DM.Insulator, DM.Thickness(2.0e-3), xlpe_props) + ig + end + + function make_component(id::AbstractString) + g = make_conductor_group() + ig = make_insulator_group(g) + DM.CableComponent(String(id), g, ig) + end + + function make_design(id::AbstractString; ncomponents::Int = 1) + comps = [make_component(n == 1 ? "core" : "comp$(n)") for n in 1:ncomponents] + des = DM.CableDesign(String(id), comps[1]) + for c in comps[2:end] + add!(des, c.id, c.conductor_group, c.insulator_group) + end + des + end +end + +@testitem "simplify unit tests" setup = + [defaults, deps_datamodel, defs_materials, simplify_fixtures] begin + const DM = LineCableModels.DataModel + + @testset "Input Validation" begin + des = make_design("CAB-V-0") + + # Missing required positional argument + @test_throws MethodError DM.equivalent() + + # Invalid first argument type + @test_throws MethodError DM.equivalent(42) + + # Invalid keyword type for new_id + @test_throws TypeError DM.equivalent(des; new_id = 123) + + + end + + @testset "Basic Functionality" begin + des = make_design("CAB-BASIC"; ncomponents = 2) + des_s = DM.equivalent(des) + + @test des_s isa DM.CableDesign + @test des_s.cable_id == "CAB-BASIC_equivalent" + @test length(des_s.components) == length(des.components) + + # Geometry continuity: outer radius preserved by equivalence + for (orig, simp) in zip(des.components, des_s.components) + @test simp.conductor_group.radius_in ≈ orig.conductor_group.radius_in atol = + TEST_TOL + @test simp.conductor_group.radius_ext ≈ orig.conductor_group.radius_ext atol = + TEST_TOL + @test simp.insulator_group.radius_ext ≈ orig.insulator_group.radius_ext atol = + TEST_TOL + @test simp.id == orig.id + end + + # new_id override + des_s2 = DM.equivalent(des; new_id = "CAB-SIMPLE") + @test des_s2.cable_id == "CAB-SIMPLE" + end + + @testset "Equivalence Preservation" begin + # The simplified design must preserve the component-equivalent properties + des = make_design("CAB-EQ"; ncomponents = 2) + des_s = DM.equivalent(des) + + for (orig, simp) in zip(des.components, des_s.components) + # Compare equivalent material properties (conductor) + @test simp.conductor_props.rho ≈ orig.conductor_props.rho atol = TEST_TOL + @test simp.conductor_props.eps_r ≈ orig.conductor_props.eps_r atol = TEST_TOL + @test simp.conductor_props.mu_r ≈ orig.conductor_props.mu_r atol = TEST_TOL + @test simp.conductor_props.T0 ≈ orig.conductor_props.T0 atol = TEST_TOL + @test simp.conductor_props.alpha ≈ orig.conductor_props.alpha atol = TEST_TOL + + # Compare equivalent material properties (insulator) + @test simp.insulator_props.rho ≈ orig.insulator_props.rho atol = TEST_TOL + @test simp.insulator_props.eps_r ≈ orig.insulator_props.eps_r atol = TEST_TOL + @test simp.insulator_props.mu_r ≈ orig.insulator_props.mu_r atol = TEST_TOL + @test simp.insulator_props.T0 ≈ orig.insulator_props.T0 atol = TEST_TOL + @test simp.insulator_props.alpha ≈ orig.insulator_props.alpha atol = TEST_TOL + + # Compare group lumped parameters (should be preserved by construction) + @test simp.conductor_group.resistance ≈ orig.conductor_group.resistance atol = + TEST_TOL + @test simp.conductor_group.gmr ≈ orig.conductor_group.gmr atol = TEST_TOL + @test simp.insulator_group.shunt_capacitance ≈ + orig.insulator_group.shunt_capacitance atol = TEST_TOL + @test simp.insulator_group.shunt_conductance ≈ + orig.insulator_group.shunt_conductance atol = TEST_TOL + end + end + + @testset "Edge Cases" begin + # Use Measurement geometry to ensure robustness with promoted numeric types + des = make_design("CAB-EDGE") + desM = DM.CableDesign( + "CAB-EDGE-M", + DM.CableComponent( + des.components[1].id, + DM.coerce_to_T( + des.components[1].conductor_group, + Measurements.Measurement{Float64}, + ), + DM.coerce_to_T( + des.components[1].insulator_group, + Measurements.Measurement{Float64}, + ), + ), + ) + + des_sM = DM.equivalent(desM) + @test typeof(des_sM.components[1].conductor_group.radius_in) <: + Measurements.Measurement + @test typeof(des_sM.components[1].conductor_group.radius_ext) <: + Measurements.Measurement + @test typeof(des_sM.components[1].insulator_group.radius_in) <: + Measurements.Measurement + @test typeof(des_sM.components[1].insulator_group.radius_ext) <: + Measurements.Measurement + end + + @testset "Physical Behavior" begin + des = make_design("CAB-PHYS") + des_s = DM.equivalent(des) + for comp in des_s.components + @test comp.conductor_props.rho > 0 + @test comp.conductor_group.gmr > 0 + @test comp.insulator_props.eps_r > 0 + @test comp.insulator_group.shunt_capacitance > 0 + end + end + + @testset "Type Stability & Promotion" begin + des = make_design("CAB-TYPES") + cF = des.components[1] + + # Base: Float64 -> Float64 + desF = DM.CableDesign("CAB-F", cF) + sF = DM.equivalent(desF) + @test eltype([sF.components[1].conductor_group.radius_in]) == Float64 + + # Fully promoted: Measurement -> Measurement + gM = DM.coerce_to_T(cF.conductor_group, Measurements.Measurement{Float64}) + igM = DM.coerce_to_T(cF.insulator_group, Measurements.Measurement{Float64}) + desM = DM.CableDesign("CAB-M", DM.CableComponent("coreM", gM, igM)) + sM = DM.equivalent(desM) + @test typeof(sM.components[1].conductor_group.radius_in) <: Measurements.Measurement + @test typeof(sM.components[1].insulator_group.radius_ext) <: + Measurements.Measurement + + # Mixed cases + desC = DM.CableDesign("CAB-C", DM.CableComponent("c1", gM, cF.insulator_group)) + sC = DM.equivalent(desC) + @test typeof(sC.components[1].conductor_group.radius_in) <: Measurements.Measurement + @test typeof(sC.components[1].insulator_group.radius_in) <: Measurements.Measurement + + desI = DM.CableDesign("CAB-I", DM.CableComponent("c2", cF.conductor_group, igM)) + sI = DM.equivalent(desI) + @test typeof(sI.components[1].conductor_group.radius_in) <: Measurements.Measurement + @test typeof(sI.components[1].insulator_group.radius_in) <: Measurements.Measurement + end +end diff --git a/test/unit_ImportExport/test_export_data_atp.jl b/test/unit_ImportExport/test_export_data_atp.jl index 2e741f12..6e0dfce9 100644 --- a/test/unit_ImportExport/test_export_data_atp.jl +++ b/test/unit_ImportExport/test_export_data_atp.jl @@ -1,181 +1,182 @@ -@testsnippet deps_export_atp begin - using EzXML -end - -# TODO: test if serialization works properly if uncertain types are used (Measurements) - -@testitem "ImportExport(export_data::atp): export LineCableSystem -> LCC data" setup = - [defaults, cable_system_export, deps_export_atp] begin - - - # 1. ARRANGE & ACT: Run the export in a temporary directory - mktempdir(joinpath(@__DIR__)) do tmpdir - output_file = joinpath(tmpdir, "atp_export_test.xml") - result_path = export_data(:atp, cable_system, earth_props, file_name = output_file) - expected_file = joinpath( - dirname(output_file), - "$(cable_system.system_id)_$(basename(output_file))", - ) - - # 2. ASSERT: Basic file checks (exporter prefixes basename with system_id) - @test result_path == expected_file - @test isfile(expected_file) - @test filesize(expected_file) > 500 - - # 3. ASSERT: General XML structure and LCC data - @info " Performing high-level XML structure checks..." - doc = readxml(expected_file) - root_node = root(doc) - - @test nodename(root_node) == "project" - @test root_node["Application"] == "ATPDraw" - - # Find the main LCC component content node - comp_content_node = findfirst("/project/objects/comp/comp_content", root_node) - @test !isnothing(comp_content_node) - - # Verify general parameters like Length, Freq, and Ground Resistivity - @info " Verifying general LCC data (Length, Freq, Grnd resis)..." - @test parse( - Float64, - findfirst("data[@Name='Length']", comp_content_node)["Value"], - ) ≈ cable_system.line_length - @test parse(Float64, findfirst("data[@Name='Freq']", comp_content_node)["Value"]) ≈ - problem_atp.frequencies[1] - @test parse( - Float64, - findfirst("data[@Name='Grnd resis']", comp_content_node)["Value"], - ) ≈ problem_atp.earth_props.layers[end].base_rho_g - - # 4. ASSERT: Detailed validation of ALL cables and conductors - @info " Verifying all cables and their conductors..." - lcc_node = findfirst("/project/objects/comp/LCC", root_node) - cable_header = findfirst("cable_header", lcc_node) - cable_nodes = findall("cable", cable_header) - - @test length(cable_nodes) == num_phases - - # Loop through each cable exported in the XML and compare it to the source - for (i, cable_node) in enumerate(cable_nodes) - @info " -> Checking Cable #$i..." - source_cable = cable_system.cables[i] - - # Verify position of EACH cable - @test parse(Float64, cable_node["PosX"]) ≈ source_cable.horz - @test parse(Float64, cable_node["PosY"]) ≈ source_cable.vert - - # Verify the number of conductor components inside this cable - num_components = length(source_cable.design_data.components) - @test parse(Int, cable_node["NumCond"]) == num_components - - conductor_nodes = findall("conductor", cable_node) - @test length(conductor_nodes) == num_components - - # Loop through each conductor component within the cable - for (j, conductor_node) in enumerate(conductor_nodes) - source_component = source_cable.design_data.components[j] - cond_group = source_component.conductor_group - cond_props = source_component.conductor_props - ins_group = source_component.insulator_group - ins_props = source_component.insulator_props - - expected_radius_in = cond_group.radius_in - expected_radius_ext = cond_group.radius_ext - expected_rho = cond_props.rho - expected_muC = cond_props.mu_r - expected_epsI = ins_props.eps_r - expected_muI = ins_props.mu_r - expected_Cext = ins_group.shunt_capacitance - expected_Gext = ins_group.shunt_conductance - - # Assert that every attribute matches the expected value - @test parse(Float64, conductor_node["Rin"]) ≈ expected_radius_in - @test parse(Float64, conductor_node["Rout"]) ≈ expected_radius_ext - @test parse(Float64, conductor_node["rho"]) ≈ expected_rho - @test parse(Float64, conductor_node["muC"]) ≈ expected_muC - @test parse(Float64, conductor_node["muI"]) ≈ expected_muI - @test parse(Float64, conductor_node["epsI"]) ≈ expected_epsI - @test parse(Float64, conductor_node["Cext"]) ≈ expected_Cext - @test parse(Float64, conductor_node["Gext"]) ≈ expected_Gext - end - end - @info " All detailed checks passed!" - end -end - - - - -@testitem "ImportExport(export_data::atp): export LineParameters -> ZY matrices" setup = - [defaults, cable_system_export, deps_export_atp] begin - - - - # 1. RUN THE TEST IN A TEMPORARY DIRECTORY - mktempdir(joinpath(@__DIR__)) do tmpdir - output_file = joinpath(tmpdir, "atp_export_test.xml") - @info " Exporting ATP XML file to: $output_file" - Z_matrix = randn(ComplexF64, num_phases, num_phases, length(freqs)) - Y_matrix = randn(ComplexF64, num_phases, num_phases, length(freqs)) - line_params = LineParameters(Z_matrix, Y_matrix, freqs) - - # Call the function we want to test (use the LineParameters overload and pass freqs) - result_path = export_data( - :atp, - line_params; - file_name = output_file, - cable_system = cable_system, - ) - expected_file = joinpath( - dirname(output_file), - "$(cable_system.system_id)_$(basename(output_file))", - ) - - # 2. BASIC FILE CHECKS - @test result_path == expected_file - @test isfile(expected_file) - @test filesize(expected_file) > 100 - - xml_content = read(expected_file, String) - @test occursin("", xml_content) - - # 3. XML STRUCTURE AND DATA VALIDATION - @info " Performing XML structure checks via XPath..." - xml_doc = readxml(expected_file) - root_node = root(xml_doc) - - @test nodename(root_node) == "ZY" - @test parse(Int, root_node["NumPhases"]) == num_phases - - # Search the whole document for Z blocks (safer) and assert presence before indexing - z_blocks = findall("//Z", xml_doc) - @test !isempty(z_blocks) - @test length(z_blocks) == length(freqs) - - # 4. DETAILED DATA VERIFICATION (for the first frequency) - @info " Verifying numerical data for first frequency..." - first_z_block = z_blocks[1] - @test parse(Float64, first_z_block["Freq"]) ≈ freqs[1] - - z_matrix_rows = split(strip(nodecontent(first_z_block)), '\n') - @test length(z_matrix_rows) == num_phases - - first_row_elements = split(z_matrix_rows[1], ',') - @test length(first_row_elements) == num_phases - number_pattern = r"(-?[\d\.]+E[+-]\d+)" - complex_pattern = Regex("$(number_pattern.pattern)([+-][\\d\\.]+E[+-]\\d+)i") - - match_result = match(complex_pattern, first_row_elements[1]) - - if !isnothing(match_result) - # The captures are now guaranteed to be valid Float64 strings - real_part = parse(Float64, match_result.captures[1]) - imag_part = parse(Float64, match_result.captures[2]) - parsed_z11 = complex(real_part, imag_part) - - expected_z11 = Z_matrix[1, 1, 1] - @test parsed_z11 ≈ expected_z11 rtol = 1e-12 - end - end -end +@testsnippet deps_export_atp begin + using EzXML + include("C:\\Users\\Amauri\\OneDrive\\Documentos\\UnB\\Mestrado\\LineCableModels.jl\\test\\runtests.jl") +end + +# TODO: test if serialization works properly if uncertain types are used (Measurements) + +@testitem "ImportExport(export_data::atp): export LineCableSystem -> LCC data" setup = + [defaults, cable_system_export, deps_export_atp] begin + + + # 1. ARRANGE & ACT: Run the export in a temporary directory + mktempdir(joinpath(@__DIR__)) do tmpdir + output_file = joinpath(tmpdir, "atp_export_test.xml") + result_path = export_data(:atp, cable_system, earth_props, file_name = output_file) + expected_file = joinpath( + dirname(output_file), + "$(cable_system.system_id)_$(basename(output_file))", + ) + + # 2. ASSERT: Basic file checks (exporter prefixes basename with system_id) + @test result_path == expected_file + @test isfile(expected_file) + @test filesize(expected_file) > 500 + + # 3. ASSERT: General XML structure and LCC data + @info " Performing high-level XML structure checks..." + doc = readxml(expected_file) + root_node = root(doc) + + @test nodename(root_node) == "project" + @test root_node["Application"] == "ATPDraw" + + # Find the main LCC component content node + comp_content_node = findfirst("/project/objects/comp/comp_content", root_node) + @test !isnothing(comp_content_node) + + # Verify general parameters like Length, Freq, and Ground Resistivity + @info " Verifying general LCC data (Length, Freq, Grnd resis)..." + @test parse( + Float64, + findfirst("data[@Name='Length']", comp_content_node)["Value"], + ) ≈ cable_system.line_length + @test parse(Float64, findfirst("data[@Name='Freq']", comp_content_node)["Value"]) ≈ + problem_atp.frequencies[1] + @test parse( + Float64, + findfirst("data[@Name='Grnd resis']", comp_content_node)["Value"], + ) ≈ problem_atp.earth_props.layers[end].base_rho_g + + # 4. ASSERT: Detailed validation of ALL cables and conductors + @info " Verifying all cables and their conductors..." + lcc_node = findfirst("/project/objects/comp/LCC", root_node) + cable_header = findfirst("cable_header", lcc_node) + cable_nodes = findall("cable", cable_header) + + @test length(cable_nodes) == num_phases + + # Loop through each cable exported in the XML and compare it to the source + for (i, cable_node) in enumerate(cable_nodes) + @info " -> Checking Cable #$i..." + source_cable = cable_system.cables[i] + + # Verify position of EACH cable + @test parse(Float64, cable_node["PosX"]) ≈ source_cable.horz + @test parse(Float64, cable_node["PosY"]) ≈ source_cable.vert + + # Verify the number of conductor components inside this cable + num_components = length(source_cable.design_data.components) + @test parse(Int, cable_node["NumCond"]) == num_components + + conductor_nodes = findall("conductor", cable_node) + @test length(conductor_nodes) == num_components + + # Loop through each conductor component within the cable + for (j, conductor_node) in enumerate(conductor_nodes) + source_component = source_cable.design_data.components[j] + cond_group = source_component.conductor_group + cond_props = source_component.conductor_props + ins_group = source_component.insulator_group + ins_props = source_component.insulator_props + + expected_radius_in = cond_group.radius_in + expected_radius_ext = cond_group.radius_ext + expected_rho = cond_props.rho + expected_muC = cond_props.mu_r + expected_epsI = ins_props.eps_r + expected_muI = ins_props.mu_r + expected_Cext = ins_group.shunt_capacitance + expected_Gext = ins_group.shunt_conductance + + # Assert that every attribute matches the expected value + @test parse(Float64, conductor_node["Rin"]) ≈ expected_radius_in + @test parse(Float64, conductor_node["Rout"]) ≈ expected_radius_ext + @test parse(Float64, conductor_node["rho"]) ≈ expected_rho + @test parse(Float64, conductor_node["muC"]) ≈ expected_muC + @test parse(Float64, conductor_node["muI"]) ≈ expected_muI + @test parse(Float64, conductor_node["epsI"]) ≈ expected_epsI + @test parse(Float64, conductor_node["Cext"]) ≈ expected_Cext + @test parse(Float64, conductor_node["Gext"]) ≈ expected_Gext + end + end + @info " All detailed checks passed!" + end +end + + + + +@testitem "ImportExport(export_data::atp): export LineParameters -> ZY matrices" setup = + [defaults, cable_system_export, deps_export_atp] begin + + + + # 1. RUN THE TEST IN A TEMPORARY DIRECTORY + mktempdir(joinpath(@__DIR__)) do tmpdir + output_file = joinpath(tmpdir, "atp_export_test.xml") + @info " Exporting ATP XML file to: $output_file" + Z_matrix = randn(ComplexF64, num_phases, num_phases, length(freqs)) + Y_matrix = randn(ComplexF64, num_phases, num_phases, length(freqs)) + line_params = LineParameters(Z_matrix, Y_matrix, freqs) + + # Call the function we want to test (use the LineParameters overload and pass freqs) + result_path = export_data( + :atp, + line_params; + file_name = output_file, + cable_system = cable_system, + ) + expected_file = joinpath( + dirname(output_file), + "$(cable_system.system_id)_$(basename(output_file))", + ) + + # 2. BASIC FILE CHECKS + @test result_path == expected_file + @test isfile(expected_file) + @test filesize(expected_file) > 100 + + xml_content = read(expected_file, String) + @test occursin("", xml_content) + + # 3. XML STRUCTURE AND DATA VALIDATION + @info " Performing XML structure checks via XPath..." + xml_doc = readxml(expected_file) + root_node = root(xml_doc) + + @test nodename(root_node) == "ZY" + @test parse(Int, root_node["NumPhases"]) == num_phases + + # Search the whole document for Z blocks (safer) and assert presence before indexing + z_blocks = findall("//Z", xml_doc) + @test !isempty(z_blocks) + @test length(z_blocks) == length(freqs) + + # 4. DETAILED DATA VERIFICATION (for the first frequency) + @info " Verifying numerical data for first frequency..." + first_z_block = z_blocks[1] + @test parse(Float64, first_z_block["Freq"]) ≈ freqs[1] + + z_matrix_rows = split(strip(nodecontent(first_z_block)), '\n') + @test length(z_matrix_rows) == num_phases + + first_row_elements = split(z_matrix_rows[1], ',') + @test length(first_row_elements) == num_phases + number_pattern = r"(-?[\d\.]+E[+-]\d+)" + complex_pattern = Regex("$(number_pattern.pattern)([+-][\\d\\.]+E[+-]\\d+)i") + + match_result = match(complex_pattern, first_row_elements[1]) + + if !isnothing(match_result) + # The captures are now guaranteed to be valid Float64 strings + real_part = parse(Float64, match_result.captures[1]) + imag_part = parse(Float64, match_result.captures[2]) + parsed_z11 = complex(real_part, imag_part) + + expected_z11 = Z_matrix[1, 1, 1] + @test parsed_z11 ≈ expected_z11 rtol = 1e-12 + end + end +end diff --git a/test/unit_ParametricBuilder/test_parametricbuilder.jl b/test/unit_ParametricBuilder/test_parametricbuilder.jl index b989bebf..7ae473df 100644 --- a/test/unit_ParametricBuilder/test_parametricbuilder.jl +++ b/test/unit_ParametricBuilder/test_parametricbuilder.jl @@ -1,388 +1,389 @@ -@testitem "ParametricBuilder(SystemBuilderSpec): combinatorics + value integrity" setup = - [defaults] begin - # ------------------------------------------------------------------------- - # Shared setup (mirrors your example, but we toggle `unc` per testset) - # ------------------------------------------------------------------------- - using LineCableModels - using LineCableModels.ParametricBuilder: - CableBuilder, build, Conductor, Insulator, Material, Earth, SystemBuilder, at, - make_stranded, make_screened, cardinality - using LineCableModels.DataModel: trifoil_formation, LineCableSystem, CablePosition - using Measurements - - # deterministic frequency grid - f = 10.0 .^ range(0, stop = 6, length = 10) - - # Materials library - materials = MaterialsLibrary(add_defaults = true) - - # deterministic geometry - t_sct = 0.3e-3 - t_sc_in = 0.000768 - t_ins = 0.0083 - t_sc_out = 0.000472 - t_cut = 0.0001 - w_cut = 10e-3 - t_wbt = 0.00094 - t_alt = 0.15e-3 - t_pet = 0.05e-3 - t_jac = 0.0034 - - # nominal data - datasheet_info = NominalData( - designation_code = "NA2XS(FL)2Y", - U0 = 18.0, U = 30.0, - conductor_cross_section = 1000.0, screen_cross_section = 35.0, - resistance = 0.0291, capacitance = 0.39, inductance = 0.3, - ) - - co_w = make_stranded(datasheet_info.conductor_cross_section).best_match - co_n = co_w.layers - co_d = co_w.wire_diameter_m - co_lay = 13.0 - - sc_w = make_screened(datasheet_info.screen_cross_section, 55.3).best_match - sc_n = sc_w.wires - sc_d = sc_w.wire_diameter_m - sc_lay = 10.0 - - # canonical parts builder (ρ/μ grids attached via `unc` in each testset) - function make_parts( - ms_al_uq, - ms_cu, - ms_pe, - ms_xlpe, - ms_sem1, - ms_sem2, - ms_polyacryl; - unc = nothing, - ) - return [ - # CORE conductors: stranded (central + rings) — uses PB coupling semantics. :contentReference[oaicite:1]{index=1} - Conductor.Stranded( - :core; - layers = co_n, - d = (co_d, unc), - n = 6, - lay = (co_lay, unc), - mat = ms_al_uq, - ), - - # CORE insulators - Insulator.Semicon(:core; layers = 1, t = (t_sct, unc), mat = ms_polyacryl), - Insulator.Semicon(:core; layers = 1, t = (t_sc_in, unc), mat = ms_sem1), - Insulator.Tubular(:core; layers = 1, t = (t_ins, unc), mat = ms_xlpe), - Insulator.Semicon(:core; layers = 1, t = t_sc_out, mat = ms_sem2), - Insulator.Semicon(:core; layers = 1, t = t_sct, mat = ms_polyacryl), - - # SHEATH - Conductor.Wires( - :sheath; - layers = 1, - d = (sc_d, unc), - n = sc_n, - lay = (sc_lay, unc), - mat = ms_cu, - ), - Conductor.Strip( - :sheath; - layers = 1, - t = (t_cut, unc), - w = (w_cut, unc), - lay = (sc_lay, unc), - mat = ms_cu, - ), - Insulator.Semicon(:sheath; layers = 1, t = t_wbt, mat = ms_polyacryl), - - # JACKET - Conductor.Tubular(:jacket; layers = 1, t = t_alt, mat = ms_al_uq), - Insulator.Tubular(:jacket; layers = 1, t = t_pet, mat = ms_pe), - Insulator.Tubular(:jacket; layers = 1, t = t_jac, mat = ms_pe), - ] - end - - # formation anchors - x0, y0 = 0.0, -1.0 - xa, ya, xb, yb, xc, yc = trifoil_formation(x0, y0, 0.05) - - # convenience to build SystemBuilder with 3 positions - function make_spec(cbs; dx = (0.0, nothing), dy = (0.0, nothing), - length = (1000.0, nothing), - temperature = (20.0, nothing), earth = Earth(rho = 100.0, eps_r = 10.0, mu_r = 1.0)) - positions = [ - at( - x = xa, - y = ya, - dx = dx, - dy = dy, - phases = (:core=>1, :sheath=>0, :jacket=>0), - ), - at( - x = xb, - y = yb, - dx = dx, - dy = dy, - phases = (:core=>2, :sheath=>0, :jacket=>0), - ), - at( - x = xc, - y = yc, - dx = dx, - dy = dy, - phases = (:core=>3, :sheath=>0, :jacket=>0), - ), - ] - return SystemBuilder( - "trifoil_case", - cbs, - positions; - length = length, - temperature = temperature, - earth = earth, - f = f, - ) - end - - # # helpers to collect all produced problems (channel consumer). - # function collect_all(xs) - # acc = Any[] - # for x in xs - # ; - # push!(acc, x); - # end - # return acc - # end - - # ──────────────────────────────────────────────────────────────────────── - @testset "Baseline: fully deterministic (cardinality=1, value equality)" begin - unc = nothing - # materials - ms_al_uq = Material(materials, "aluminum", rho = unc, mu_r = unc) - ms_al = Material(materials, "aluminum") - ms_cu = Material(materials, "copper") - ms_pe = Material(materials, "pe") - ms_xlpe = Material(materials, "xlpe") - ms_sem1 = Material(materials, "semicon1") - ms_sem2 = Material(materials, "semicon2") - ms_polyacryl = Material(materials, "polyacrylate") - - parts = make_parts(ms_al_uq, ms_cu, ms_pe, ms_xlpe, ms_sem1, ms_sem2, ms_polyacryl; unc) - cbs = CableBuilder("NA2XS(FL)2Y_1000", parts; nominal = datasheet_info) - - # CableBuilder cardinality should be 1 (all scalars). :contentReference[oaicite:2]{index=2} - @test length(cbs) == 1 - - spec = make_spec(cbs; dx = (0.0, nothing), dy = (0.0, nothing), - length = (1000.0, nothing), temperature = (20.0, nothing), - earth = Earth(rho = 100.0, eps_r = 10.0, mu_r = 1.0)) - - # SystemBuilder cardinality is designs × length × positions(dx,dy) × temperature × earth. :contentReference[oaicite:3]{index=3} - @test length(spec) == 1 - - probs = collect(spec) - @test length(probs) == 1 - - prob = probs[1] - - # Check system contents: 3 cables, phase mapping intact. :contentReference[oaicite:4]{index=4} - sys = prob.system - @test sys.num_cables == 3 - # access positions - let cps = sys.cables - @test length(cps) == 3 - # coords exact (deterministic) - @test cps[1].horz == xa && cps[1].vert == ya - @test cps[2].horz == xb && cps[2].vert == yb - @test cps[3].horz == xc && cps[3].vert == yc - # mapping - @test cps[1].conn[1] == 1 && cps[1].conn[2] == 0 && - cps[1].conn[3] == 0 - @test cps[2].conn[1] == 2 - @test cps[3].conn[1] == 3 - end - - # frequencies are carried through, deterministic preview - @test prob.frequencies == f - end - - # ──────────────────────────────────────────────────────────────────────── - @testset "Earth grids & %: ρ×εr×μr×t axes expand correctly" begin - unc = nothing - ms_al_uq = Material(materials, "aluminum", rho = unc, mu_r = unc) - ms_cu = Material(materials, "copper") - ms_pe = Material(materials, "pe") - ms_xlpe = Material(materials, "xlpe") - ms_sem1 = Material(materials, "semicon1") - ms_sem2 = Material(materials, "semicon2") - ms_polyacryl = Material(materials, "polyacrylate") - - parts = make_parts(ms_al_uq, ms_cu, ms_pe, ms_xlpe, ms_sem1, ms_sem2, ms_polyacryl; unc) - cbs = CableBuilder("NA2XS(FL)2Y_1000", parts; nominal = datasheet_info) - @test length(cbs) == 1 - - # ρ: (100, 500, 2) with 10% → values [100, 500] each ±10% (as Measurement) → 2 - # εr: [5, 10] → 2 - # μr: 1.0 → 1 - # t: Inf → 1 - earth = Earth( - rho = ((100.0, 500.0, 2), (10.0)), - eps_r = [5.0, 10.0], - mu_r = 1.0, - t = Inf, - ) - - spec = make_spec(cbs; earth = earth) - # total = 1 (designs) × 1 (len) × (1×1)^3 (positions) × 1 (T) × (2×2×1×1) = 4 - @test length(spec) == 4 - - probs = collect(spec) - @test length(probs) == 4 - - # Verify EarthModel inputs carry Measurement when % is present. - for pr in probs - em = pr.earth_props - # Check first layer nominal scalars hold Measurement type for rho (base value) when % applied - # (Earth layer API stores base_* as T; implementation ensures promotion via resolve_T). - lay1 = em.layers[1] - @test lay1.base_rho_g isa Measurement - @test lay1.base_epsr_g isa Measurement - @test lay1.base_mur_g isa Measurement - end - end - - # ──────────────────────────────────────────────────────────────────────── - @testset "System knobs: length & temperature grids + % expansion" begin - unc = (0.0, 10.0, 2) # use 0% and 10% (length 2) - ms_al_uq = Material(materials, "aluminum", rho = nothing, mu_r = nothing) - ms_cu = Material(materials, "copper") - ms_pe = Material(materials, "pe") - ms_xlpe = Material(materials, "xlpe") - ms_sem1 = Material(materials, "semicon1") - ms_sem2 = Material(materials, "semicon2") - ms_polyacryl = Material(materials, "polyacrylate") - - parts = make_parts(ms_al_uq, ms_cu, ms_pe, ms_xlpe, ms_sem1, ms_sem2, ms_polyacryl; unc = nothing) - cbs = CableBuilder("NA2XS(FL)2Y_1000", parts; nominal = datasheet_info) - @test length(cbs) == 1 - - # length: (1000, (0,10,2)) → [1000, 1000±10%] (2 choices) - # temp: (20, (0,10,2)) → [20, 20±10%] (2 choices) - spec = make_spec(cbs; - length = (1000.0, unc), - temperature = (20.0, unc), - earth = Earth(rho = 100.0, eps_r = 10.0, mu_r = 1.0), - ) - - # total = 1 × 2 × 1 × 2 × 1 = 4 - @test length(spec) == 4 - - probs = collect(spec) - @test length(probs) == 4 - - # Check at least one problem has Measurement length & temperature (promotion path) - # Pull system line_length via internal field (LineCableSystem constructor stores it as T). :contentReference[oaicite:7]{index=7} - found_meas = false - for pr in probs - sys = pr.system - if sys.line_length isa Measurement && pr.temperature isa Measurement - found_meas = true - break - end - end - @test found_meas - end - - # ──────────────────────────────────────────────────────────────────────── - @testset "Position axes: displacement grids & anchor % semantics" begin - ms_al_uq = Material(materials, "aluminum", rho = nothing, mu_r = nothing) - ms_cu = Material(materials, "copper") - ms_pe = Material(materials, "pe") - ms_xlpe = Material(materials, "xlpe") - ms_sem1 = Material(materials, "semicon1") - ms_sem2 = Material(materials, "semicon2") - ms_polyacryl = Material(materials, "polyacrylate") - - parts = make_parts(ms_al_uq, ms_cu, ms_pe, ms_xlpe, ms_sem1, ms_sem2, ms_polyacryl; unc = nothing) - cbs = CableBuilder("NA2XS(FL)2Y_1000", parts; nominal = datasheet_info) - @test length(cbs) == 1 - - # Case A: dx sweep, dy deterministic - specA = make_spec(cbs; - dx = (-0.01, 0.01, 3), # => [-0.01, 0.0, 0.01] about anchor - dy = (0.0, nothing), - earth = Earth(rho = 100.0, eps_r = 10.0, mu_r = 1.0), - ) - # total = 1 × 1 × (3×1)^3 × 1 × 1 = 27 - @test length(specA) == 27 - - # Validate actual x coordinates hit the expected triplet on p1 - xs = Float64[] - for pr in specA - push!(xs, pr.system.cables[1].horz - xa) - end - @test sort!(unique(round.(xs; digits = 5))) == [-0.01, 0.0, 0.01] - - # Case B: anchor % (no displacement sweep) — (nothing, pct) on dx. :contentReference[oaicite:8]{index=8} - specB = make_spec(cbs; - dx = (nothing, (0.0, 10.0, 2)), # anchors become [xa, measurement(xa, 10%)] - dy = (0.0, nothing), - earth = Earth(rho = 100.0, eps_r = 10.0, mu_r = 1.0), - ) - # total = 1 × 1 × (2×1)^3 × 1 × 1 = 8 - @test length(specB) == 8 - - # Confirm produced anchors include a Measurement with std(|xa|*10%) - has_anchor_meas = any( - begin - x = pr.system.cables[1].horz - x isa Measurement && - isapprox(uncertainty(x), abs(xa)*0.10; atol = eps()) # std = |xa|*10% - end for pr in specB - ) - @test has_anchor_meas - end - - # ──────────────────────────────────────────────────────────────────────── - @testset "Material % propagates into designs (ρ with % → Measurement in design tree)" begin - # Attach % to aluminum ρ and μ; all geometry deterministic - ms_al_uq = Material(materials, "aluminum", rho = (1.0, (0.0, 5.0, 2)), mu_r = (1.0, (0.0, 5.0, 2))) - ms_cu = Material(materials, "copper") - ms_pe = Material(materials, "pe") - ms_xlpe = Material(materials, "xlpe") - ms_sem1 = Material(materials, "semicon1") - ms_sem2 = Material(materials, "semicon2") - ms_polyacryl = Material(materials, "polyacrylate") - - parts = make_parts(ms_al_uq, ms_cu, ms_pe, ms_xlpe, ms_sem1, ms_sem2, ms_polyacryl; unc = nothing) - cbs = CableBuilder("NA2XS(FL)2Y_1000", parts; nominal = datasheet_info) - - # designs = 2×2 from ρ(%) × μ(%) on the same MaterialSpec (coupled across parts when equal tuples). :contentReference[oaicite:9]{index=9} - @test length(cbs) == cardinality(cbs) - - # System with deterministic system knobs - spec = make_spec(cbs; earth = Earth(rho = 100.0, eps_r = 10.0, mu_r = 1.0)) - probs = collect(spec) - - # Pick one problem; inspect the first cable's design tree for Measurement presence. - pr = probs[1] - des = pr.system.cables[1].design_data # the concrete CableDesign - # Assert that somewhere in the conductor effective material we see Measurement (ρ or μ). - # We traverse last component conductor props or any material-like fields that match ρ/μ semantics. - found_meas = false - for comp in des.components - # effective conductor/insulator props are Materials.Material - if hasproperty(comp, :conductor_props) - mp = getfield(comp, :conductor_props) - if (getfield(mp, :rho) isa Measurement) || - (getfield(mp, :mu_r) isa Measurement) || - (getfield(mp, :T0) isa Measurement) || - (getfield(mp, :alpha) isa Measurement) || - (getfield(mp, :eps_r) isa Measurement) - found_meas = true - break - end - end - end - @test found_meas - end -end +@testitem "ParametricBuilder(SystemBuilderSpec): combinatorics + value integrity" setup = + [defaults] begin + # ------------------------------------------------------------------------- + # Shared setup (mirrors your example, but we toggle `unc` per testset) + # ------------------------------------------------------------------------- + using LineCableModels + using LineCableModels.ParametricBuilder: + CableBuilder, build, Conductor, Insulator, Material, Earth, SystemBuilder, at, + make_stranded, make_screened, cardinality + using LineCableModels.DataModel: trifoil_formation, LineCableSystem, CablePosition + using Measurements + + # deterministic frequency grid + f = 10.0 .^ range(0, stop = 6, length = 10) + + # Materials library + materials = MaterialsLibrary(add_defaults = true) + + # deterministic geometry + t_sct = 0.3e-3 + t_sc_in = 0.000768 + t_ins = 0.0083 + t_sc_out = 0.000472 + t_cut = 0.0001 + w_cut = 10e-3 + t_wbt = 0.00094 + t_alt = 0.15e-3 + t_pet = 0.05e-3 + t_jac = 0.0034 + + # nominal data + datasheet_info = NominalData( + designation_code = "NA2XS(FL)2Y", + U0 = 18.0, U = 30.0, + conductor_cross_section = 1000.0, screen_cross_section = 35.0, + resistance = 0.0291, capacitance = 0.39, inductance = 0.3, + ) + + co_w = make_stranded(datasheet_info.conductor_cross_section).best_match + co_n = co_w.layers + co_d = co_w.wire_diameter_m + co_lay = 13.0 + + sc_w = make_screened(datasheet_info.screen_cross_section, 55.3).best_match + sc_n = sc_w.wires + sc_d = sc_w.wire_diameter_m + sc_lay = 10.0 + + # canonical parts builder (ρ/μ grids attached via `unc` in each testset) + function make_parts( + ms_al_uq, + ms_cu, + ms_pe, + ms_xlpe, + ms_sem1, + ms_sem2, + ms_polyacryl; + unc = nothing, + ) + return [ + # CORE conductors: stranded (central + rings) — uses PB coupling semantics. :contentReference[oaicite:1]{index=1} + Conductor.Stranded( + :core; + layers = co_n, + d = (co_d, unc), + n = 6, + lay = (co_lay, unc), + mat = ms_al_uq, + ), + + # CORE insulators + Insulator.Semicon(:core; layers = 1, t = (t_sct, unc), mat = ms_polyacryl), + Insulator.Semicon(:core; layers = 1, t = (t_sc_in, unc), mat = ms_sem1), + Insulator.Tubular(:core; layers = 1, t = (t_ins, unc), mat = ms_xlpe), + Insulator.Semicon(:core; layers = 1, t = t_sc_out, mat = ms_sem2), + Insulator.Semicon(:core; layers = 1, t = t_sct, mat = ms_polyacryl), + + # SHEATH + Conductor.Wires( + :sheath; + layers = 1, + d = (sc_d, unc), + n = sc_n, + lay = (sc_lay, unc), + mat = ms_cu, + ), + Conductor.Strip( + :sheath; + layers = 1, + t = (t_cut, unc), + w = (w_cut, unc), + lay = (sc_lay, unc), + mat = ms_cu, + ), + Insulator.Semicon(:sheath; layers = 1, t = t_wbt, mat = ms_polyacryl), + + # JACKET + Conductor.Tubular(:jacket; layers = 1, t = t_alt, mat = ms_al_uq), + Insulator.Tubular(:jacket; layers = 1, t = t_pet, mat = ms_pe), + Insulator.Tubular(:jacket; layers = 1, t = t_jac, mat = ms_pe), + ] + end + + # formation anchors + x0, y0 = 0.0, -1.0 + xa, ya, xb, yb, xc, yc = trifoil_formation(x0, y0, 0.05) + + # convenience to build SystemBuilder with 3 positions + function make_spec(cbs; dx = (0.0, nothing), dy = (0.0, nothing), + length = (1000.0, nothing), + temperature = (20.0, nothing), earth = Earth(rho = 100.0, eps_r = 10.0, mu_r = 1.0)) + positions = [ + at( + x = xa, + y = ya, + dx = dx, + dy = dy, + phases = (:core=>1, :sheath=>0, :jacket=>0), + ), + at( + x = xb, + y = yb, + dx = dx, + dy = dy, + phases = (:core=>2, :sheath=>0, :jacket=>0), + ), + at( + x = xc, + y = yc, + dx = dx, + dy = dy, + phases = (:core=>3, :sheath=>0, :jacket=>0), + ), + ] + return SystemBuilder( + "trifoil_case", + cbs, + positions; + length = length, + temperature = temperature, + earth = earth, + f = f, + ) + end + + # # helpers to collect all produced problems (channel consumer). + # function collect_all(xs) + # acc = Any[] + # for x in xs + # ; + # push!(acc, x); + # end + # return acc + # end + + # ──────────────────────────────────────────────────────────────────────── + @testset "Baseline: fully deterministic (cardinality=1, value equality)" begin + unc = nothing + # materials + ms_al_uq = Material(materials, "aluminum", rho = unc, mu_r = unc) + ms_al = Material(materials, "aluminum") + ms_cu = Material(materials, "copper") + ms_pe = Material(materials, "pe") + ms_xlpe = Material(materials, "xlpe") + ms_sem1 = Material(materials, "semicon1") + ms_sem2 = Material(materials, "semicon2") + ms_polyacryl = Material(materials, "polyacrylate") + + parts = make_parts(ms_al_uq, ms_cu, ms_pe, ms_xlpe, ms_sem1, ms_sem2, ms_polyacryl; unc) + cbs = CableBuilder("NA2XS(FL)2Y_1000", parts; nominal = datasheet_info) + + # CableBuilder cardinality should be 1 (all scalars). :contentReference[oaicite:2]{index=2} + @test length(cbs) == 1 + + spec = make_spec(cbs; dx = (0.0, nothing), dy = (0.0, nothing), + length = (1000.0, nothing), temperature = (20.0, nothing), + earth = Earth(rho = 100.0, eps_r = 10.0, mu_r = 1.0)) + + # SystemBuilder cardinality is designs × length × positions(dx,dy) × temperature × earth. :contentReference[oaicite:3]{index=3} + @test length(spec) == 1 + + probs = collect(spec) + @test length(probs) == 1 + + prob = probs[1] + + # Check system contents: 3 cables, phase mapping intact. :contentReference[oaicite:4]{index=4} + sys = prob.system + @test sys.num_cables == 3 + # access positions + let cps = sys.cables + @test length(cps) == 3 + # coords exact (deterministic) + @test cps[1].horz == xa && cps[1].vert == ya + @test cps[2].horz == xb && cps[2].vert == yb + @test cps[3].horz == xc && cps[3].vert == yc + # mapping + @test cps[1].conn[1] == 1 && cps[1].conn[2] == 0 && + cps[1].conn[3] == 0 + @test cps[2].conn[1] == 2 + @test cps[3].conn[1] == 3 + end + + # frequencies are carried through, deterministic preview + @test prob.frequencies == f + end + + # ──────────────────────────────────────────────────────────────────────── + @testset "Earth grids & %: ρ×εr×μr×t axes expand correctly" begin + unc = nothing + ms_al_uq = Material(materials, "aluminum", rho = unc, mu_r = unc) + ms_cu = Material(materials, "copper") + ms_pe = Material(materials, "pe") + ms_xlpe = Material(materials, "xlpe") + ms_sem1 = Material(materials, "semicon1") + ms_sem2 = Material(materials, "semicon2") + ms_polyacryl = Material(materials, "polyacrylate") + + parts = make_parts(ms_al_uq, ms_cu, ms_pe, ms_xlpe, ms_sem1, ms_sem2, ms_polyacryl; unc) + cbs = CableBuilder("NA2XS(FL)2Y_1000", parts; nominal = datasheet_info) + @test length(cbs) == 1 + + # ρ: (100, 500, 2) with 10% → values [100, 500] each ±10% (as Measurement) → 2 + # εr: [5, 10] → 2 + # μr: 1.0 → 1 + # t: Inf → 1 + earth = Earth( + rho = ((100.0, 500.0, 2), (10.0)), + eps_r = [5.0, 10.0], + mu_r = 1.0, + kappa = 1.0, + t = Inf, + ) + + spec = make_spec(cbs; earth = earth) + # total = 1 (designs) × 1 (len) × (1×1)^3 (positions) × 1 (T) × (2×2×1×1) = 4 + @test length(spec) == 4 + + probs = collect(spec) + @test length(probs) == 4 + + # Verify EarthModel inputs carry Measurement when % is present. + for pr in probs + em = pr.earth_props + # Check first layer nominal scalars hold Measurement type for rho (base value) when % applied + # (Earth layer API stores base_* as T; implementation ensures promotion via resolve_T). + lay1 = em.layers[1] + @test lay1.base_rho_g isa Measurement + @test lay1.base_epsr_g isa Measurement + @test lay1.base_mur_g isa Measurement + end + end + + # ──────────────────────────────────────────────────────────────────────── + @testset "System knobs: length & temperature grids + % expansion" begin + unc = (0.0, 10.0, 2) # use 0% and 10% (length 2) + ms_al_uq = Material(materials, "aluminum", rho = nothing, mu_r = nothing) + ms_cu = Material(materials, "copper") + ms_pe = Material(materials, "pe") + ms_xlpe = Material(materials, "xlpe") + ms_sem1 = Material(materials, "semicon1") + ms_sem2 = Material(materials, "semicon2") + ms_polyacryl = Material(materials, "polyacrylate") + + parts = make_parts(ms_al_uq, ms_cu, ms_pe, ms_xlpe, ms_sem1, ms_sem2, ms_polyacryl; unc = nothing) + cbs = CableBuilder("NA2XS(FL)2Y_1000", parts; nominal = datasheet_info) + @test length(cbs) == 1 + + # length: (1000, (0,10,2)) → [1000, 1000±10%] (2 choices) + # temp: (20, (0,10,2)) → [20, 20±10%] (2 choices) + spec = make_spec(cbs; + length = (1000.0, unc), + temperature = (20.0, unc), + earth = Earth(rho = 100.0, eps_r = 10.0, mu_r = 1.0), + ) + + # total = 1 × 2 × 1 × 2 × 1 = 4 + @test length(spec) == 4 + + probs = collect(spec) + @test length(probs) == 4 + + # Check at least one problem has Measurement length & temperature (promotion path) + # Pull system line_length via internal field (LineCableSystem constructor stores it as T). :contentReference[oaicite:7]{index=7} + found_meas = false + for pr in probs + sys = pr.system + if sys.line_length isa Measurement && pr.temperature isa Measurement + found_meas = true + break + end + end + @test found_meas + end + + # ──────────────────────────────────────────────────────────────────────── + @testset "Position axes: displacement grids & anchor % semantics" begin + ms_al_uq = Material(materials, "aluminum", rho = nothing, mu_r = nothing) + ms_cu = Material(materials, "copper") + ms_pe = Material(materials, "pe") + ms_xlpe = Material(materials, "xlpe") + ms_sem1 = Material(materials, "semicon1") + ms_sem2 = Material(materials, "semicon2") + ms_polyacryl = Material(materials, "polyacrylate") + + parts = make_parts(ms_al_uq, ms_cu, ms_pe, ms_xlpe, ms_sem1, ms_sem2, ms_polyacryl; unc = nothing) + cbs = CableBuilder("NA2XS(FL)2Y_1000", parts; nominal = datasheet_info) + @test length(cbs) == 1 + + # Case A: dx sweep, dy deterministic + specA = make_spec(cbs; + dx = (-0.01, 0.01, 3), # => [-0.01, 0.0, 0.01] about anchor + dy = (0.0, nothing), + earth = Earth(rho = 100.0, eps_r = 10.0, mu_r = 1.0), + ) + # total = 1 × 1 × (3×1)^3 × 1 × 1 = 27 + @test length(specA) == 27 + + # Validate actual x coordinates hit the expected triplet on p1 + xs = Float64[] + for pr in specA + push!(xs, pr.system.cables[1].horz - xa) + end + @test sort!(unique(round.(xs; digits = 5))) == [-0.01, 0.0, 0.01] + + # Case B: anchor % (no displacement sweep) — (nothing, pct) on dx. :contentReference[oaicite:8]{index=8} + specB = make_spec(cbs; + dx = (nothing, (0.0, 10.0, 2)), # anchors become [xa, measurement(xa, 10%)] + dy = (0.0, nothing), + earth = Earth(rho = 100.0, eps_r = 10.0, mu_r = 1.0), + ) + # total = 1 × 1 × (2×1)^3 × 1 × 1 = 8 + @test length(specB) == 8 + + # Confirm produced anchors include a Measurement with std(|xa|*10%) + has_anchor_meas = any( + begin + x = pr.system.cables[1].horz + x isa Measurement && + isapprox(uncertainty(x), abs(xa)*0.10; atol = eps()) # std = |xa|*10% + end for pr in specB + ) + @test has_anchor_meas + end + + # ──────────────────────────────────────────────────────────────────────── + @testset "Material % propagates into designs (ρ with % → Measurement in design tree)" begin + # Attach % to aluminum ρ and μ; all geometry deterministic + ms_al_uq = Material(materials, "aluminum", rho = (1.0, (0.0, 5.0, 2)), mu_r = (1.0, (0.0, 5.0, 2))) + ms_cu = Material(materials, "copper") + ms_pe = Material(materials, "pe") + ms_xlpe = Material(materials, "xlpe") + ms_sem1 = Material(materials, "semicon1") + ms_sem2 = Material(materials, "semicon2") + ms_polyacryl = Material(materials, "polyacrylate") + + parts = make_parts(ms_al_uq, ms_cu, ms_pe, ms_xlpe, ms_sem1, ms_sem2, ms_polyacryl; unc = nothing) + cbs = CableBuilder("NA2XS(FL)2Y_1000", parts; nominal = datasheet_info) + + # designs = 2×2 from ρ(%) × μ(%) on the same MaterialSpec (coupled across parts when equal tuples). :contentReference[oaicite:9]{index=9} + @test length(cbs) == cardinality(cbs) + + # System with deterministic system knobs + spec = make_spec(cbs; earth = Earth(rho = 100.0, eps_r = 10.0, mu_r = 1.0)) + probs = collect(spec) + + # Pick one problem; inspect the first cable's design tree for Measurement presence. + pr = probs[1] + des = pr.system.cables[1].design_data # the concrete CableDesign + # Assert that somewhere in the conductor effective material we see Measurement (ρ or μ). + # We traverse last component conductor props or any material-like fields that match ρ/μ semantics. + found_meas = false + for comp in des.components + # effective conductor/insulator props are Materials.Material + if hasproperty(comp, :conductor_props) + mp = getfield(comp, :conductor_props) + if (getfield(mp, :rho) isa Measurement) || + (getfield(mp, :mu_r) isa Measurement) || + (getfield(mp, :T0) isa Measurement) || + (getfield(mp, :alpha) isa Measurement) || + (getfield(mp, :eps_r) isa Measurement) + found_meas = true + break + end + end + end + @test found_meas + end +end diff --git a/test/unit_Validation/test_rules_tubular.jl b/test/unit_Validation/test_rules_tubular.jl index 994ea6f1..b26a84ad 100644 --- a/test/unit_Validation/test_rules_tubular.jl +++ b/test/unit_Validation/test_rules_tubular.jl @@ -1,26 +1,26 @@ -@testitem "Validation(Tubular): rule order unit test" setup = [defaults] begin - # Use fully-qualified names; do not add extra `using` here. - V = LineCableModels.Validation - T = LineCableModels.DataModel.Tubular - M = LineCableModels.Materials.Material - - r = V._rules(T) - - expected = ( - V.Normalized(:radius_in), V.Normalized(:radius_ext), - V.Finite(:radius_in), V.Nonneg(:radius_in), - V.Finite(:radius_ext), V.Nonneg(:radius_ext), - V.Less(:radius_in, :radius_ext), - V.Finite(:temperature), - V.IsA{M}(:material_props), - ) - - if r != expected - @error "[Validation] Rule set for Tubular is wrong. Someone ‘helpfully’ changed the bundle order or duplicated rules.\n" * - "Expected exact structural equality with the generated bundle. Fix your traits/extra_rules and stop being clever." - @show expected - @show r - end - - @test r == expected -end +@testitem "Validation(Tubular): rule order unit test" setup = [defaults] begin + # Use fully-qualified names; do not add extra `using` here. + V = LineCableModels.Validation + T = LineCableModels.DataModel.Tubular + M = LineCableModels.Materials.Material + + r = V._rules(T) + + expected = ( + V.Normalized(:radius_in), V.Normalized(:radius_ext), + V.Finite(:radius_in), V.Nonneg(:radius_in), + V.Finite(:radius_ext), V.Nonneg(:radius_ext), + V.Less(:radius_in, :radius_ext), + V.Finite(:temperature), + V.IsA{M}(:material_props), + ) + + if r != expected + @error "[Validation] Rule set for Tubular is wrong. Someone ‘helpfully’ changed the bundle order or duplicated rules.\n" * + "Expected exact structural equality with the generated bundle. Fix your traits/extra_rules and stop being clever." + @show expected + @show r + end + + @test r == expected +end