diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 000000000..648d16049
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,20 @@
+# Third-party notices
+
+## GetDP
+
+The optional Gmsh/GetDP finite-element backend can download and execute GetDP
+3.5.0 from the package's lazy `getdp` artifact. GetDP is copyright (C)
+1997–2022 P. Dular and C. Geuzaine, University of Liege, and is distributed
+under the GNU General Public License, version 2 or later.
+
+- Project: https://getdp.info/
+- Source: https://getdp.info/src/getdp-3.5.0-source.tgz
+- Source SHA-256: `d6814dc3f81431f1db30b3d5318553efab616d7ea53b352a2c2d0640d130a328`
+- License: https://getdp.info/doc/texinfo/getdp.html#License
+
+The upstream binary archives bound by `Artifacts.toml` include `LICENSE.txt`,
+`CREDITS.txt`, and `README.txt`. LineCableModels invokes GetDP as an external
+program. GetDP is not incorporated into the LineCableModels library.
+
+Maintainers can revalidate every supported archive, tree hash, executable and
+license file with `julia test/manual/verification/verify_getdp_artifact.jl`.
diff --git a/TODO.md b/TODO.md
deleted file mode 100644
index e044b1574..000000000
--- a/TODO.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# TODO for LineCableModels.jl
-
-This is a living document intended to track scientific development priorities and research directions for new features, methods and solutions to be included in the package.
-
-For bugs, features and implementation taks, the [Issues](https://github.com/Electa-Git/LineCableModels.jl/issues) page is used.
-
-## Wishlist
-
-- [ ] Pipe-type cables and MoM-SO implementation.
-
-## In progress
-
-- [ ] Implementation of frequency-dependent soil properties.
-- [ ] Development of novel formulations for cables composed of N concentrical layers, allowing for accurate representations of semiconductor materials.
-- [ ] Implementation of an interface to run finite element simulations using [Onelab](https://onelab.info/).
-
-## Done ✓
-
-- [x] Object-oriented data model for cables, conductors, insulations and materials.
diff --git a/binder/Project.toml b/binder/Project.toml
deleted file mode 100644
index 0bdc76be6..000000000
--- a/binder/Project.toml
+++ /dev/null
@@ -1,30 +0,0 @@
-name = "Showcase"
-
-[deps]
-Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
-WebIO = "0f1e0344-ec1d-5b48-a673-e5cf874b6c29"
-Pluto = "c3e4b0f8-55cb-11ea-2926-15256bba5781"
-PlutoUI = "7f904dfe-b85e-4ff6-b463-dae2292396a8"
-GetDP = "dbfd83e6-7aba-450b-9c2b-93ccd973023a"
-LineCableModels = "dffb7669-8cd4-4628-99dd-7694e356ba37"
-CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
-DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
-LaTeXStrings = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f"
-Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
-MathTeXEngine = "0a4f8689-d25c-4efe-a92b-7142dfc1aa53"
-Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7"
-Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
-PrettyTables = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d"
-Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7"
-Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"
-WGLMakie = "276b4fcb-3e11-5398-bf8b-a0c2d153d008"
-FileIO = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549"
-PlutoStyles = "16e07271-a41c-4e3e-b73a-b807cf2ea0eb"
-
-[sources]
-GetDP = {rev = "main", url = "https://github.com/Electa-Git/GetDP.jl"}
-LineCableModels = {path = ".."}
-
-
-[compat]
-julia = "1.12"
diff --git a/binder/postBuild b/binder/postBuild
deleted file mode 100644
index 76f9d749f..000000000
--- a/binder/postBuild
+++ /dev/null
@@ -1,78 +0,0 @@
-#!/bin/bash
-set -euxo pipefail
-
-# TODO: Make this stupid Binder+Pluto setup bow to my will. Need to find some way to add the necessary (unregistered) packages to whatever is the default env under jupyterlab -> pluto.
-
-julia -e '
-using Pkg, TOML
-
-# 1) Activate the default versioned env, not a folder named "@v#.#"
-Pkg.activate("@v#.#")
-
-# 2) Develop the local repo (assume postBuild is running at repo root)
-try
- Pkg.develop(path=".")
-catch e
- @warn "Pkg.develop(path=\".\") failed" exception=(e, catch_backtrace())
-end
-
-# Helper to add deps from a Project.toml
-function add_deps_from(project_file::String)
- if !isfile(project_file)
- @info "No $project_file found"; return
- end
- proj = TOML.parsefile(project_file)
- deps = get(proj, "deps", Dict{String,Any}())
- compat = get(proj, "compat", Dict{String,Any}())
- extras = get(proj, "extras", Dict{String,Any}()) # in case you keep dev/test stuff here
- targets = get(proj, "targets", Dict{String,Any}())
-
- # Collect package names to install (deps + optionally selected extras)
- names = Set{String}(keys(deps))
-
- # If you keep things like IJulia/Pluto in [extras] with a "binder" target, include them
- if haskey(targets, "binder") && isa(targets["binder"], Vector)
- for extra in targets["binder"]
- extra in keys(extras) && push!(names, extra)
- end
- end
-
- for name in sort(collect(names))
- # Skip your own devved package if it appears in deps
- if name == "LineCableModels"
- continue
- end
- ver = get(compat, name, nothing)
- spec = ver === nothing ? Pkg.PackageSpec(name=name) :
- Pkg.PackageSpec(name=name, version=ver)
- try
- Pkg.add(spec)
- catch e
- @warn "Pkg.add failed for $name" exception=(e, catch_backtrace())
- end
- end
-end
-
-# Prefer binder/Project.toml, fall back to repo root
-add_deps_from("binder/Project.toml")
-add_deps_from("Project.toml")
-
-# 3) Nice-to-haves commonly needed in Binder + Pluto
-try
- Pkg.add(["IJulia", "Pluto"])
-catch e
- @warn "Optional adds failed" exception=(e, catch_backtrace())
-end
-
-# 4) Precompile for faster startup
-Pkg.precompile()
-
-# 5) Make sure the IJulia kernel points to the default env (useful in JupyterLab)
-try
- using IJulia
- envpath = Base.load_path_expand("@v#.#")
- IJulia.installkernel("Julia (@v#.#)", env=envpath)
-catch e
- @warn "IJulia kernel install failed (non-fatal)" exception=(e, catch_backtrace())
-end
-'
diff --git a/binder/requirements.txt b/binder/requirements.txt
deleted file mode 100644
index b8cc81228..000000000
--- a/binder/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-jupyterlab>=4
-jupyter-server-proxy>=4
-jupyter-pluto-proxy==0.1.1
\ No newline at end of file
diff --git a/codecov.yml b/codecov.yml
deleted file mode 100644
index ba3f00680..000000000
--- a/codecov.yml
+++ /dev/null
@@ -1,3 +0,0 @@
-ignore:
- - "LineCableModels/src/legacy/**"
-
diff --git a/docs/Project.toml b/docs/Project.toml
index e647e1322..96022d8cc 100644
--- a/docs/Project.toml
+++ b/docs/Project.toml
@@ -1,14 +1,32 @@
[deps]
-Changelog = "5217a498-cd5d-4ec6-b8c2-9b85a09b6e3e"
+BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
+AbstractTrees = "1520ce14-60c1-5f80-bbc7-55ef81b5835c"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b"
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
DocumenterCitations = "daee34ce-89f3-4625-b898-19384cb65244"
+Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6"
+GeoInterface = "cf35fbd7-0cd7-5166-be24-54bfbe79505f"
+GeoJSON = "61d90e0f-e114-555e-ac52-39dfb47a3ef9"
+LineCableModels = "dffb7669-8cd4-4628-99dd-7694e356ba37"
Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306"
+Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7"
+
+[sources]
+LineCableModels = {path = ".."}
[compat]
-Changelog = "1"
+BenchmarkTools = "1"
+AbstractTrees = "0.4"
+CairoMakie = "0.15"
+CSV = "0.10"
DataFrames = "1"
Documenter = "1"
DocumenterCitations = "1"
+GeoInterface = "1.5"
+GeoJSON = "0.8"
+LineCableModels = "0.2"
Literate = "2"
+Measurements = "2.11.0"
julia = "1.12"
diff --git a/docs/deploy.jl b/docs/deploy.jl
new file mode 100644
index 000000000..e633b932e
--- /dev/null
+++ b/docs/deploy.jl
@@ -0,0 +1,8 @@
+using Documenter
+
+deploydocs(;
+ repo = "github.com/Electa-Git/LineCableModels.jl.git",
+ devbranch = "main",
+ versions = ["stable" => "v^", "dev" => "main"],
+ branch = "gh-pages"
+)
diff --git a/docs/doctest.jl b/docs/doctest.jl
new file mode 100644
index 000000000..e5ebc09d5
--- /dev/null
+++ b/docs/doctest.jl
@@ -0,0 +1,13 @@
+using Documenter
+using LineCableModels
+
+DocMeta.setdocmeta!(
+ LineCableModels,
+ :DocTestSetup,
+ quote
+ using LineCableModels
+ using LineCableModels.DataModel.BaseParams
+ end;
+ recursive = true
+)
+doctest(LineCableModels)
diff --git a/docs/instantiate.jl b/docs/instantiate.jl
new file mode 100644
index 000000000..73a864499
--- /dev/null
+++ b/docs/instantiate.jl
@@ -0,0 +1,4 @@
+using Pkg
+
+Pkg.activate(@__DIR__)
+Pkg.instantiate()
diff --git a/docs/literate/plotting.jl b/docs/literate/plotting.jl
new file mode 100644
index 000000000..8ed5cc1f4
--- /dev/null
+++ b/docs/literate/plotting.jl
@@ -0,0 +1,1325 @@
+# # Makie plotting: implementation guide and complete gallery
+
+# Plot cable geometry, electrical parameters, and uncertainty results with Makie.
+# Each example shows the call that produces its figure. The returned `UIPlot`
+# contains native Makie objects that you can modify directly.
+
+using LineCableModels
+using CairoMakie
+using LinearAlgebra: diag
+using Measurements: measurement
+using Statistics: mean
+
+# ## Figures, titles, and legends
+
+# A call that produces one figure returns a [`UIPlot`](@ref). A call that
+# produces several figures returns `Vector{UIPlot}`. The `figure`, `axes`,
+# `controls`, `legend`, `panel_legends`, and `colorbars` fields expose the
+# displayed Makie objects.
+
+# ### Naming titles and legends
+
+# Layout text has one name per scope. These keywords are presentation metadata.
+# The result tensors and geometry tags remain unchanged.
+#
+# | Keyword | Scope |
+# |:--|:--|
+# | `figure_title` | One visible title above the whole native figure |
+# | `title_attributes` | Native Makie `Label` attributes for `figure_title` |
+# | `panel_titles` | Axis-title overrides by position, request, or panel identifier |
+# | `legend_title` | Heading of the controlled figure legend |
+# | `series_labels` | Names of the overlaid gridpoints or physical coordinates. These are the legend entries |
+# | `series_attributes` | Native Makie attributes for all overlaid curves, or one named tuple per curve |
+# | `legend_position` | `:inside`, a named outer dock, or a positive dock grid position |
+# | `legend_attributes` | Native `halign`/`valign`, orientation, banks, fonts, and padding |
+#
+# Some established recipes also accept `title` for the window or exported file,
+# or as the heading of one recipe. Use the explicit scoped names above when composing a
+# dashboard.
+# Benchmark windows default to `case ID — quantity`, followed by block indices
+# when split. Deterministic, mean ± std, and explicit-statistic comparisons use
+# this same rule. An explicit `title` overrides the default case prefix. Subplot
+# titles and `figure_title` remain independent.
+#
+# `series_attributes` uses the shared PlotBuilder controls for matrix and benchmark
+# plots, observed results, statistical plots, and geometry previews.
+# A named tuple applies to every group. A tuple or vector of named tuples styles
+# each group separately.
+# `series_attributes=((marker=:circle, markersize=8), (;), (linestyle=:dash,))`
+# adds markers to the first series, keeps the second's defaults and dashes the
+# third. Styles apply across facets and pages, including their legends and
+# visibility controls. Attributes must be supported by the group's native plots.
+# In `plotwindow`, groups follow native plot insertion order across its axes.
+#
+# The same scopes are mutable after construction. `figuretitle!` and
+# `paneltitle!` replace titles. `figurelegend!` and `panellegend!` rebuild a
+# native legend from the plots in each visibility group, preserving grouped
+# visibility controls when labels change.
+
+# ### Observation, facet, page, and legend semantics
+
+# `LineParameters` owns dense ``Z`` and ``Y`` tensors. The observation grammar
+# is what turns those tensors into displayable physical quantities: ``Z``
+# expands to ``R`` then ``X``. ``Y`` expands to ``G`` then ``B``. An exact
+# request such as `@observe Z[1,1,:]` keeps its conductor coordinates while
+# selecting the complete frequency range. Plotting consumes that resolved
+# request and displays both components.
+# The plot-facing name for this ordinate selection is `ydata`. Pass it
+# positionally or as a keyword, such as `plot(result; ydata=(R, L))`.
+#
+# With matrix gridpoint overlays, every `(quantity, row, column)` has one axis.
+# Its default title uses `Units.label` and the retained coordinate labels, such as `Self series resistance — conductor 1` or
+# `Mutual series reactance — conductor 1 → 2`. With gridpoint overlays,
+# coordinates identify panels and legends distinguish result sets. With
+# coordinate overlays, each selected coordinate is a labelled curve. One
+# unlabeled curve has no legend by default.
+#
+# `layout` is the only nominal panel capacity. Every selected quantity or
+# statistical meaning has its own figure family. Omitted layout chooses capacity
+# independently for each family. An explicit `(rows, columns)` sets the same
+# nominal capacity for all families.
+#
+# | `layout` | Page grouping |
+# |:--|:--|
+# | `nothing` | Each family's selected matrix span, or a near-square flow capacity |
+# | `(1, 1)` | One selected panel per figure, per quantity |
+# | `(1, 2)` | Up to two panels in one row per page |
+# | `(2, 1)` | Up to two panels in one column per page |
+# | `(N, N)` | Up to `N²` panels per figure, per quantity |
+#
+# Layout follows observation selection. It cannot add an excluded coefficient or
+# merge different quantities. `overlay` controls the panel and curve dimensions:
+#
+# | `overlay` | Panels | Curves |
+# |:--|:--|:--|
+# | `:auto` | Mode panels for several gridpoints or a larger explicit layout. Matrix coefficient panels otherwise | Gridpoints |
+# | `:auto` for one modal vector with omitted layout or `(1,1)` | One panel per component | Selected modes |
+# | `:gridpoints` | Selected coordinates | Gridpoints |
+# | `:coordinates` | Selected gridpoints | Selected coordinates |
+# | `:rows` (matrices) | Selected columns, with separate figures per gridpoint | Selected rows |
+#
+# Filtering occurs before this choice. An explicit reference counts as another
+# gridpoint. Gridpoint overlays retain equivalent-result grouping. Coordinate
+# overlays retain a panel for every selected point. Tuple/vector labels and styles
+# address the overlaid dimension, so incompatible mixed families need separate
+# calls. A shared style NamedTuple applies to every curve.
+#
+# `overlay=:rows` also retains every selected point, including equivalent results
+# and a reference, in its own figure sequence. For Tv/Ti, columns are modes and
+# rows are conductors: each panel title identifies its mode and its legend lists
+# conductors. Figure titles use compact gridpoint descriptions. Other matrices
+# use the same operation with their own physical row and column labels.
+#
+# Row-overlay panels follow selected column order, left to right and then top to
+# bottom. `layout=(r,c)` applies the same block capacity to each point. Pagination
+# restarts at each point, even when the previous page has empty cells. Automatic
+# capacity uses the selected column count per point. Panel titles and controls
+# accept `(original_point_position, original_column)` addresses. An explicit
+# reference uses its appended input position. Row colors and markers remain
+# consistent across columns, points, references and reordered selections.
+# Positional `series_labels` and `series_attributes` follow selected row order.
+# Use separate calls for matrix and vector quantities with this explicit option.
+
+# ### Matrix pagination and overlays
+#
+# `plot(results; ydata=(R,L), layout=(2,2))` produces four R pages and four L
+# pages for a full 3×3 matrix. Their actual extents are 2×2, 2×1, 1×2, and 1×1.
+# Pages follow request order and then row-major matrix-page order. Original
+# coefficient identities remain the addresses for titles, legends, scales, and reset.
+# Positional `panel_titles` bind to the complete selected population before paging.
+#
+# Automatic matrix pages cover the selected original-coordinate rectangle. Consider this example: selecting columns 2 and 3 gives a two-column overview without splitting
+# at the original block edge. Explicit capacities keep blocks anchored at
+# original coordinate (1,1). Both remove unselected exterior tracks and retain
+# internal holes: selecting (1,1) and (1,3) still spans three columns.
+#
+# Native decoration measurements establish equal initial data frames. `fig_size`
+# is the reference size for the nominal capacity. `figure.size` takes precedence.
+# Legends use at most the fraction specified by `legend_cap=0.5`
+# of their associated data area. Extra entries become `(...)` within that
+# allocation. All curves remain plotted.
+# Each finished window fits its actual occupied panels, titles, guides, and
+# enabled controls. A singleton with explicit `layout=(1,2)` retains the smaller
+# frame from that two-column reference, without allocating an empty second cell.
+# Each page can be resized independently in both dimensions.
+# Explicit diagonal observations and preview collections use the same capacity,
+# preserving their source order and original identities in compact flow pages.
+#
+# Result overlays use solid lines with sparse, staggered markers on saved
+# sample points: black curves and hollow circles for references, colored curves
+# and filled shapes for results. References may themselves include uncertainty.
+# Default routes and explicit implementations have the same styling semantics.
+# Deterministic references mark both endpoints.
+# Colors and marker identities remain stable
+# when formulations are filtered. No curves are merged because they agree.
+# Use `series_attributes=(marker=nothing,)` for lines only, or an explicit native
+# `marker` for all-sample placement. References remain comparison operands, not
+# declarations of physical truth.
+#
+# With multiple displayed series, `errorbar_sampling=:staggered` selects sparse error
+# bars at retained samples separately from automatic markers. Both references
+# and results retain their full mean curves. Coordinates, uncertainties,
+# comparison calculations, and full-data axis limits are unchanged. X and Y
+# intervals use the same indices. When very few samples are available, intervals
+# take priority over conflicting automatic markers. The legend retains identity.
+# Use `errorbar_sampling=:all` to inspect every interval, with automatic markers
+# omitted on uncertain series. One displayed series uses `:all` by default.
+# Explicit native markers still use
+# all samples. Native `whiskerwidth` and `linewidth` overrides take priority.
+# Sparse glyphs are an overview: use full intervals or separate standard-deviation
+# curves to inspect uncertainty variation. Mean ± std is not mean ± standard error.
+# Many exactly coincident methods cannot all remain distinguishable at finite
+# screen resolution. Use legend visibility to inspect them separately.
+#
+# Within one identified physical point, observation-owned groups may share a trace
+# for equivalent quantity-relevant selections: impedance choices on Z/R/L/X pages,
+# admittance choices on Y/G/C/B pages. Every relevant composite route, control,
+# coordinate and uncertainty meaning participates. Equal curves or descriptions
+# alone never merge cases. Different physical points remain separate. Conflicting
+# observations under the same selection raise an error. Saved results remain intact.
+# Default labels show only relevant differences across results and reference,
+# omitting common physical inputs and individual controls. Names are captured from
+# owner-dispatched `description(...; compact=true)` methods used by report tables:
+# `FEM (reference)`, `PSCAD (reference)`, `Monte Carlo (reference)`, and results
+# such as `LEP` or `earth Z=Saad`, without result numbering.
+# `formulations=[3,1]` selects recorded original formulation identities, preserving
+# order and colors. Explicit `series_labels` and styles follow the retained source.
+# When nothing varies, labels use the applicable compact owner description.
+# Automatic results are chromatic. The separate reference is black.
+# Full scientific explanations and common settings remain in `formula_details`.
+#
+# Top/bottom legends fit a measured row-major grid and wrap long labels without
+# dropping fields. Explicit `legend_attributes=(orientation=..., nbanks=...)`
+# retains native manual layout control.
+
+# ## Gallery data
+
+# The frequency responses are deliberately small but non-constant so that
+# logarithmic axes, engineering units, legends, and automatic limits are all
+# visible in the generated documentation.
+
+frequency = collect(10.0 .^ range(1, 4; length = 24));
+angular_frequency = reshape(2π .* frequency, 1, 1, :);
+resistance = cat(
+ (
+ [1.0 0.22; 0.22 1.8] .* 1.0e-4 .* (1 + 0.12 * log10(f / first(frequency)))
+ for f in frequency
+ )...;
+ dims = 3);
+inductance = cat(
+ (
+ [2.0 0.28; 0.28 2.5] .* 1.0e-7 .* (1 - 0.04 * log10(f / first(frequency)))
+ for f in frequency
+ )...;
+ dims = 3);
+conductance = cat(
+ (
+ [3.0 -0.45; -0.45 4.0] .* 1.0e-9 .* (1 + 0.08 * log10(f / first(frequency)))
+ for f in frequency
+ )...;
+ dims = 3);
+capacitance = repeat([4.0 -0.7; -0.7 5.0] .* 1.0e-10, 1, 1, length(frequency));
+parameters = LineParameters(
+ complex.(resistance, inductance .* angular_frequency),
+ complex.(conductance, capacitance .* angular_frequency),
+ frequency
+);
+
+# The geometry gallery uses the example library included in the repository.
+# A system is assembled from two placements without introducing a plotting-only
+# geometry representation.
+
+cable_library = CablesLibrary();
+LineCableModels.load!(
+ cable_library;
+ file_name = joinpath(pkgdir(LineCableModels), "examples", "cables_library.json")
+);
+mv_design = cable_library["18kV_1000mm2"];
+hv_design = cable_library["525kV_1600mm2"];
+earth = EarthModel(100.0, 10.0, 1.0);
+cable_system = build(
+ LineCableSystem,
+ [mv_design, mv_design],
+ [(-0.06, -0.20), (0.06, -0.20)];
+ environment = earth,
+ system_id = "two-cable-gallery",
+ line_length = 1_000.0
+);
+
+# The following synthetic samples, moments, and histograms illustrate the
+# UQ plotting methods.
+retained_samples = (
+ R = reshape([2.0, 3.0, 5.0, 8.0] .* 1e-4, 1, :),
+ L = reshape([11.0, 13.0, 17.0, 19.0] .* 1e-7, 1, :),
+ C = reshape([23.0, 29.0, 31.0, 37.0] .* 1e-11, 1, :),
+ G = reshape([41.0, 43.0, 47.0, 53.0] .* 1e-10, 1, :)
+);
+retained_statistics = map(x -> [SampleSummary(vec(x))], retained_samples);
+retained_histograms = map(x -> [HistogramDensity(vec(x); bins = 2)], retained_samples);
+retained_core = CableConstants(mean(retained_samples.R), mean(retained_samples.L),
+ mean(retained_samples.C), mean(retained_samples.G));
+retained_core = LineCableModels.materialize(retained_core, retained_statistics);
+mc_formulation = MonteCarlo(Formulation(); trials = 4, seed = 41,
+ return_samples = true, return_histograms = true);
+mc_result = MonteCarloResult(mc_formulation, [retained_core], [retained_statistics],
+ [retained_samples], [retained_histograms], UInt64(41), UInt64[42], [4]);
+
+# ## Line-parameter recipes
+
+# ### Complete default view
+
+# `Makie.plot(parameters)` is the minimal call. It observes everything in the
+# order ``Z`` then ``Y``, expands those families to ``R``, ``X``,
+# ``G``, and ``B``, and returns four matrix-dashboard pages. For this 2×2
+# result, every page contains four axes. The calls below render the full default
+# gallery in exactly that order.
+
+default_line_pages = Makie.plot(
+ parameters;
+ backend = :cairo, # choose the already-loaded native backend
+ display_plot = false, # Documenter owns display. Interactive use may omit this
+ controls = false, # omit toolbar chrome from this static gallery
+ xscale = :log10, # initial scale. Interactive controls may change it
+ fig_size = (900, 680) # reference size of the nominal capacity
+)
+default_line_pages[1].figure #hide
+default_line_pages[2].figure #hide
+default_line_pages[3].figure #hide
+default_line_pages[4].figure #hide
+
+# To change only appearance, mutate the returned native axes. To change which
+# physical values exist, pass an observation selector or exact `@observe`
+# request. To change pagination, pass one of the layouts in the table above.
+
+# ### Cartesian series impedance
+
+# This exact observation selects the self impedance of conductor 1 over the
+# full frequency range. `ObservedResult` construction expands ``Z`` into resistance and
+# reactance. They remain separate quantity figures, even with a multi-panel layout.
+
+## `xscale=:log10` is an initial state. The live toolbar can still change it.
+## `figure_title` labels each figure. `panel_titles` follows the selected quantity order.
+## `series_labels` names result sets, so supplying one explicitly opts into a legend.
+## `:inside` overlays the logical frame region with native alignment attributes.
+series_cartesian = Makie.plot(
+ parameters,
+ @observe Z[1, 1, :]; # self impedance, conductor 1, every frequency
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ xscale = :log10,
+ fig_size = (1000, 500),
+ figure_title = "Conductor 1 self impedance",
+ panel_titles = ("Resistance", "Reactance"),
+ legend_title = "Result set",
+ series_labels = ("reference",),
+ legend_position = :inside,
+ legend_attributes = (;
+ halign = :right, valign = :bottom, backgroundcolor = (:white, 0.92)),
+ legend_cap = 0.5
+)
+series_cartesian[1].figure #hide
+#-
+series_cartesian[2].figure #hide
+
+# To modify this recipe, select other matrix coordinates with an explicit
+# observation request, pass native legend attributes, or mutate either returned
+# axis. `series_cartesian[1].axes[1].title[] = "Measured resistance"`
+# changes the live title without asking LineCableModels to rebuild anything.
+
+# ### Cartesian shunt admittance
+
+# Conductance and susceptance are the real and imaginary parts of ``Y``. They
+# use the same dashboard implementation, retained units, limits, legend
+# groups, and controls as impedance. Only the scientific requests differ.
+
+## A bottom dock is horizontal by default and remains a native Makie Legend.
+shunt_cartesian = Makie.plot(
+ parameters,
+ @observe Y[1, 1, :];
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ xscale = :log10,
+ fig_size = (900, 480),
+ panel_titles = Dict(:G => "Self conductance", :B => "Self susceptance")
+)
+shunt_cartesian[1].figure #hide
+#-
+shunt_cartesian[2].figure #hide
+
+# Any accepted Makie `Legend` keyword belongs in `legend_attributes`. For a
+# labeled source or comparison, moving the
+# block uses `legend_position = :inside`, `:left`, `:right`, `:top`, `:bottom`,
+# or a positive `(row, column)` dock coordinate. `(2, 2)` remains the plot
+# canvas. Native `halign` and `valign` also position inside legends.
+
+# ### Figure-wide and panel-scoped legends
+
+# A controlled comparison registers one group per result set, then exposes two
+# views of those groups. The figure legend spans every panel. A panel legend
+# filters the same source handles to one logical plot position. Hiding a global
+# result entry changes that source's visibility across both resistance coefficients.
+
+legend_scope_demo = Makie.plot(
+ parameters,
+ parameters,
+ @observe R[1, 1:2, :];
+ series_labels = ("reference", "result"),
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ xscale = :log10,
+ fig_size = (1100, 600),
+ figure_title = "Resistance dashboard",
+ panel_titles = ("Self resistance", "Mutual resistance"),
+ legend_position = nothing
+)
+# A figure-scoped legend can be placed in any addon dock and given any native
+# Makie Legend attributes.
+figurelegend!(
+ legend_scope_demo;
+ position = :top,
+ title = "Result set",
+ legend_labels = Dict(
+ :result_1 => "baseline",
+ :result_2 => "alternative"
+ ),
+ orientation = :horizontal,
+ nbanks = 2,
+ max_fraction = 0.5
+)
+# Logical position `(1, 1)` is the resistance panel.
+panellegend!(
+ legend_scope_demo,
+ (1, 1);
+ position = :inside,
+ halign = :left, valign = :bottom,
+ title = "Resistance result",
+ legend_labels = ("base R", "alternative R"),
+ backgroundcolor = (:white, 0.92),
+ max_fraction = 0.5
+)
+# Titles use the same logical panel address as panel legends.
+figuretitle!(legend_scope_demo, "Resistance dashboard"; fontsize = 20)
+paneltitle!(legend_scope_demo, (1, 2), "Mutual resistance")
+legend_scope_demo.figure #hide
+
+# The same panel legends can be requested at construction time with
+# `panel_legends=Dict((1, 1) => (position=:inside, halign=:left, valign=:bottom,
+# legend_labels=("base R", "alternative R")))`. Runtime dictionaries target
+# stable source keys such as `:result_1`.
+
+# ### Derived R/L/G/C dashboard
+
+# `L` and `C` are observation-owned proxies derived from reactance or susceptance
+# and angular frequency. Their DC samples are retained as unavailable. Independently
+# valid components remain available. The observation owner assigns their physical
+# units. For this 2×2 result, `layout=(2,2)` requests one matrix dashboard per
+# physical quantity.
+
+## `layout` sets the initial panel grid. The returned Makie objects remain editable.
+rlgc_dashboards = Makie.plot(
+ parameters,
+ (R, L, G, C);
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ xscale = :log10,
+ layout = (2, 2),
+ fig_size = (950, 700),
+ legend_position = nothing
+)
+rlgc_dashboards[1].figure #hide
+rlgc_dashboards[2].figure #hide
+rlgc_dashboards[3].figure #hide
+rlgc_dashboards[4].figure #hide
+
+# With `layout=(1,1)`, each quantity and coordinate facet has its own figure.
+
+# ### Polar impedance and admittance
+
+# Function selectors are resolved through the same observation grammar.
+# `abs` and `angle` publish magnitude and phase for both ``Z`` and ``Y``.
+
+## The 4 transformed quantities become 4 matrix dashboards.
+polar_dashboards = Makie.plot(
+ parameters,
+ (abs, angle);
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ xscale = :log10,
+ layout = (2, 2),
+ fig_size = (950, 700),
+ legend_position = nothing
+)
+polar_dashboards[1].figure #hide
+polar_dashboards[2].figure #hide
+polar_dashboards[3].figure #hide
+polar_dashboards[4].figure #hide
+
+# `real`, `imag`, `Z`, and `Y` are also valid selectors. Implement additional
+# scientific transforms in the observation grammar.
+
+# ### Standalone series-impedance result
+
+# `SeriesImpedance` can be plotted before it is bundled into `LineParameters`.
+# Supply its frequency samples as an explicit positional argument. The same
+# native dashboard controls are available.
+
+standalone_impedance = Makie.plot(
+ parameters.Z,
+ parameters.f,
+ (Z, 1, 1, Colon());
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ xscale = :log10,
+ fig_size = (900, 440),
+ legend_cap = 0.5
+)
+standalone_impedance[1].figure #hide
+#-
+standalone_impedance[2].figure #hide
+
+# ### Standalone shunt-admittance result
+
+# `ShuntAdmittance` follows the same rule: frequencies are explicit and its
+# default scientific family is `(G, B)`.
+
+standalone_admittance = Makie.plot(
+ parameters.Y,
+ parameters.f,
+ (Y, 1, 1, Colon());
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ xscale = :log10,
+ fig_size = (900, 440),
+ legend_cap = 0.5
+)
+standalone_admittance[1].figure #hide
+#-
+standalone_admittance[2].figure #hide
+
+# ### Exact observation requests and modal coordinates
+
+# An `@observe` request is the precise extension point for matrix coordinates.
+# It passes through `Commons.observation_request`, so selection rules stay shared
+# by plotting, tables, and reports. This example keeps only one diagonal entry
+# from each quantity.
+
+resistance_request = @observe R[1, 1, :];
+inductance_request = @observe L[1, 1, :];
+selected_response = Makie.plot(
+ parameters,
+ (resistance_request, inductance_request);
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ xscale = :log10,
+ fig_size = (900, 420),
+ legend_cap = 0.5
+)
+selected_response[1].figure #hide
+#-
+selected_response[2].figure #hide
+
+# ### An explicit capacity for one selected coefficient
+#
+# The previous call uses an automatic 1×1 reference for each quantity. This call
+# intentionally uses a two-column reference. Its one selected panel keeps the
+# corresponding cell size. The returned figure fits around that panel.
+
+capacity_example = Makie.plot(
+ parameters, resistance_request;
+ backend = :cairo, display_plot = false, controls = false,
+ layout = (1, 2), fig_size = (900, 420)
+)
+capacity_example.figure #hide
+
+# ### Family requests and a vertical capacity
+
+# The observation owner expands `Z[1,1,:]` into retained R and X products.
+# `layout=(2,1)` gives each quantity its own nominal two-row capacity. It never
+# puts different quantities into the same figure. Only selected original panels
+# are drawn, and unselected exterior rows are not allocated.
+
+self_impedance_request = @observe Z[1, 1, :];
+stacked_self_impedance = Makie.plot(
+ parameters,
+ self_impedance_request;
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ xscale = :log10,
+ layout = (2, 1), # two-row nominal capacity per quantity
+ panel_titles = ("Self resistance", "Self reactance"),
+ legend_position = :inside, # native overlay. Use any outer dock instead
+ legend_attributes = (halign = :right, valign = :top),
+ legend_title = "Result set",
+ series_labels = ("solution",), # explicitly opt into a one-source legend
+ legend_cap = 0.5,
+ fig_size = (720, 720)
+)
+stacked_self_impedance[1].figure #hide
+#-
+stacked_self_impedance[2].figure #hide
+
+# With the default matrix arrangement, select more coordinates to add panels,
+# more result containers to add curves or more quantities to add figure families. `overlay=:coordinates` instead makes selected coordinates the
+# curves and selected gridpoints the panels.
+
+# Modal results retain their physical domain. Use `diag` to select the diagonal
+# coefficients, as in the following inductance plot.
+
+modal_parameters = compute(
+ ModalAnalysisProblem(parameters),
+ ModalAnalysisFormulation(:default);
+ options = (offdiagonal_tolerance = 1.0,)
+);
+modal_inductance = Makie.plot(
+ modal_parameters,
+ (@observe((L, diag)[:, :]),);
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ xscale = :log10,
+ fig_size = (820, 430),
+ legend_cap = 0.5
+)
+modal_inductance.figure #hide
+
+# Extend coordinate behavior in the grammar and domain owner. Extend only visual
+# appearance by mutating the returned axes or forwarding Makie plot attributes.
+
+# ### Measurement uncertainty
+
+# Uncertain retained values draw native intervals around their nominal curves.
+# Full uncertainty support participates in limits even when intervals are sparse.
+# `ObservedResult` classifies engineering zero using absolute nominal magnitude
+# and owner-defined cutoffs. With `clip=true`, values within those cutoffs
+# become exact zero with zero uncertainty. Phase unavailability and
+# linked X/L or B/C thresholds are resolved when observations are acquired.
+# Plotting uses the resulting values and the stored RMS errors directly.
+# `clip=false` and `atol` are raw acquisition options. Compatible display units
+# also work directly on retained inputs: `plot(observed; length_unit=:base)`.
+# Plotting delegates unit conversion to ObservedResult, preserving the recorded
+# clipping decisions and saved RMS values and units.
+
+# Legend actions hide or restore the nominal line, its markers and its x/y
+# error bars together. Figure legends act across the figure. Panel legends act
+# only on their panel. This also applies to overlays and report illustrations.
+# Native error-bar handles remain independently editable. The next action to
+# hide or show the series restores the complete set of components.
+
+measured_parameters = LineParameters(
+ complex.(
+ measurement.(resistance, 0.05 .* resistance),
+ measurement.(inductance .* angular_frequency,
+ 0.05 .* inductance .* angular_frequency)
+ ),
+ complex.(
+ measurement.(conductance, abs.(0.05 .* conductance)),
+ measurement.(capacitance .* angular_frequency,
+ abs.(0.05 .* capacitance .* angular_frequency))
+ ),
+ frequency
+);
+uncertainty_plot = Makie.plot(
+ measured_parameters,
+ (@observe(R[1, 1, :]), @observe(L[1, 1, :]));
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ xscale = :log10,
+ fig_size = (900, 440),
+ legend_cap = 0.5
+)
+uncertainty_plot[1].figure #hide
+#-
+uncertainty_plot[2].figure #hide
+
+# The scalar type defines how to extract nominal values and errors for plotting.
+
+# ### Comparing completed line results
+
+# A named tuple supplies both completed results and default legend labels. The
+# observation owner validates usable units and coordinates. Plotting overlays sources in one axis for every selected matrix position. Each
+# result may retain its own frequency samples.
+
+result = LineParameters(
+ parameters.Z.values .* (1.06 + 0im),
+ parameters.Y.values .* (0.94 + 0im),
+ parameters.f
+);
+comparison_plot = Makie.plot(
+ (; reference = parameters, result),
+ (R,);
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ xscale = :log10,
+ layout = (2, 2),
+ fig_size = (900, 700),
+ legend_position = :top,
+ legend_attributes = (; orientation = :horizontal),
+ legend_cap = 0.5
+)
+comparison_plot.figure #hide
+
+# The exact-request positional form is
+# `Makie.plot(reference, result, @observe(Z[1,1,:]);
+# series_labels=("reference", "result"))`.
+# Change line styling after construction through the native plot objects in each
+# axis. Change source identity through `series_labels` or named-tuple keys.
+
+# ### Detached observed results
+
+# `ObservedResult` retains complete primary representations and their original
+# matrix coordinates. The existing matrix renderer selects these records for
+# display. Report illustrations use the same observed-input plotting method.
+
+observed = ObservedResult(
+ parameters,
+ (resistance_request, inductance_request)
+);
+observed_plot = Makie.plot(
+ observed;
+ ydata = (resistance_request, inductance_request),
+ title = "Retained coefficient observations",
+ figure_title = "Retained coefficient observations",
+ panel_titles = ("R[1,1]", "L[1,1]"),
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ layout = (1, 2),
+ fig_size = (900, 480),
+ legend_title = "Observed result",
+ ## R and L retain the same original coefficient in separate figures.
+ series_labels = ("self impedance",),
+ legend_position = :bottom,
+ legend_attributes = (; orientation = :horizontal),
+ legend_cap = 0.5
+)
+observed_plot[1].figure #hide
+#-
+observed_plot[2].figure #hide
+
+# A new primary result owner supplies observation methods. The same retained
+# quantity records reuse table and plot consumption.
+
+# ## Geometry preview recipes
+
+# ### Cable-design cross-section
+
+# `DataModel.preview_shapes` exposes detached physical polygons with only their
+# material and construction tag. The Makie preview adapter derives optional
+# presentation groups. `_preview_axis!` draws the polygons with native
+# `poly!` and locks the axis to `DataAspect`. It also computes
+# geometry limits. Preview `size` is an initial reference allocation. The shared
+# shell preserves physical frame sizes and view limits, then fits the window
+# around the panels and their complete decorations. A circle or sector row can
+# retain necessary space beside its shorter panel. A row of circles need not
+# retain unused vertical allocation. The same fitted native `.figure` is returned
+# for interactive display and documentation.
+
+## `display_id=true` promotes the design identifier into the panel title.
+## Preview scales form a horizontal strip below the plot by default,
+## with each property label on the left.
+cable_preview = preview(
+ mv_design;
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ display_id = true,
+ size = (950, 700),
+ ## Group the physical core-wire regions into one presentation entry.
+ ## Geometry tags, terminals, and electrical construction remain untouched.
+ legend_group = region -> region.source.tag === :wire ?
+ :stranded_core : region.source.tag,
+ legend_labels = Dict(:stranded_core => "Stranded core"),
+ legend_position = :right,
+ legend_attributes = (; nbanks = 2)
+)
+cable_preview.figure #hide
+
+# `legend_group` accepts either the shown callback over a `PlacedRegion` or a
+# tag-to-group dictionary. `legend_labels` maps the resulting presentation group
+# to text. This keeps display grouping independent of physical tags. For detailed
+# annotation, mutate `cable_preview.axes[1]` and add ordinary Makie plots. Each
+# object in `cable_preview.colorbars` is a native `Colorbar`.
+# All preview routes use `guide_gap=(8,8,24,8)` in left, right, bottom, top order.
+# The 24-pixel bottom clearance separates the axis label from the scale strip.
+# Pass a scalar or four-sided `guide_gap` to change that clearance.
+
+# ### Independent scale arrangement and guide spacing
+
+# Bar orientation, group cells, and placement are independent. This uses the
+# `mv_design` constructed above. Physical properties and material ranges remain unchanged.
+# The 3 horizontal bars form one row below the preview directly.
+
+design = mv_design
+scale_arrangement = preview(
+ design;
+ backend = :cairo, display_plot = false, controls = false,
+ size = (1200, 900),
+ legend_position = :right,
+ legend_attributes = (halign = :left, valign = :top),
+ colorbar_position = :bottom,
+ colorbar_attributes = (vertical = false, width = 160, height = 14),
+ colorbar_group_attributes = (layout = (1, 3), colgap = 16, halign = :center),
+ guide_spacing = (rowgap = 14, colgap = 16)
+)
+scale_arrangement.figure #hide
+
+# Move the same native objects to a column on the right. The legend-to-group
+# minimum of 14 logical pixels differs from the 12-pixel internal row gap.
+
+figurecolorbars!(scale_arrangement;
+ position = :right,
+ group_attributes = (layout = (3, 1), rowgap = 12, valign = :bottom),
+ vertical = false
+)
+figurelegend!(scale_arrangement; position = :right, valign = :top, guide_spacing = 14)
+scale_arrangement.figure #hide
+
+# Gaps separate complete visible siblings, including labels and endpoint ticks.
+# Bars in a column share their left and right edges. Bars in a row share their
+# baseline. Different label widths and fonts change the reserved decoration
+# space, not those alignments. `halign` moves the complete group once.
+# Figure padding, plot clearance (`guide_gap`), legend-entry spacing, and bar
+# dimensions have separate controls. Hidden scales keep their native handles
+# for restoration and release their occupied space.
+#
+# Omitted group settings survive live updates. `layout=nothing` restores one
+# row at top and bottom or one column at left and right and explicit side-grid slots.
+# Standalone scales in main content default to one column. Explicit capacity
+# fills row-major and omits unoccupied tracks within the colorbar group.
+# `rowgap=nothing` and `colgap=nothing` restore inheritance from `guide_spacing`.
+# A partial live `guide_spacing=(rowgap=18,)` preserves the current column gap.
+# Omitted constructor components use 12. Fractions and zero are valid, while
+# negative, nonfinite and Boolean gaps are rejected. Spacing is counted only
+# between neighbors, so one guide has zero exterior padding.
+
+# ### Cable-design collection
+
+# A collection preview repeats the same detached geometry path for every design
+# and assigns the caller-requested layout. The material ranges are aggregated
+# once. Each panel then uses comparable colors and one shared set of scales.
+# Insulating regions include sparse diagonal marks over their existing material
+# colors. Pass `display_dielectric_pattern=false` to omit these marks in a
+# design, collection, or system preview. Semicon and conductor fills retain
+# their own material colors.
+
+design_collection = preview(
+ [mv_design, hv_design, hv_design, mv_design];
+ layout = (2, 2),
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ size = (1000, 850),
+ figure_title = "Cable design family",
+ panel_titles = ("MV option A", "HV option A", "HV option B", "MV option B")
+)
+design_collection.figure #hide
+
+# Use any sufficient `(rows, columns)` layout. The default omits layer legends.
+# Request selected local legends with `panel_legends`, using the same logical
+# grid-position rules as line dashboards.
+
+# ### Cable-system cross-section
+
+# A system preview resolves each placed region into the system frame and adds reference geometry such as the earth interface. Limits are derived from the physical placement or `zoom_factor`. Earth properties use the same atomic
+# color-scheme rules as cable materials, with a separate logarithmic
+# resistivity palette: slate at 0.1 Ω·m, taupe at 100 Ω·m, and ochre at 10⁴ Ω·m.
+# Horizontal earth fills follow pan, zoom and figure resizing while interfaces
+# stay at their physical depths. A semi-infinite basement covers the remainder
+# of the view. A finite final layer retains its declared bottom.
+
+system_preview = preview(
+ cable_system;
+ earth_model = earth,
+ zoom_factor = 1.35,
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ display_id = true,
+ size = (1000, 700),
+ legend_position = :right
+)
+system_preview.figure #hide
+
+# Modify physical contents before previewing. Modify visual annotations after
+# previewing. `zoom_factor` changes only the initial view, and the reset control
+# returns to those computed limits.
+# The light blue sky fades from the upper axis limit to transparency at `z=0`,
+# stretching with the view and disappearing in entirely underground views.
+# It is a visual cue with no material-property meaning. Use
+# `display_surface_gradient=false` to disable it independently of earth colors.
+# Vertical strata are currently not rendered in the system preview.
+
+# ## Material colors and native colorbars
+
+# ### One reusable material scheme
+
+# A color scheme is the atom. `material_property_ranges` obtains values from a
+# design, a design collection, or the material defaults. `materialcolors`
+# turns exactly one property and range into a Makie-compatible named tuple:
+# `label`, `colormap`, `limits`, and `ticks`. `materialscale!` places exactly
+# that one scheme wherever the caller chooses.
+
+material_ranges = LineCableModels.DataModel.material_property_ranges(mv_design);
+rho_scheme = materialcolors(
+ :rho,
+ material_ranges.rho
+);
+rho_scale_figure = Figure(size = (850, 180), figure_padding = 24)
+materialscale!(
+ rho_scale_figure[1, 1],
+ rho_scheme;
+ vertical = false,
+ width = Relative(0.85)
+)
+rho_scale_figure
+
+# The scheme contains no placement. Put that `Colorbar` in any `GridPosition`,
+# combine it with a heatmap, or reuse the same scheme in another figure. Define a
+# new property by defining another palette producer next to `materialcolors`.
+# Physical range collection remains a DataModel concern.
+# Relative permeability uses the existing logarithmic range from 1 to 300.
+# Unity leaves the base color unchanged. Indigo tint becomes visible before
+# the progression toward magenta at high permeability. The permeability scale
+# shows that same tint over a neutral reference color.
+# Dielectric marks are native Makie pattern tiles. Cairo embeds the small
+# bitmap tiles in SVG/PDF output while preserving the surrounding geometry.
+
+# ### High-level material-scale reference
+
+# `show_material_scale` is a preview option that combines the three
+# default property schemes and returns three independent native colorbars.
+
+material_scale = show_material_scale(
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ size = (850, 360),
+ figure_title = "Reusable material-property schemes",
+ colorbar_attributes = (; vertical = false)
+)
+material_scale.figure #hide
+
+# For one property, use the preceding `materialscale!(position, scheme)` pattern.
+# For a different high-level combination, compose schemes in the caller's own
+# Makie layout.
+
+# ## Monte Carlo result recipes
+
+# Monte Carlo methods first publish the requested marginal from retained
+# samples, `HistogramDensity`, or both. Cable-constant requests accept `R`, `L`,
+# `C`, `G`, or `(selector, assembly)`. Matrix-valued line results require an
+# exact request such as `@observe R[1, 1, 3]`. Native Makie function identity
+# chooses the visual primitive.
+
+# ### Sample histogram
+
+# `Makie.hist` uses retained samples and calls native `hist!`. `bins` and
+# `normalization` retain their Makie meanings. Unit options are consumed while
+# publishing the marginal.
+
+sample_histogram = Makie.hist(
+ mc_result,
+ R;
+ bins = 12,
+ normalization = :pdf,
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ fig_size = (820, 400),
+ figure_title = "Retained Monte Carlo samples",
+ legend_cap = 0.5,
+ color = :steelblue
+)
+sample_histogram.figure #hide
+
+# Pass ordinary `hist!` attributes such as `color`, `strokewidth`, or
+# `transparency` through the remaining keywords. Use `(R, assembly)` when a
+# `CableConstants` result contains more than one assembly.
+
+# ### Retained-model probability density
+
+# `Makie.stairs` consumes the retained histogram model and calls native
+# `stairs!` with post-step edges.
+
+model_density = Makie.stairs(
+ mc_result,
+ R;
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ fig_size = (820, 400),
+ legend_cap = 0.5,
+ color = :darkorange,
+ linewidth = 3
+)
+model_density.figure #hide
+
+# With `bins=n`, a different binning is derived from retained samples. The
+# stored model is unchanged. Changing its bin count requires retained samples.
+# With `bins=nothing` (the default), the retained model is reused, or a model
+# is derived with automatic binning if only samples were retained. Constant
+# samples always produce one finite-width bin. Use native `stairs!` keywords
+# for appearance.
+
+# ### Empirical cumulative distribution
+
+# `Makie.ecdfplot` consumes retained samples and delegates the empirical curve
+# to Makie's `ecdfplot!` recipe.
+
+empirical_cdf = Makie.ecdfplot(
+ mc_result,
+ R;
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ fig_size = (820, 400),
+ legend_cap = 0.5,
+ color = :seagreen,
+ linewidth = 3
+)
+empirical_cdf.figure #hide
+
+# The returned axis can be combined with confidence bands or additional native
+# curves. The addon only defines the initial empirical series and its legend group.
+
+# ### Retained-model cumulative distribution
+
+# Raw `Makie.lines` first acquires the UQ-owned CDF product. Its observed method
+# draws the retained CDF coordinates with `lines!`.
+
+model_cdf = Makie.lines(
+ mc_result,
+ R;
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ fig_size = (820, 400),
+ legend_cap = 0.5,
+ color = :firebrick,
+ linewidth = 3
+)
+model_cdf.figure #hide
+
+# Increase visual resolution by extending the owner-side model grid settings. Add
+# purely visual reference curves directly to `model_cdf.axes[1]`.
+
+# ### Sample/model Q-Q plot
+
+# `Makie.qqplot` requests both retained products. UQ calculates matching quantile pairs for the native scatter plot. The identity reference line is optional.
+
+quantile_plot = Makie.qqplot(
+ mc_result,
+ R;
+ qqline = :identity,
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ fig_size = (820, 440),
+ panel_titles = ("Sample versus retained model",),
+ legend_title = "Q–Q elements",
+ legend_labels = ("sample quantiles", "identity reference"),
+ legend_position = :inside,
+ legend_attributes = (; halign = :left, valign = :top, backgroundcolor = (:white, 0.92)),
+ legend_cap = 0.5,
+ color = :purple,
+ markersize = 10
+)
+quantile_plot.figure #hide
+
+# Set `qqline=:none` to remove the reference. Other scatter attributes are
+# forwarded to `scatter!`. Data and units remain observation concerns.
+
+# ## Callable controls and retained assembly points
+
+# A widget callable receives the final live handle once per figure. Its native
+# callback uses the same status and frame-preserving shell operations as built-ins.
+# `controls=false` skips standard and custom widgets. Closing a display window
+# leaves the retained handle editable, exportable, and redisplayable.
+
+function add_reset_y!(p)
+ addwidget!((plot, cell) -> Button(cell; label = "Reset Y"), p, :reset_y;
+ event = button -> button.clicks,
+ callback = (plot, _) -> resetview!(plot; x = false, y = true),
+ success = "Y view reset")
+ nothing
+end
+widget_plot=Makie.plot(parameters; ydata = ((R, 1, 1, :),), layout = (1, 1),
+ backend = :cairo, display_plot = false, widgets = (add_reset_y!,));
+widget_plot.figure #hide
+
+# CableConstants supply categorical assembly coordinates. The first-seen union
+# below is core, sheath, screen. Each observation uses its own requested point.
+# Their intervals retain their original uncertainty meaning. Categorical points
+# are primary data, so decorative-marker sampling does not remove them.
+
+assembly_a=CableConstants([:core, :sheath], [1.0, 2.0] .* 1e-4, [2.0, 3.0] .* 1e-7,
+ [3.0, 4.0] .* 1e-10, [1.0, 2.0] .* 1e-9, 50.0);
+assembly_b=CableConstants([:core, :screen], [1.1, 2.2] .* 1e-4, [2.1, 3.1] .* 1e-7,
+ [3.1, 4.1] .* 1e-10, [1.1, 2.1] .* 1e-9, 50.0);
+assembly_plot=Makie.plot((first = assembly_a, second = assembly_b); ydata = (R,),
+ backend = :cairo, display_plot = false, controls = false);
+assembly_plot.figure #hide
+
+# ## Composing figures with `plotwindow`
+
+# ### A caller-owned 2×2 plot
+
+# `plotwindow` is the escape hatch when no high-level recipe is appropriate. It
+# creates the figure and controls, then passes its content `GridLayout` to the
+# callback. The callback uses normal Makie constructors. Afterward `plotwindow`
+# discovers the native axes and attaches shared numeric scale, reset and export
+# controls. Explicit `axis=(...)` overrides apply to callback-created axes.
+# Otherwise their native construction settings are retained.
+
+custom_dashboard = LineCableModels.plotwindow(
+ title = "Caller-owned diagnostics",
+ figure_title = "Four caller-owned Makie axes",
+ size = (900, 650),
+ backend = :cairo,
+ display_plot = false,
+ controls = false
+) do grid
+ for row in 1:2, column in 1:2
+
+ axis = Axis(
+ grid[row, column];
+ title = "Response $row,$column",
+ xlabel = "Frequency [Hz]",
+ ylabel = "Amplitude"
+ )
+ lines!(
+ axis,
+ frequency,
+ @. (row + column) * sin(log10(frequency));
+ color = Makie.wong_colors()[(row - 1) * 2 + column],
+ linewidth = 2
+ )
+ axis.xscale = log10
+ end
+end
+custom_dashboard.figure #hide
+
+# The callback supports nested layouts, `Axis3`, `Colorbar`, `Slider`, custom
+# Makie recipes, and arbitrary plot primitives. Use `Figure` directly for a composition that omits LineCableModels controls and export settings.
+
+# ## Plot controls and layout
+
+# ### Scientific axes, scale controls, and limits
+
+# Scientific recipes supply quantity and unit labels. PlotBuilder formats
+# every linear numeric axis, including previews, statistical plots, and `plotwindow`
+# axes: the displayed limits determine one engineering power-of-ten multiplier
+# (powers of three) in the axis label. Ticks show plain decimal mantissas. Tick density follows each data rectangle and the rendered
+# label size. A linear frequency view from 0 to 10 MHz can show ticks at
+# `0, 2, 4, 6, 8, 10` with the label `Frequency [Hz] ×10⁶`. The retained
+# frequencies are unchanged.
+# Zooming, panning, changing limits, and resetting keep the ticks and multiplier
+# synchronized. Native custom tick formatters or explicit tick labels override
+# automatic formatting. Setting the formatter back to `Makie.automatic` restores
+# it. Native tick positions persist across scale changes. Caller tick functions
+# and custom locator objects retain native formatting. Resetting the tick
+# attribute to `Makie.automatic` restores the shared locator.
+# Date/category axes and other native transforms retain Makie's own presentation.
+# These defaults use the native tick locators in Makie 0.24.11 or newer.
+# Positive and signed logarithmic views spanning less than two base-10 units
+# after transformation show decimal mantissas, with one engineering multiplier
+# when needed. Broader views show scientific tick labels without another axis
+# multiplier. Those labels retain the physical sign and any nonunit coefficient.
+# Zero is displayed as `0`. The signed-log reference determines the transform,
+# not the displayed units or the engineering multiplier. Both x and y use the
+# same tick-spacing rule, fitting actual label spacing after the coordinate transform.
+# The x/y toggles validate current visible data and uncertainty bounds before
+# changing the page. Native numeric `plotwindow` axes share these controls.
+# Automatic near-constant positive log ranges use modest multiplicative padding
+# (`c/1.05` to `c*1.05`, enlarged for uncertainty).
+#
+# Native Axis keywords such as `xticks`, `limits`, and `ytickformat`, and native
+# series attributes such as `linewidth` and `color` can be passed at construction.
+# Explicit `axis=(...)`, `figure=(...)` and per-series `series_attributes`
+# override shared defaults. Subsequent native mutations remain authoritative.
+# For names shared by Axis and a plot, the unqualified form targets Axis.
+# An explicit native `figure.size` overrides `fig_size`. Portrait sizes stay portrait.
+#
+# UQ benchmark plots with ordinary `ydata=(R,L,G,C)` overlay mean ±1 standard
+# deviation from retained moments. Explicit `(statistics,L,std)` requests show
+# the standard deviation alone. Request these two products in separate calls.
+# `uncertain(result, configuration)` returns the uncertainty-bearing core stored
+# during MC aggregation. This marginal representation retains each output's mean
+# and standard deviation. Joint output correlations require the retained samples.
+# Every eligible numeric route retains log controls for zero or negative support.
+# Adaptive logarithmic panels use a sign-preserving pseudo-log transform with
+# `log1p`/`expm1` evaluation: `sign(v)*log10(1+abs(v)/s)`. The reference `s` is
+# the smallest finite nonzero magnitude among eligible visible samples,
+# enabled uncertainty endpoints and explicit limits, in the displayed units.
+# If no nonzero value exists, `s=1`. Ticks retain physical values and units.
+# This transform lets small signed conductances span decades instead of
+# appearing linear.
+# Reapply `axisscale!(page, :y, :log10)` or switch log off/on to select the
+# reference from current data. Zooming and resetting limits keep it fixed.
+# Explicit `:pseudolog10` retains a reference of 1 in displayed units.
+# Switching log off selects a linear axis. Observation clipping is set during
+# acquisition. Use `clip=false` or component cutoffs such as `atol=(G=0., B=0.)`
+# when acquiring observations to preserve nonzero components. Retained
+# observations must be reconstructed from their primary result to undo clipping.
+#
+# Limits are calculated from finite visible data, including measurement error
+# bounds. Constant and near-constant series receive at least ±5% padding around
+# a nonzero baseline, enlarged for visible uncertainty. An exactly zero series
+# without uncertainty uses a neutral nonzero range. Near-constant means that the
+# endpoints agree within `sqrt(eps(Float64))` relatively in view coordinates.
+# These bounds affect the displayed view. The samples retain their original
+# values. Small values with measurable relative variation receive a tightly
+# fitted view. Legend visibility changes trigger another limit pass, so hiding
+# a dominant curve fits the view to the remaining data.
+# Explicit native limits, including one-sided limits, remain authoritative.
+# Reset refits automatic bounds and restores explicit bounds. Changing x/y scale
+# refits that automatic dimension while preserving the other dimension's current
+# view. Incompatible log limits are rejected before the page changes. Use native
+# `autolimits!(axis)` explicitly when manual bounds should be discarded.
+
+# ### Legends and docks
+
+# `legend_position` selects only placement. `:inside` overlays the union of the
+# figure's axis viewports or the one axis viewport for a panel legend.
+# Native `halign`/`valign` select corner, center, or fractional alignment. Side-grid slots
+# remain outside the plot area. `legend_attributes` is merged into the native
+# `Legend` constructor, so orientation, bank count, padding, background,
+# alignment, and other Makie options remain available. `legend_cap=0.5`
+# is a finite, non-Boolean real number in `(0,1]`. It caps top and bottom legend
+# height or side legend width relative to the associated data area. The other
+# dimension must also fit. Inside legends use the height cap. Figure legends
+# use the combined panel footprint. Panel legends use their own data area.
+# Entries that exceed the cap are replaced by `(...)` and return when resizing
+# provides room. If the title and ellipsis cannot fit, the legend remains hidden
+# until space returns. The cap includes padding and margins, and never removes
+# plotted curves. Use `max_fraction` with `figurelegend!`, `panellegend!`, or a
+# `panel_legends` configuration.
+# `figurelegend!` and `panellegend!` move retained native objects. Label changes
+# retain their source bindings, so placement, title, and legend labels can be
+# changed after construction. Clicking/toggling a grouped Makie legend entry
+# continues to affect every plot handle in that group.
+#
+# High-level recipes may put a legend and colorbars in the same dock. PlotBuilder
+# creates a nested `GridLayout` and gives each block its own cell. Resizing moves
+# the canvas and dock blocks together.
+
+# ### Colorbars
+
+# `colorbar_position` and `colorbar_attributes` mirror the legend placement
+# rules. `colorbar_group_attributes.layout` determines the independent group
+# arrangement. `guide_spacing` supplies minimum sibling gaps unless group
+# `rowgap`/`colgap` overrides them. A preview supplies several schemes, but
+# `_colorbar!` always consumes one scheme and creates one native
+# `Colorbar`. The reusable public atom remains
+# `materialcolors(property, range)` plus `materialscale!(position, scheme)`.
+
+# ### Observables and ownership
+
+# `@observe` selects a scientific quantity and its matrix and frequency
+# coordinates. Makie's `Observable` type drives live UI state: controls, scales,
+# limits, visibility, and layout bounds. The adapter renders the selected
+# observations and connects the native UI subscriptions.
+# Keeping the `UIPlot` alive keeps the native figure and its subscriptions alive.
+#
+# Native mutation is the normal extension mechanism:
+
+owned_plot = Makie.plot(
+ parameters,
+ @observe R[1, 1, :];
+ backend = :cairo,
+ display_plot = false,
+ controls = false,
+ xscale = :log10,
+ fig_size = (820, 400),
+ legend_cap = 0.5
+);
+owned_axis = only(owned_plot.axes);
+owned_axis.title[] = "Caller-owned resistance";
+vlines!(owned_axis, [100.0, 1_000.0]; color = :black, linestyle = :dash);
+owned_plot.figure #hide
+
+# ### Responsive layout
+
+# Automatic diagonal and preview flow pages may reflow locally when resized.
+# Their page membership and identities remain fixed. Explicit layouts and matrix
+# topology remain fixed. Physical aspect belongs to each panel, so circular designs,
+# wide systems, and 1×4 preview collections remain correctly scaled. A later guide,
+# title, or widget change refits only that window around its current data frames.
+
+# ### Current-state SVG export
+
+# Load CairoMakie before creating a plot to include its Save button. For GL
+# interactivity and SVG export, import both CairoMakie and GLMakie, then select
+# `backend=:gl`. Loading CairoMakie later enables direct export of an existing
+# plot. Recreate the plot to add its button. The toolbar displays
+# file errors in the status row. Direct `export_svg` calls throw them to the caller.
+#
+# [`export_svg`](@ref) saves the current live figure through CairoMakie. For a
+# publication export it temporarily hides the toolbar and status row, switches
+# the figure's font roles to Makie's LaTeX font theme, uses a white background,
+# and then restores every changed observable, preserving the active backend.
+# The SVG includes caller-added plots, visibility, scales, limits, and annotations.
+# Interactive zoom and pan are retained in both the SVG and the live window.
+
+export_directory = mktempdir();
+export_svg(
+ owned_plot;
+ path = joinpath(export_directory, "caller_owned_resistance.svg"),
+ theme = :publication,
+ open_file = false
+); #hide
+
+# ## Adding or changing a managed recipe
+
+# A new high-level recipe should keep these responsibilities with their owners:
+#
+# 1. Expose numerical observations or physical geometry through the public
+# methods of the module that defines them. Do not add plot-data construction, labels,
+# colors, layout, or Makie types to scientific owners.
+# 2. Add request normalization and the narrow public dispatch method in
+# `LineCableModelsMakieExt`, then call
+# native Makie constructors or primitives there.
+# 3. Reuse only the shared Makie functions the recipe needs: `_figure_layout`,
+# `_axis!`, `_finish_plot!`, or `plotwindow`. Do not create a second
+# plot specification or an optional adapter hierarchy.
+# 4. Return `UIPlot` with the actual native objects and leave further mutation to
+# the caller.
+# 5. Add the real call to this literate gallery. Add a Cairo rendering assertion and an interactive GL inspection fixture when resizing or widgets matter.
+#
+# A purely visual variation usually does not need a new managed recipe. Pass a native attribute or mutate the returned block. Add a Makie primitive, or start from
+# `plotwindow`. A new recipe is justified when LineCableModels implements application-specific
+# retained coordinate presentation, physical geometry, or a reusable piece
+# of scientific interaction.
+
+# ## Implementation
+
+# Scientific objects supply observations, geometry, material properties, and
+# units. `LineCableModelsMakieExt` converts these values into Makie plots.
+# Matrix coordinates identify subplots. Result containers identify overlaid
+# series. PlotBuilder adds labels, controls, and SVG export.
+#
+# | Implementation | Responsibility |
+# |:--|:--|
+# | `src/plotbuilder/` | Optional entry points and `UIPlot` |
+# | `ext/LineCableModelsMakieExt/recipes/line_data.jl` and `comparison_data.jl` | Select observations for plotting |
+# | `src/datamodel/preview/geometry.jl` and `materials.jl` | Geometry and material ranges |
+# | `ext/LineCableModelsMakieExt/recipes/preview_data.jl` | Extract geometry for drawing |
+# | `ext/LineCableModelsMakieExt/material_colors.jl` | Material palettes |
+# | `ext/LineCableModelsMakieExt/shell.jl` | Figure layout, axes, limits, and controls |
+# | `ext/LineCableModelsMakieExt/recipes/*_render.jl` | Draw declaration geometry |
+# | `ext/LineCableModelsMakieExt/layout.jl` and `guides.jl` | Capacity, native frames, and guide composition |
+# | `ext/LineCableModelsMakieExt/controls.jl` | Native widget ownership and common actions |
+# | `ext/LineCableModelsMakieExt/export_presentation.jl` | Temporary native export presentation and restoration |
+# | `ext/LineCableModelsMakieExt/montecarlo.jl` | Statistical plots |
+# | `ext/LineCableModelsMakieExt/native_export.jl` | SVG export |
diff --git a/docs/make.jl b/docs/make.jl
index 05b8db68a..0100e434e 100644
--- a/docs/make.jl
+++ b/docs/make.jl
@@ -1,204 +1,265 @@
using Documenter
using DocumenterCitations
+using CairoMakie
+using LineCableModels
using Literate
-using Pkg
-using Changelog
+using TOML
-function get_project_toml()
- # Get the current active environment (docs)
- docs_env = Pkg.project().path
+include("type_trees.jl")
+include("owned_doc_links.jl")
- # Path to the main project (one level up from docs)
- main_project_path = joinpath(dirname(docs_env), "..")
+const ROOT_DIR = normpath(joinpath(@__DIR__, ".."))
+const DOCS_SRC_DIR = joinpath(@__DIR__, "src")
+const REPOSITORY = "Electa-Git/LineCableModels.jl"
+const REPOSITORY_URL = "https://github.com/$(REPOSITORY)"
+const SITE_URL = "https://electa-git.github.io/LineCableModels.jl"
+const TUTORIAL_SOURCE = joinpath(ROOT_DIR, "examples")
+const TUTORIAL_OUTPUT = joinpath(DOCS_SRC_DIR, "tutorials")
+const PLOTTING_SOURCE = joinpath(@__DIR__, "literate", "plotting.jl")
- # Parse the main project's TOML
- project_toml = Pkg.TOML.parsefile(joinpath(main_project_path, "Project.toml"))
+const CONVENIENCE_API_OBJECTS = ()
- return project_toml
-end
+const EXTENSION_API_OBJECTS = (
+ LineCableModels.Commons.validate_observables,
+ LineCableModels.Commons.unit_targets,
+ LineCableModels.Commons.detach,
+ LineCableModels.Commons.observation_request,
+ LineCableModels.Commons.observation_indices,
+ LineCableModels.Commons.materialize_observation,
+ LineCableModels.Commons.request_identity,
+ LineCableModels.Commons.request_quantity,
+ LineCableModels.Commons.request_indices,
+ LineCableModels.ObservedResult,
+ LineCableModels.Commons.observation_quantity,
+ LineCableModels.Commons.observation_gridpoint,
+ LineCableModels.Commons.observation_groups,
+ LineCableModels.Units.family,
+ LineCableModels.DataModel.preview_shapes,
+ LineCableModels.DataModel.preview_materials,
+ LineCableModels.DataModel.PreviewShape,
+ LineCableModels.DataModel.material_property_ranges,
+ LineCableModels.materialcolors,
+ LineCableModels.materialscale!,
+ LineCableModels.Engine.has_uncertainty_type,
+ LineCableModels.materialize,
+ LineCableModels.ParametricBuilder.traverse,
+ LineCableModels.sample_uncertainty,
+ LineCableModels.UIPlot,
+ LineCableModels.plotwindow,
+ LineCableModels.export_svg,
+ LineCableModels.figurelegend!,
+ LineCableModels.panellegend!,
+ LineCableModels.figuretitle!,
+ LineCableModels.paneltitle!,
+ LineCableModels.figurecolorbars!,
+ LineCableModels.axisscale!,
+ LineCableModels.resetview!,
+ LineCableModels.addwidget!,
+ LineCableModels.removewidget!,
+ LineCableModels.ReportBuilder,
+ LineCableModels.ReportBuilder.AbstractReportDefinition,
+ LineCableModels.ReportBuilder.CableConstantsTableDefinition,
+ LineCableModels.ReportBuilder.LineParametersTableDefinition,
+ LineCableModels.ReportBuilder.BenchmarkTableDefinition,
+ LineCableModels.ReportBuilder.MonteCarloTableDefinition,
+ LineCableModels.ReportBuilder.select,
+ LineCableModels.ReportBuilder.tabulate,
+ LineCableModels.ReportBuilder.illustrate,
+ LineCableModels.ReportBuilder.encode,
+ LineCableModels.ReportBuilder.write,
+ LineCableModels.ReportBuilder.observation_columns,
+ LineCableModels.ReportBuilder.encode_cell,
+ LineCableModels.ReportBuilder.XLSXSheet,
+ LineCableModels.ReportBuilder.XLSXWorkbook,
+ LineCableModels.ImportExport.serialize_value,
+ LineCableModels.ImportExport.deserialize_value,
+ LineCableModels.ImportExport.deserialize_extension
+)
-function open_in_default_browser(url::AbstractString)::Bool
- try
- if Sys.isapple()
- Base.run(`open $url`)
- true
- elseif Sys.iswindows()
- Base.run(`powershell.exe Start "'$url'"`)
- true
- elseif Sys.islinux()
- Base.run(`xdg-open $url`, devnull, devnull, devnull)
- true
- else
- false
- end
- catch ex
- false
- end
+_contains_identity(collection, object) = any(entry -> entry === object, collection)
+function api_reference_entry(object)
+ !_contains_identity(CONVENIENCE_API_OBJECTS, object) &&
+ !_contains_identity(EXTENSION_API_OBJECTS, object)
end
+developer_reference_entry(object) = _contains_identity(EXTENSION_API_OBJECTS, object)
-
-
-# Get project data
-PROJECT_TOML = get_project_toml()
-PROJECT_VERSION = PROJECT_TOML["version"]
-NAME = PROJECT_TOML["name"]
-AUTHORS = join(PROJECT_TOML["authors"], ", ") * " and contributors."
-GITHUB = PROJECT_TOML["git_url"]
-
-@eval using $(Symbol(NAME))
-main_module = @eval $(Symbol(NAME))
-
-function customize_literate_footer(content, custom_footer="")
- if isempty(custom_footer)
- return replace(
- content,
- r"---\s*\n\*This page was generated using \[Literate\.jl\]\(.*?\)\.\*\s*$" => "",
- )
- else
- return replace(content,
- r"\*This page was generated using \[Literate\.jl\]\(.*?\)\.\*\s*$" =>
- custom_footer)
- end
+function project_metadata()
+ project = TOML.parsefile(joinpath(ROOT_DIR, "Project.toml"))
+ authors = get(project, "authors", String[])
+ return (
+ name = get(project, "name", "LineCableModels"),
+ version = get(project, "version", "dev"),
+ authors = isempty(authors) ? "LineCableModels contributors" : join(authors, ", ")
+ )
end
-function post_process_literate(content)
- content = customize_literate_footer(content, "🏠 Back to [Tutorials](@ref)\n")
- return content
+function strip_literate_footer(content::AbstractString)
+ return replace(
+ content,
+ r"(?ms)^---\s*\n\*This page was generated using \[Literate\.jl\]\(.*?\)\.\*\s*$" => "Back to [Tutorials](../tutorials.md)\n"
+ )
end
-tutorial_source = joinpath(@__DIR__, "..", "examples")
-tutorial_output = joinpath(@__DIR__, "src", "tutorials")
-# Remove the directory if it exists and then create it fresh
-if isdir(tutorial_output)
- rm(tutorial_output, recursive=true)
+normalize_literate_page(content::AbstractString) = rstrip(content) * "\n"
+
+function tutorial_title(path::AbstractString)
+ content = read(path, String)
+ matchobj = match(r"(?m)^#\s+(.+)$", content)
+ isnothing(matchobj) ||
+ return replace(String(matchobj.captures[1]), r"^#+\s*" => "")
+ stem = splitext(basename(path))[1]
+ return titlecase(replace(stem, "_" => " ", "-" => " "))
end
-mkpath(tutorial_output)
-for file in readdir(tutorial_source)
- if endswith(file, ".jl")
+function build_tutorials!()
+ rm(TUTORIAL_OUTPUT; recursive = true, force = true)
+ mkpath(TUTORIAL_OUTPUT)
+
+ for file in sort(readdir(TUTORIAL_SOURCE))
+ endswith(file, ".jl") || continue
Literate.markdown(
- joinpath(tutorial_source, file),
- tutorial_output,
- documenter=true,
- postprocess=content ->
- post_process_literate(
- content,
- ),
+ joinpath(TUTORIAL_SOURCE, file),
+ TUTORIAL_OUTPUT;
+ documenter = true,
+ postprocess = strip_literate_footer
)
end
-end
-
-# Get all .md files in tutorial_output
-tutorial_files = filter(
- file -> endswith(file, ".md") && file != "index.md",
- readdir(tutorial_output),
-)
-# Build menu from existing files only
-tutorial_menu = ["Contents" => "tutorials.md"]
-for file in tutorial_files
- relative_path = String(joinpath("tutorials", file)) # Convert to full String
- # Get title from file content
- content = read(joinpath(tutorial_output, file), String)
- m = match(r"#\s+(.*)", content)
- # Make sure title is a full String too, not SubString
- if m !== nothing
- title = String(m.captures[1])
- else
- title = String(titlecase(replace(basename(file)[1:end-3], "_" => " ")))
- end
- push!(tutorial_menu, title => relative_path)
+ files = sort(filter(file -> endswith(file, ".md"), readdir(TUTORIAL_OUTPUT)))
+ return [tutorial_title(joinpath(TUTORIAL_OUTPUT, file)) => joinpath("tutorials", file)
+ for
+ file in files]
end
-tutorial_pages = [String(joinpath("tutorials", file)) for file in tutorial_files]
+function generate_maintained_pages!()
+ Literate.markdown(
+ PLOTTING_SOURCE,
+ DOCS_SRC_DIR;
+ documenter = true,
+ credit = false,
+ postprocess = normalize_literate_page
+ )
+ return nothing
+end
-bib = CitationBibliography(
- joinpath(@__DIR__, "src", "refs.bib"),
- style=:numeric, # default
-)
+metadata = project_metadata()
+tutorials = build_tutorials!()
+tutorial_pages = last.(tutorials)
+generate_maintained_pages!()
DocMeta.setdocmeta!(
- main_module,
+ LineCableModels,
:DocTestSetup,
- :(using $(Symbol(NAME)));
- recursive=true,
+ quote
+ using LineCableModels
+ using LineCableModels.DataModel.BaseParams
+ end;
+ recursive = true
)
-mathengine = MathJax3(
- Dict(
- :loader => Dict("load" => ["[tex]/physics"]),
- :tex => Dict(
- "inlineMath" => [["\$", "\$"], ["\\(", "\\)"]],
- "tags" => "ams",
- "packages" => ["base", "ams", "autoload", "physics"],
- ),
- :chtml => Dict(
- :scale => 1.1,
- ),
- ),
-)
-
-Changelog.generate(
- Changelog.Documenter(), # output type
- joinpath(@__DIR__, "..", "CHANGELOG.md"), # input file
- joinpath(@__DIR__, "src", "CHANGELOG.md"); # output file
- repo="Electa-Git/LineCableModels.jl", # default repository for links
-)
-
-todo_src = joinpath(@__DIR__, "..", "TODO.md")
-todo_dest = joinpath(@__DIR__, "src", "TODO.md")
-cp(todo_src, todo_dest, force=true)
+bibliography = CitationBibliography(joinpath(DOCS_SRC_DIR, "bibliography.bib"); style = :numeric)
+owned_doc_links = OwnedDocLinks.OwnedDocLinker(LineCableModels)
makedocs(;
- modules=[main_module],
- authors="Amauri Martins",
- sitename="$NAME.jl",
- format=Documenter.HTML(;
- mathengine=mathengine,
- edit_link="main",
- assets=[
+ modules = [LineCableModels],
+ authors = metadata.authors,
+ sitename = "$(metadata.name).jl",
+ format = Documenter.HTML(;
+ canonical = SITE_URL,
+ edit_link = "main",
+ assets = [
"assets/citations.css",
"assets/favicon.ico",
"assets/custom.css",
- "assets/custom.js",
+ "assets/custom.js"
],
- prettyurls=get(ENV, "CI", "false") == "true",
- ansicolor=true,
- collapselevel=1,
- footer="[$NAME.jl]($GITHUB) v$PROJECT_VERSION supported by the Etch Competence Hub of EnergyVille, financed by the Flemish Government.",
- size_threshold=nothing,
+ mathengine = MathJax3(
+ Dict(
+ :loader => Dict("load" => ["[tex]/physics"]),
+ :tex => Dict(
+ "inlineMath" => [["\$", "\$"], ["\\(", "\\)"]],
+ "tags" => "ams",
+ "packages" => ["base", "ams", "autoload", "physics"]
+ ),
+ :chtml => Dict(:scale => 1.1)
+ ),
+ ),
+ prettyurls = get(ENV, "CI", "false") == "true",
+ footer = "[$(metadata.name).jl]($(REPOSITORY_URL)) v$(metadata.version) supported by the Etch Competence Hub of EnergyVille, financed by the Flemish Government.",
+ size_threshold_warn = 700 * 1024,
+ size_threshold = 1024 * 1024
),
- pages=[
+ pages = [
"Home" => "index.md",
- "Tutorials" => tutorial_menu,
+ "Theory" => Any[
+ "Contents" => "theory/contents.md",
+ "Matrix formulation" => "theory/matrix_formulation.md",
+ "Modal decomposition" => Any[
+ "Overview" => "theory/modal_decomposition.md",
+ "Default modal decomposition and eigenvalue tracking" => "theory/modal-decomposition/default.md"
+ ],
+ "Earth properties" => Any[
+ "Overview" => "theory/earth_properties.md",
+ "Default frequency-dependent earth material" => "theory/earth-properties/frequency-dependent/default.md",
+ "Registered frequency-dependent soil relations" => "theory/earth-properties/frequency-dependent/formulas.md",
+ "Default equivalent homogeneous-earth rule" => "theory/earth-properties/equivalent-homogeneous/default.md"
+ ],
+ "Earth return admittance" => Any[
+ "Overview" => "theory/earth_return_admittance.md",
+ "Pollaczek underground earth-return admittance" => "theory/external-admittance/1926/homogeneous-earth-generalized-induction-green-function/Pollaczek1926.md",
+ "Wise homogeneous-earth overhead potential coefficient" => "theory/external-admittance/1948/homogeneous-earth-overhead-potential-coefficient/Wise1948.md",
+ "Xue underground earth-return admittance" => "theory/external-admittance/2018/complete-field-and-quasi-tem-underground/Xue2018.md"
+ ],
+ "Earth return impedance" => Any[
+ "Overview" => "theory/earth_return_impedance.md",
+ "Carson homogeneous-earth overhead correction integral" => "theory/external-impedance/1926/homogeneous-earth-overhead-integral/Carson1926.md",
+ "Pollaczek generalized induction coefficients" => "theory/external-impedance/1926/homogeneous-earth-generalized-induction-green-function/Pollaczek1926.md",
+ "Wise high-frequency overhead displacement-current integral" => "theory/external-impedance/1934/homogeneous-earth-overhead-displacement-current-integral/Wise1934.md",
+ "Xue underground earth-return impedance" => "theory/external-impedance/2018/complete-field-and-quasi-tem-underground/Xue2018.md",
+ "Ametani mixed-pair exponential-image approximation" => "theory/external-impedance/2009/homogeneous-earth-mixed-exponential-image/Ametani2009.md"
+ ],
+ "Insulation parameters" => Any[
+ "Overview" => "theory/insulation_parameters.md",
+ "Default cable-insulation admittivity" => "theory/insulation-admittance/default.md",
+ "Lossless cable-layer admittivity" => "theory/insulation-admittance/lossless.md",
+ "Lossy cable-layer admittivity (Ametani 2004 application)" => "theory/insulation-admittance/2004/semiconducting-screen-complex-permittivity/Ametani2004.md",
+ "Default coaxial-insulation magnetic series impedance" => "theory/insulation-impedance/default.md"
+ ],
+ "Internal impedance" => Any[
+ "Overview" => "theory/internal_impedance.md",
+ "Default cylindrical-conductor surface impedances" => "theory/internal-impedance/default.md",
+ "Default analytical pipe-type treatment" => "theory/internal-impedance/pipe-default.md"
+ ]
+ ],
+ "Tutorials" => Any["Contents" => "tutorials.md", tutorials...],
+ "User guide" => Any[
+ "Cable data model" => "data-model.md",
+ "Modeling and results" => "usage.md",
+ "Modal analysis" => "modal_analysis.md",
+ "Gmsh/GetDP FEM backend" => "fem.md",
+ "Gridspace and uncertainty" => "gridspace.md"
+ ],
"API reference" => "reference.md",
- "Development" => Any[
- "Validation module"=>"validation.md",
- "Docstrings"=>"docstrings.md",
- "TODO"=>"TODO.md",
- "Changelog"=>"CHANGELOG.md",
+ "Conveniences" => Any[
+ "Overview" => "conveniences.md",
+ "Data entry validation" => "validation.md"
+ ],
+ "Developers" => Any[
+ "Commons invariants" => "developers.md",
+ "Extension API" => "extensions.md",
+ "Conventions" => "conventions.md",
+ "Computational engine" => "engine.md",
+ "Makie plotting" => "plotting.md",
+ "Contributing" => "contributing.md"
],
- "Bibliography" => "bib.md",
+ "Bibliography" => "bibliography.md"
],
- clean=true,
- plugins=[bib],
- checkdocs=:exports,
- pagesonly=true,
- warnonly=true,
+ clean = true,
+ plugins = [bibliography, owned_doc_links],
+ checkdocs = :exports,
+ pagesonly = true
)
-if haskey(ENV, "CI")
- deploydocs(
- repo="github.com/Electa-Git/LineCableModels.jl.git",
- devbranch="main",
- versions=["stable" => "v^", "dev" => "main"],
- branch="gh-pages",
- )
-else
- open_in_default_browser(
- "file://$(abspath(joinpath(@__DIR__, "build", "index.html")))",
- ) ||
- println("Failed to open the documentation in the browser.")
-end
-@info "Finished docs build." # Good to know the script completed
+owned_doc_links.linked > 0 || error("owned documentation linker did not process any names")
+@info "Finished documentation build."
diff --git a/docs/owned_doc_links.jl b/docs/owned_doc_links.jl
new file mode 100644
index 000000000..d1fc24065
--- /dev/null
+++ b/docs/owned_doc_links.jl
@@ -0,0 +1,151 @@
+module OwnedDocLinks
+
+import Documenter
+
+const MarkdownAST = Documenter.MarkdownAST
+
+export OwnedDocLinker
+
+"""
+ OwnedDocLinker(roots...)
+
+Turn unlinked inline-code names owned by `roots` into ordinary Documenter `@ref`
+links. Only bare or module-qualified names that Documenter has included in the
+current build are linked. Existing links, code snippets, values, missing names,
+and ambiguous names are left unchanged.
+"""
+mutable struct OwnedDocLinker <: Documenter.Plugin
+ roots::Vector{Module}
+ linked::Int
+
+ function OwnedDocLinker(roots::Module...)
+ isempty(roots) && throw(ArgumentError("at least one owning module is required"))
+ return new(collect(roots), 0)
+ end
+end
+
+abstract type LinkOwnedDocNames <: Documenter.Builder.DocumentPipeline end
+
+# Template expansion registers the docstring inventory. CrossReferences then
+# resolves the @ref nodes created here using Documenter's normal machinery.
+Documenter.Selectors.order(::Type{LinkOwnedDocNames}) = 2.9
+
+function Documenter.Selectors.runner(
+ ::Type{LinkOwnedDocNames}, doc::Documenter.Document)
+ linker = get(doc.plugins, OwnedDocLinker, nothing)
+ linker === nothing && return
+ Documenter.is_doctest_only(doc, "LinkOwnedDocNames") && return
+
+ linker.linked = 0
+ for page in values(doc.blueprint.pages)
+ meta = copy(doc.user.meta)
+ link_owned_names!(page.mdast, meta, doc, linker)
+ end
+ @info "LinkOwnedDocNames: linked $(linker.linked) owned inline-code references."
+ return
+end
+
+function link_owned_names!(node, meta, doc, linker)
+ element = node.element
+
+ if element isa Documenter.MetaNode
+ merge!(meta, element.dict)
+ elseif element isa Documenter.DocsNode
+ for (docstring, docmeta) in zip(element.mdasts, element.metas)
+ docstring_meta = copy(meta)
+ module_ = get(docmeta, :module, nothing)
+ isnothing(module_) || (docstring_meta[:CurrentModule] = module_)
+ link_owned_names!(docstring, docstring_meta, doc, linker)
+ end
+ return
+ elseif element isa Union{MarkdownAST.Link, MarkdownAST.Image}
+ # Existing links and image descriptions must never acquire nested links.
+ return
+ elseif element isa MarkdownAST.Code
+ code = element.code
+ target = documented_owned_target(code, meta, doc, linker.roots)
+ if target !== nothing
+ node.element = MarkdownAST.Link("@ref $target", "")
+ push!(node.children, MarkdownAST.Node(element))
+ linker.linked += 1
+ end
+ return
+ end
+
+ for child in node.children
+ link_owned_names!(child, meta, doc, linker)
+ end
+ return
+end
+
+function is_name(code::AbstractString)
+ expression = try
+ Meta.parse(code)
+ catch
+ return false
+ end
+ return is_name(expression)
+end
+
+is_name(::Symbol) = true
+function is_name(expression::Expr)
+ Meta.isexpr(expression, :., 2) || return false
+ field = expression.args[2]
+ return is_name(expression.args[1]) &&
+ field isa QuoteNode && field.value isa Symbol
+end
+is_name(_) = false
+
+function documented_owned_target(code, meta, doc, roots)
+ is_name(code) || return nothing
+ expression = Meta.parse(code)
+ current_module = get(meta, :CurrentModule, Main)
+ modules = current_module === Main ? (Main,) : (current_module, Main)
+
+ for module_ in (modules..., roots...)
+ binding = try
+ Documenter.DocSystem.binding(module_, expression)
+ catch
+ continue
+ end
+ object = Documenter.find_object(doc, binding, Union{})
+ object === nothing && continue
+ is_owned(object.binding, roots) || continue
+ return Documenter.bindingstring(object.binding)
+ end
+
+ # Public-but-unexported names are common in this package. A bare name is
+ # still safe to link when the generated inventory has exactly one owned
+ # binding with that name. Duplicate names remain deliberately unlinked.
+ expression isa Symbol || return nothing
+ targets = Set{String}()
+ for binding in keys(doc.internal.bindings)
+ binding.var === expression || continue
+ object = Documenter.find_object(doc, binding, Union{})
+ object === nothing && continue
+ is_owned(object.binding, roots) || continue
+ push!(targets, Documenter.bindingstring(object.binding))
+ end
+ return length(targets) == 1 ? only(targets) : nothing
+end
+
+function is_owned(binding, roots)
+ any(root -> is_descendant(binding.mod, root), roots) && return true
+
+ # A module's binding belongs to its parent, so handle the root module
+ # (and its documented submodules) explicitly.
+ isdefined(binding.mod, binding.var) || return false
+ value = getfield(binding.mod, binding.var)
+ return value isa Module && any(root -> is_descendant(value, root), roots)
+end
+
+function is_descendant(module_::Module, root::Module)
+ while true
+ module_ === root && return true
+ parent = parentmodule(module_)
+ parent === module_ && return false
+ module_ = parent
+ end
+end
+
+end
diff --git a/docs/package_stats.jl b/docs/package_stats.jl
new file mode 100644
index 000000000..a1eae9e6a
--- /dev/null
+++ b/docs/package_stats.jl
@@ -0,0 +1,166 @@
+using CairoMakie
+using CSV
+using Downloads
+using GeoInterface
+using GeoJSON
+
+const PACKAGE_UUID = "dffb7669-8cd4-4628-99dd-7694e356ba37"
+const LOG_URL = "https://julialang-logs.s3.amazonaws.com/public_outputs/current/package_requests_by_region.csv.gz"
+const LAND_URL = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_110m_land.geojson"
+const DEFAULT_OUTPUT = joinpath(@__DIR__, "src", "assets", "user-statistics.svg")
+
+# Julia package-server regions are operational regions, not user countries.
+const REGION_LOCATION = Dict(
+ "us-east" => (label = "US East", longitude = -77.0, latitude = 38.0,
+ offset = (12, 14), align = (:left, :bottom)),
+ "us-west" => (label = "US West", longitude = -122.0, latitude = 38.0,
+ offset = (-12, 14), align = (:right, :bottom)),
+ "eu-central" => (label = "EU Central", longitude = 10.0, latitude = 50.0,
+ offset = (0, 18), align = (:center, :bottom)),
+ "eu-north" => (label = "EU North", longitude = 18.0, latitude = 60.0,
+ offset = (0, 18), align = (:center, :bottom)),
+ "au" => (label = "Australia", longitude = 151.0, latitude = -33.0,
+ offset = (0, 18), align = (:center, :bottom)),
+ "jp" => (label = "Japan", longitude = 139.7, latitude = 35.7,
+ offset = (12, 14), align = (:left, :bottom)),
+ "in" => (label = "India", longitude = 77.2, latitude = 28.6,
+ offset = (0, 18), align = (:center, :bottom)),
+ "kr" => (label = "Korea", longitude = 127.0, latitude = 37.5,
+ offset = (-12, 14), align = (:right, :bottom)),
+ "sa" => (label = "South America", longitude = -46.6, latitude = -23.5,
+ offset = (0, 18), align = (:center, :bottom)),
+ "sg" => (label = "Singapore", longitude = 103.8, latitude = 1.3,
+ offset = (0, -18), align = (:center, :top)),
+ "cn-east" => (label = "China East", longitude = 121.5, latitude = 31.2,
+ offset = (-12, 14), align = (:right, :bottom)),
+ "cn-northeast" => (label = "China Northeast", longitude = 123.4, latitude = 41.8,
+ offset = (12, 14), align = (:left, :bottom)),
+ "cn-southeast" => (label = "China Southeast", longitude = 113.3, latitude = 23.1,
+ offset = (0, -18), align = (:center, :top))
+)
+
+function package_regions(path::AbstractString)
+ counts = Dict{String, Float64}()
+ minimum_date = nothing
+ maximum_date = nothing
+
+ for row in CSV.File(path)
+ ismissing(row.package_uuid) && continue
+ String(row.package_uuid) == PACKAGE_UUID || continue
+ ismissing(row.client_type) && continue
+ String(row.client_type) == "user" || continue
+ ismissing(row.region) && continue
+ region = String(row.region)
+ haskey(REGION_LOCATION, region) || continue
+ ismissing(row.request_addrs) && continue
+
+ counts[region] = get(counts, region, 0.0) + Float64(row.request_addrs)
+ if !ismissing(row.date_min)
+ minimum_date = isnothing(minimum_date) ? row.date_min :
+ min(minimum_date, row.date_min)
+ end
+ if !ismissing(row.date_max)
+ maximum_date = isnothing(maximum_date) ? row.date_max :
+ max(maximum_date, row.date_max)
+ end
+ end
+
+ rows = [(; region, request_addrs) for (region, request_addrs) in counts]
+ sort!(rows; by = row -> row.request_addrs, rev = true)
+ dates = (; minimum = minimum_date, maximum = maximum_date)
+ return first(rows, min(10, length(rows))), dates
+end
+
+function land_geometries(path::AbstractString)
+ collection = GeoJSON.read(read(path, String))
+ return GeoInterface.geometry.(collect(collection))
+end
+
+function draw_statistics(rows, dates, land_path::AbstractString, output::AbstractString)
+ figure = Figure(
+ size = (1050, 480),
+ figure_padding = 8,
+ backgroundcolor = RGBf(0.95, 0.97, 0.99)
+ )
+ axis = Axis(
+ figure[2, 1];
+ aspect = DataAspect(),
+ limits = (-180, 180, -60, 88),
+ width = 1000,
+ height = 395,
+ backgroundcolor = RGBf(0.68, 0.86, 0.97)
+ )
+ hidedecorations!(axis)
+ hidespines!(axis)
+ poly!(
+ axis,
+ land_geometries(land_path);
+ color = RGBf(0.96, 0.96, 0.93),
+ strokecolor = RGBf(0.68, 0.72, 0.73),
+ strokewidth = 0.5
+ )
+
+ if isempty(rows)
+ text!(axis, 0, 5;
+ text = "No public package-server usage recorded yet",
+ align = (:center, :center),
+ fontsize = 24,
+ color = RGBf(0.12, 0.18, 0.24))
+ period = "awaiting the first registered-package requests"
+ else
+ maximum_count = maximum(row.request_addrs for row in rows)
+ for row in rows
+ location = REGION_LOCATION[String(row.region)]
+ size = 14 + 32sqrt(row.request_addrs / maximum_count)
+ scatter!(axis, [location.longitude], [location.latitude];
+ markersize = size,
+ color = RGBAf(0.05, 0.29, 0.53, 0.78),
+ strokecolor = :white,
+ strokewidth = 1.5)
+ text!(axis, location.longitude, location.latitude;
+ text = "$(location.label) · $(round(Int, row.request_addrs))",
+ offset = location.offset,
+ align = location.align,
+ fontsize = 13,
+ color = RGBf(0.05, 0.10, 0.15))
+ end
+ period = "$(dates.minimum) to $(dates.maximum)"
+ end
+
+ Label(figure[1, 1], "LineCableModels.jl package users";
+ fontsize = 25,
+ font = :bold,
+ color = RGBf(0.05, 0.10, 0.15))
+ Label(figure[3, 1],
+ "Top Julia package-server regions · summed request addresses · $period";
+ fontsize = 13,
+ color = RGBf(0.22, 0.28, 0.34))
+ rowgap!(figure.layout, 3)
+ mkpath(dirname(output))
+ save(output, figure)
+ return output
+end
+
+function replace_statistics(rows, dates, land_path::AbstractString, output::AbstractString)
+ mkpath(dirname(output))
+ mktempdir(dirname(output)) do directory
+ staged = joinpath(directory, basename(output))
+ draw_statistics(rows, dates, land_path, staged)
+ mv(staged, output; force = true)
+ end
+ return output
+end
+
+function main(args = ARGS)
+ source = isempty(args) ? Downloads.download(LOG_URL) : abspath(args[1])
+ output = length(args) < 2 ? DEFAULT_OUTPUT : abspath(args[2])
+ land = length(args) < 3 ? Downloads.download(LAND_URL) : abspath(args[3])
+ rows, dates = package_regions(source)
+ result = replace_statistics(rows, dates, land, output)
+ @info "Generated package-server usage map" result regions = length(rows)
+ return result
+end
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ main()
+end
diff --git a/docs/src/CHANGELOG.md b/docs/src/CHANGELOG.md
deleted file mode 100644
index ed9a2d870..000000000
--- a/docs/src/CHANGELOG.md
+++ /dev/null
@@ -1,35 +0,0 @@
-```@meta
-EditURL = "https://github.com/Electa-Git/LineCableModels.jl/blob/master/CHANGELOG.md"
-```
-
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
-## [Unreleased]
-
-- /
-
-### Changed
-
-- Refactored CodeComponent structure and constructors.
-
-### Added
-
-- Included interface to run finite element simulations using [Onelab](https://onelab.info/).
-
-- Included basic coverage tests.
-
-- Included import/export of CableDesigns to JSON files.
-
-## [v0.1.0](https://github.com/Electa-Git/LineCableModels.jl/releases/tag/v0.1.0) - 2025-03-29
-
-### Added
-
-- First release. See [README.md](https://github.com/Electa-Git/LineCableModels.jl/blob/main/README.md) for more details.
-
-[Unreleased]: [https://github.com/Electa-Git/LineCableModels.jl/compare/v0.1.0...HEAD](https://github.com/Electa-Git/LineCableModels.jl/compare/v0.1.0...HEAD)
-[v0.1.0](https://github.com/Electa-Git/LineCableModels.jl/releases/tag/v0.1.0): [https://github.com/Electa-Git/LineCableModels.jl/releases/tag/v0.1.0](https://github.com/Electa-Git/LineCableModels.jl/releases/tag/v0.1.0)
diff --git a/docs/src/TODO.md b/docs/src/TODO.md
deleted file mode 100644
index e044b1574..000000000
--- a/docs/src/TODO.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# TODO for LineCableModels.jl
-
-This is a living document intended to track scientific development priorities and research directions for new features, methods and solutions to be included in the package.
-
-For bugs, features and implementation taks, the [Issues](https://github.com/Electa-Git/LineCableModels.jl/issues) page is used.
-
-## Wishlist
-
-- [ ] Pipe-type cables and MoM-SO implementation.
-
-## In progress
-
-- [ ] Implementation of frequency-dependent soil properties.
-- [ ] Development of novel formulations for cables composed of N concentrical layers, allowing for accurate representations of semiconductor materials.
-- [ ] Implementation of an interface to run finite element simulations using [Onelab](https://onelab.info/).
-
-## Done ✓
-
-- [x] Object-oriented data model for cables, conductors, insulations and materials.
diff --git a/docs/src/assets/cable_sc_armor_uncertainties.svg b/docs/src/assets/cable_sc_armor_uncertainties.svg
new file mode 100644
index 000000000..8e4b6235f
--- /dev/null
+++ b/docs/src/assets/cable_sc_armor_uncertainties.svg
@@ -0,0 +1,1329 @@
+
+
+
+
diff --git a/docs/src/assets/custom.css b/docs/src/assets/custom.css
index 94934f62a..f49604630 100644
--- a/docs/src/assets/custom.css
+++ b/docs/src/assets/custom.css
@@ -20,7 +20,7 @@
article#documenter-page img {
display: block !important;
- margin: auto !important;
+ margin: 0.75rem auto !important;
}
/* Improved scrollbar styling for code blocks rendered in Chrome */
@@ -60,7 +60,7 @@ table {
overflow-x: auto;
/* Use auto instead of scroll */
max-width: 100%;
- /* Ensure content doesn't exceed container */
+ /* Ensure content does not exceed its container */
}
/* Modern scrollbar styling using standard CSS */
@@ -147,4 +147,4 @@ table {
.data-frame {
scrollbar-width: thin;
scrollbar-color: #888 transparent;
-}
\ No newline at end of file
+}
diff --git a/docs/src/assets/custom.js b/docs/src/assets/custom.js
index 34f1e03e2..e060bb450 100644
--- a/docs/src/assets/custom.js
+++ b/docs/src/assets/custom.js
@@ -1,6 +1,8 @@
+// ```javascript
// document.addEventListener("DOMContentLoaded", function () {
// localStorage.setItem("documenter-theme", "catppuccin-mocha");
// });
+// ```
(function () {
const theme = "documenter-dark";
@@ -14,6 +16,7 @@
document.documentElement.setAttribute("data-theme", theme);
})();
+// ```javascript
// document.addEventListener("DOMContentLoaded", function () {
// document.querySelectorAll("html.theme--catppuccin-mocha a").forEach(el => {
// el.addEventListener("mouseover", function () {
@@ -21,9 +24,10 @@
// this.style.color = "#4493f8";
// }
// });
-
+//
// el.addEventListener("mouseout", function () {
// this.style.color = ""; // Resets to default when not hovered
// });
// });
-// });
\ No newline at end of file
+// });
+// ```
\ No newline at end of file
diff --git a/docs/src/assets/user-statistics.svg b/docs/src/assets/user-statistics.svg
new file mode 100644
index 000000000..b8a164994
--- /dev/null
+++ b/docs/src/assets/user-statistics.svg
@@ -0,0 +1,574 @@
+
+
diff --git a/docs/src/bibliography.bib b/docs/src/bibliography.bib
new file mode 100644
index 000000000..215b7bb07
--- /dev/null
+++ b/docs/src/bibliography.bib
@@ -0,0 +1,789 @@
+@misc{NIST:DLMF,
+key = {DLMF},
+title = {NIST Digital Library of Mathematical Functions},
+note = {Olver, F. W. J. and Olde Daalhuis, A. B. and Lozier, D. W. and Schneider, B. I. and Boisvert, R. F. and Clark, C. W. and Miller, B. R. and Saunders, B. V. and Cohl, H. S. and McClain, M. A., eds.},
+year = {2025},
+institution = {National Institute of Standards and Technology (NIST)},
+howpublished = {[{https://dlmf.nist.gov/}]({https://dlmf.nist.gov/}), Release 1.2.4 of 2025-03-15},
+url = {https://dlmf.nist.gov/},
+}
+
+@ARTICLE{6897971,
+ author={Vujević, Slavko and Lovrić, Dino and Boras, Vedran},
+ journal={IEEE Transactions on Electromagnetic Compatibility},
+ title={High-Accurate Numerical Computation of Internal Impedance of Cylindrical Conductors for Complex Arguments of Arbitrary Magnitude},
+ year={2014},
+ volume={56},
+ number={6},
+ pages={1431-1438},
+ keywords={Impedance;Conductors;Approximation methods;Skin effect;Mathematical model;Computational modeling;Large complex arguments;modified Bessel functions;solid cylindrical conductor;tubular cylindrical conductor;Large complex arguments;modified Bessel functions;solid cylindrical conductor;tubular cylindrical conductor},
+ doi={10.1109/TEMC.2014.2352398}}
+
+
+@ARTICLE{5437464,
+ author={Papadopoulos, Theofilos A. and Tsiamitros, Dimitrios A. and Papagiannis, Grigoris K.},
+ journal={IEEE Transactions on Power Delivery},
+ title={Impedances and Admittances of Underground Cables for the Homogeneous Earth Case},
+ year={2010},
+ volume={25},
+ number={2},
+ pages={961-969},
+ keywords={Impedance;Earth;Conductors;Admittance;Electromagnetic transients;Power system transients;Underground power cables;Electromagnetic propagation;Integral equations;Power cables;Earth return admittance;earth return impedance;electromagnetic transients;power cable modeling},
+ doi={10.1109/TPWRD.2009.2034797}
+}
+
+@ARTICLE{app14198982,
+ author = {Riba, Jordi-Roger},
+ title = {The Role of AC Resistance of Bare Stranded Conductors for Developing Dynamic Line Rating Approaches},
+ journal = {Applied Sciences},
+ volume = {14},
+ year = {2024},
+ number = {19},
+ article-number = {8982},
+ url = {https://www.mdpi.com/2076-3417/14/19/8982},
+ issn = {2076-3417},
+ doi = {10.3390/app14198982}
+}
+
+@ARTICLE{6521501,
+ author={Morgan, Vincent T.},
+ journal={IEEE Transactions on Power Delivery},
+ title={The Current Distribution, Resistance and Internal Inductance of Linear Power System Conductors—A Review of Explicit Equations},
+ year={2013},
+ volume={28},
+ number={3},
+ pages={1252-1262},
+ keywords={Conductors;Resistance;Inductance;Current density;Wires;Solids;Equations;Current density;eddy currents;hysteresis;internal inductance;power loss;proximity effect;resistance;skin effect;transformer effect},
+ doi={10.1109/TPWRD.2012.2213617}
+}
+
+@INPROCEEDINGS{yang2008gmr,
+ author = {Yang, Y. and Fortin, S. and Ma, J. and Dawalibi, F. P.},
+ title = {GMR of Stranded Multizone Conductors},
+ booktitle = {The 4th IASTED Asian Conference on Power and Energy Systems (AsiaPES)},
+ year = {2008},
+ keywords = {GMR, multizone, stranded conductor, strand pattern, current density, frequency dependence},
+ note = {[Link]({https://www.actapress.com/Abstract.aspx?paperId=33058})}
+}
+
+@INPROCEEDINGS{916943,
+ author={Gustavsen, B.},
+ booktitle={2001 IEEE Power Engineering Society Winter Meeting. Conference Proceedings (Cat. No.01CH37194)},
+ title={Panel session on data for modeling system transients insulated cables},
+ year={2001},
+ volume={2},
+ number={},
+ pages={718-723 vol.2},
+ keywords={Power cable insulation},
+ doi={10.1109/PESW.2001.916943}
+}
+
+@ARTICLE{1458878,
+ author={Gustavsen, B. and Martinez, J.A. and Durbak, D.},
+ journal={IEEE Transactions on Power Delivery},
+ title={Parameter determination for modeling system transients-Part II: Insulated cables},
+ year={2005},
+ volume={20},
+ number={3},
+ pages={2045-2050},
+ keywords={Cable insulation;Cables;Magnetic materials;Conducting materials;Material properties;Semiconductor materials;Frequency;Impedance;Shunt (electrical);Admittance;Insulated cables;modeling;power system transients;simulation},
+ doi={10.1109/TPWRD.2005.848774}
+}
+
+@ARTICLE{5743045,
+ author={Gudmundsdottir, Unnur Stella and Gustavsen, Bjørn and Bak, Claus Leth and Wiechowski, Wojciech},
+ journal={IEEE Transactions on Power Delivery},
+ title={Field Test and Simulation of a 400-kV Cross-Bonded Cable System},
+ year={2011},
+ volume={26},
+ number={3},
+ pages={1403-1410},
+ keywords={Power cables;Cable shielding;Current measurement;Voltage measurement;Conductors;Bonding;Grounding;Insulated cables;model accuracy;modeling;power system transients;proximity effect;simulation},
+ doi={10.1109/TPWRD.2010.2084600}
+}
+
+@ARTICLE{4113884,
+ author={Ametani, A.},
+ journal={IEEE Transactions on Power Apparatus and Systems},
+ title={A General Formulation of Impedance and Admittance of Cables},
+ year={1980},
+ volume={PAS-99},
+ number={3},
+ pages={902-910},
+ keywords={Impedance;Admittance;Coaxial cables;Transient analysis;Earth;Conductors;Voltage;Equations;Dielectric losses;Underwater cables},
+ doi={10.1109/TPAS.1980.319718}
+}
+
+@techreport{cigre345,
+ author = {{CIGRE Working Group B2.12}},
+ title = {Alternating Current (AC) Resistance of Helically Stranded Conductors},
+ institution = {CIGRE},
+ year = {2008},
+ month = {April},
+ number = {345},
+ type = {Technical Brochure},
+ note = {[Link]({https://www.e-cigre.org/publications/detail/345-alternating-current-ac-resistance-of-helically-stranded-conductors.html})}
+}
+
+@techreport{cigre531,
+ author = {{CIGRE Working Group B1.30}},
+ title = {Cable Systems Electrical Characteristics},
+ institution = {CIGRE},
+ year = {2013},
+ month = {April},
+ number = {531},
+ type = {Technical Brochure},
+ note = {[Link]({https://www.e-cigre.org/publications/detail/531-cable-systems-electrical-characteristics.html})}
+}
+
+@book{rosa1908,
+ title = {The self and mutual-inductances of linear conductors},
+ author = {Rosa, E. B.},
+ year = {1908},
+ publisher = {National Bureau of Standards},
+ volume = {4},
+ number = {2},
+ issn = {0096-8579},
+ doi = {10.6028/bulletin.088},
+}
+
+@ARTICLE{Karmokar2025,
+ author = {Karmokar, T. and Popov, M.},
+ title = {Enhanced Modelling and Parameter Determination of HVDC Cables Using Practice-Oriented Methodology},
+ year = {2025},
+ journal = {CIGRE Science and Engineering},
+ volume = {2025-February},
+ number = {36},
+ url = {https://www.scopus.com/inward/record.uri?eid=2-s2.0-85218793172&partnerID=40&md5=415867c192cfa930318607b68cd8cd69},
+ type = {Article},
+}
+
+@book{grover1981inductance,
+ title={Inductance Calculations: Working Formulas and Tables},
+ author={Grover, F.W. and Instrument Society of America},
+ isbn={9780876645574},
+ lccn={lc82126260},
+ series={Dover books on engineering and engineering physics},
+ year={1981},
+ publisher={Instrument Society of America}
+}
+
+@techreport{CENELEC50182,
+ type = {Standard},
+ key = {EN 50182:2001},
+ author = {Technical Committee CENELEC TC 7},
+ title = {Conductors for Overhead Lines---Round Wire Concentric Lay Stranded Conductors},
+ institution = {European Committee for Electrotechnical Standardization},
+ year = {2001},
+ isbn = {0 580 38001 7},
+ pages = {1--73},
+ note = {[Link]({https://www.en-standard.eu/bs-en-50182-2001-conductors-for-overhead-lines-round-wire-concentric-lay-stranded-conductors/})}
+}
+
+@ARTICLE{4389974,
+ author={Oussalah, Naima and Zebboudj, Youcef and Boggs, Steven A.},
+ journal={IEEE Electrical Insulation Magazine},
+ title={Partial Discharge Pulse Propagation in Shielded Power Cable and Implications for Detection Sensitivity},
+ year={2007},
+ volume={23},
+ number={6},
+ pages={5-10},
+ keywords={Partial discharges;Power cables;Attenuation measurement;Cable shielding;Frequency;Power measurement;Corona;Wideband;Material properties;Software tools},
+ doi={10.1109/MEI.2007.4389974}
+}
+
+@techreport{IEC60287,
+ title = {{IEC 60287} - Electric cables -- Calculation of the current rating},
+ author = {{IEC technical committee 20: Electric cables}},
+ institution = {{International Electrotechnical Commission}},
+ year = {2023},
+ type = {Standard},
+ note = {[Link]({https://webstore.iec.ch/publication/261})},
+}
+
+@techreport{CENELEC_HD620_S3_2023,
+ type = {Standard},
+ key = {HD 620 S3:2023},
+ author = {Technical Committee CENELEC TC 20},
+ title = {Distribution cables with extruded insulation for rated voltages from 3.6/6 (7.2) kV up to and including 20.8/36 (42) kV},
+ institution = {European Committee for Electrotechnical Standardization},
+ year = {2023},
+ isbn = {978-2-8322-6203-0},
+ pages = {1--150},
+ note = {[Link]({https://standards.iteh.ai/catalog/standards/clc/ac6f2d1f-5ce8-4580-8d64-f40ac82b7125/hd-620-s3-2023})}
+}
+
+@techreport{VDE_DIN_VDE_0276_620_2024,
+ type = {Standard},
+ key = {DIN VDE 0276-620:2024-12},
+ author = {Technical Committee VDE},
+ title = {Distribution cables with extruded insulation for rated voltages from 3.6/6 (7.2) kV up to and including 20.8/36 (42) kV},
+ institution = {VDE},
+ year = {2024},
+ isbn = {978-3-8007-6204-0},
+ pages = {1--120},
+ note = {[Link]({https://www.vde-verlag.de/standards/0200084/din-vde-0276-620-vde-0276-620-2024-12.html})}
+}
+@article{Ciuprina2024,
+ author = {Ciuprina, Gabriela and Sabriego, Ruth V.},
+ title = {Electric circuit element boundary conditions for electromagneto-quasistatic and full wave models in {A}, {$\varphi$} potentials and their finite element implementation},
+ journal = {Journal of Mathematics in Industry},
+ volume = {14},
+ number = {1},
+ pages = {27},
+ year = {2024},
+ doi = {10.1186/s13362-024-00165-6}
+}
+
+@article{Martins-BrittoLopes2020,
+ author = {Martins-Britto, Amauri Gutierrez and Lopes, Felipe V. and Rondineau, Sebastien Roland Marie Joseph},
+ title = {Multilayer {Earth} {Structure} {Approximation} by a {Homogeneous} {Conductivity} {Soil} for {Ground} {Return} {Impedance} {Calculations}},
+ journal = {IEEE Transactions on Power Delivery},
+ volume = {35},
+ number = {2},
+ pages = {881--891},
+ year = {2020},
+ month = apr,
+ doi = {10.1109/TPWRD.2019.2930406},
+ url = {https://doi.org/10.1109/TPWRD.2019.2930406}
+}
+
+@article{Bernal1997,
+ author = {Bernal, Joaqu{\'i}n and Medina, Francisco and Horno, Manuel},
+ title = {Quick quasi-{TEM} analysis of multiconductor transmission lines with rectangular cross section},
+ journal = {IEEE Transactions on Microwave Theory and Techniques},
+ year = {1997},
+ volume = {45},
+ number = {9},
+ pages = {1619--1626},
+ doi = {10.1109/22.622930}
+}
+
+@article{Meixner1972,
+ author = {Meixner, J.},
+ title = {The behavior of electromagnetic fields at edges},
+ journal = {IEEE Transactions on Antennas and Propagation},
+ year = {1972},
+ volume = {20},
+ number = {4},
+ pages = {442--446},
+ doi = {10.1109/TAP.1972.1140243}
+}
+
+@article{VanBladel1985,
+ author = {Van Bladel, J.},
+ title = {Field singularities at metal-dielectric wedges},
+ journal = {IEEE Transactions on Antennas and Propagation},
+ year = {1985},
+ volume = {33},
+ number = {4},
+ pages = {450--455},
+ doi = {10.1109/TAP.1985.1143588}
+}
+
+@article{Classen2011,
+ author = {Classen, C. and Gjonaj, E. and R{\"o}mer, U. and Schuhmann, R. and Weiland, T.},
+ title = {Modeling of field singularities at dielectric edges using grid based methods},
+ journal = {Advances in Radio Science},
+ year = {2011},
+ volume = {9},
+ pages = {39--44},
+ doi = {10.5194/ars-9-39-2011}
+}
+
+@article{HelsingOjala2008,
+ author = {Helsing, Johan and Ojala, Rikard},
+ title = {On the evaluation of layer potentials close to their sources},
+ journal = {Journal of Computational Physics},
+ year = {2008},
+ volume = {227},
+ number = {5},
+ pages = {2899--2921},
+ doi = {10.1016/j.jcp.2007.11.024}
+}
+
+@article{Campione2018,
+ author = {Campione, Salvatore and Warne, Larry K. and Langston, William L. and Basilio, Lorena I.},
+ title = {First Principles Model of Electric Cable Braid Penetration with Dielectrics},
+ journal = {Progress In Electromagnetics Research C},
+ year = {2018},
+ volume = {82},
+ pages = {1--11},
+ doi = {10.2528/PIERC17103010}
+}
+
+@article{Schelkunoff1934,
+ author = {Schelkunoff, S. A.},
+ title = {The Electromagnetic Theory of Coaxial Transmission Lines and Cylindrical Shields},
+ journal = {Bell System Technical Journal},
+ year = {1934},
+ volume = {13},
+ number = {4},
+ pages = {532--579},
+ doi = {10.1002/j.1538-7305.1934.tb00679.x}
+}
+
+@book{Sunde1968,
+ author = {Sunde, Erling D.},
+ title = {Earth Conduction Effects in Transmission Systems},
+ publisher = {Dover Publications},
+ address = {New York},
+ year = {1968},
+ note = {Corrected republication of the 1949 edition}
+}
+@article{Ametani1980,
+ title = {A {{General Formulation}} of {{Impedance}} and {{Admittance}} of {{Cables}}},
+ author = {Ametani, A.},
+ year = 1980,
+ month = may,
+ journal = {IEEE Transactions on Power Apparatus and Systems},
+ volume = {PAS-99},
+ number = {3},
+ pages = {902--910},
+ issn = {0018-9510},
+ doi = {10.1109/TPAS.1980.319718},
+ urldate = {2026-09-03},
+ abstract = {Interest in the analysis of wave propagation characteristics and transients associated with cable systems has rapidly increased. In order to answer the need of the analyst,impedances and admittances of various cables have to be known. This paper describes a general formulation of impedances and admittances of single-core coaxial and pipe-type cables. The formulation presented here can handle a coaxial cable consisting of a core, sheath and armor, a pipe-type cable of which the pipe thickness is finite and an overhead cable, which has not been discussed in the literature heretofore.},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+ keywords = {docling/done},
+}
+
+@article{Ametani1992,
+ title = {Approximate Method for Calculating the Impedances of Multiconductors with Cross Sections of Arbitrary Shapes},
+ author = {Ametani, Akihiro and Fuse, Ikuko},
+ year = 1992,
+ journal = {Electrical Engineering in Japan},
+ volume = {112},
+ number = {2},
+ pages = {117--123},
+ issn = {0424-7760},
+ doi = {10.1002/eej.4391120213},
+ langid = {english},
+ keywords = {formula-corpus/source}
+}
+
+@article{Ametani2004,
+ title = {Semiconducting {{Layer Impedance}} and Its {{Effect}} on {{Cable Wave-Propagation}} and {{Transient Characteristics}}},
+ author = {Ametani, A. and Miyamoto, Y. and Nagaoka, N.},
+ year = 2004,
+ month = oct,
+ journal = {IEEE Transactions on Power Delivery},
+ volume = {19},
+ number = {4},
+ pages = {1523--1531},
+ issn = {0885-8977},
+ doi = {10.1109/TPWRD.2003.822502},
+ urldate = {2026-09-03},
+ abstract = {This paper has derived an impedance formula for conductor's semiconducting layer based on a conventional circuit theory. The formula is confirmed to be identical to an accurate one derived by solving Maxwell's equation. A wave-propagation characteristic and a transient voltage on a cable having the semiconducting layer on the conductor's surface are evaluated by applying the derived formula, and are compared with those on a cable with no semiconducting layer. The semiconducting layer increases the conductor impedance, and thus, the attenuation constant is increased, and the propagation velocity and the characteristic impedance are decreased for a coaxial mode by the semiconducting layer, but the inter-phase mode of propagation is not affected. A transient voltage is attenuated more and its oscillating period becomes greater than those on a cable with no semiconducting layer. The effect of the semiconducting layer impedance on the wave-propagation characteristic and the transient voltage is rather minor when the layer thickness is small and the resistivity is high, and the semiconducting layer effect is dominated by its admittance.},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+}
+
+@article{Ametani2009,
+ title = {An {{Investigation}} of {{Earth-Return Impedance Between Overhead}} and {{Underground Conductors}} and {{Its Approximation}}},
+ author = {Ametani, Akihiro and Yoneda, Tetsuzo and Baba, Yoshihiro and Nagaoka, Naoto},
+ year = 2009,
+ month = aug,
+ journal = {IEEE Transactions on Electromagnetic Compatibility},
+ volume = {51},
+ number = {3},
+ pages = {860--867},
+ issn = {0018-9375},
+ doi = {10.1109/TEMC.2009.2019953},
+ urldate = {2026-09-03},
+ abstract = {This paper investigates numerical instability issues in the mutual impedance formula between an overhead conductor and a buried conductor proposed by Pollaczek with special reference to the integrand of Pollaczek's infinite integral. The integrand shows a negative real part and positive imaginary part at a high frequency, which result in negative resistance and inductance. The reason for this is the assumption of TEM-mode propagation in Pollaczek's formula as is well known. The fact suggests that numerical instability of the infinite integral is caused by incorrect integrand in a high-frequency region. Based on the observations, the applicable limit of Pollacezk's formula is analytically derived. Finally, a simple closed-form formula is developed, which approximates Pollaczek's formula with satisfactory accuracy.},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+ keywords = {Analytical,Closed-form,docling/error,Earth impedance,Electromagnetic interference,Ground return,Underground cables},
+}
+
+@incollection{Ametani2015b,
+ title = {Impedance and Admittance Formulas},
+ author = {Ametani, Akihiro},
+ booktitle = {Cable System Transients: Theory, Modeling and Simulation},
+ bookauthor = {Ametani, Akihiro and Ohno, Teruo and Nagaoka, Naoto},
+ year = {2015},
+ chapter = {2},
+ pages = {21--62},
+ publisher = {Wiley-IEEE Press},
+ doi = {10.1002/9781118702154.ch2}
+}
+
+@article{Bormann2013,
+ title = {Reluctance Network Method for Calculating the Series Impedance Matrix of Multi-Conductor Transmission Lines},
+ author = {Bormann, Dierk and Tavakoli, Hanif},
+ year = 2013,
+ journal = {IEEE Transactions on Magnetics},
+ volume = {49},
+ number = {10},
+ pages = {5270--5279},
+ doi = {10.1109/TMAG.2013.2261999}
+}
+
+@article{Carson1926,
+ title = {Wave {{Propagation}} in {{Overhead Wires}} with {{Ground Return}}},
+ author = {Carson, John R.},
+ year = 1926,
+ month = oct,
+ journal = {Bell System Technical Journal},
+ volume = {5},
+ number = {4},
+ pages = {539--554},
+ issn = {00058580},
+ doi = {10.1002/j.1538-7305.1926.tb00122.x},
+ urldate = {2026-09-04},
+ langid = {english},
+ keywords = {Ground return},
+}
+
+@article{DeConti2023a,
+ title = {Closed-{{Form Expressions}} for the {{Calculation}} of the {{Ground-Return Impedance}} and {{Admittance}} of {{Underground Cables}}},
+ author = {De Conti, Alberto and Duarte, Naiara and Alipio, Rafael},
+ year = 2023,
+ month = aug,
+ journal = {IEEE Transactions on Power Delivery},
+ volume = {38},
+ number = {4},
+ pages = {2891--2900},
+ issn = {0885-8977, 1937-4208},
+ doi = {10.1109/TPWRD.2023.3264614},
+ urldate = {2026-09-03},
+ abstract = {This paper proposes closed-form expressions for the calculation of the ground-return impedance and admittance of underground cables. The proposed expressions are shown to reproduce the integral equations of Xue with great accuracy for a wide range of ground resistivities and frequencies. An error analysis, a comparison with closed-form expressions available in the literature, and time-domain simulations demonstrate the performance of the proposed equations, which avoid the calculation of improper integrals and present an accurate performance for typical cable arrangements.},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+ keywords = {Analytical,Closed-form,docling/done,Earth impedance,Ground return,Underground cables},
+}
+
+@inproceedings{DeSilva2019,
+ title = {On Combining Classical and Numerical Techniques for Extracting the Impedance and Admittance of Cables and Overhead Lines},
+ author = {De Silva, H. M. J. and Shafieipour, M.},
+ year = 2019,
+ booktitle = {International Conference on Power Systems Transients (IPST)},
+ address = {Perpignan, France}
+}
+
+@article{Deri1981,
+ title = {The {{Complex Ground Return Plane}} a {{Simplified Model}} for {{Homogeneous}} and {{Multi-Layer Earth Return}}},
+ author = {Deri, A. and Tevan, G. and Semlyen, A. and Castanheira, A.},
+ year = 1981,
+ month = aug,
+ journal = {IEEE Transactions on Power Apparatus and Systems},
+ volume = {PAS-100},
+ number = {8},
+ pages = {3686--3693},
+ issn = {0018-9510},
+ doi = {10.1109/TPAS.1981.317011},
+ urldate = {2026-09-03},
+ abstract = {For modelling current return in homogeneous ground, the paper introduces the concept of an ideal (superconducting) current return plane placed below the ground surface at a complex distance p equal to the complex penetration depth for plane waves. This "complex"' plane appears as a mirroring surface, so that conductor images can be used to derive very simple formulae for self and mutual impedances under ground return conditions. Such equations, without proofs, were originally proposed by Dubanton and published by Gary.1 In this paper, plausibility arguments serve to initially justify the procedure, then the equations are analytically related to those of Carson and, finally, the errors, which in most cases are less than a few percent, are numerically evaluated. The ideal return plane at complex depth can also be used for multi-layer earth return.},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+ keywords = {docling/done,Ground return,Multilayer soil},
+}
+
+@inproceedings{Fortin2005,
+ title = {Effects of Eddy Current on the Impedance of Pipe-Type Cables With Arbitrary Pipe Thickness},
+ author = {Fortin, Simon and Yang, Y. and Ma, J. and Dawalibi, F. P.},
+ year = 2005,
+ booktitle = {International Conference on Electrical Engineering},
+ keywords = {formula-corpus/source}
+}
+
+@article{Ghosh2022,
+ title = {A Generalized Approach to Deduce the Impedance and Admittance of Underground Cable Containing N Semiconductor Screens},
+ author = {Ghosh, Swarnankur and Das, Supriyo},
+ year = 2022,
+ journal = {Engineering Science and Technology, an International Journal},
+ volume = {28},
+ pages = {101029},
+ doi = {10.1016/j.jestch.2021.06.009}
+}
+
+@inproceedings{Gustavsen2001,
+ title = {Panel Session on Data for Modeling System Transients: Insulated Cables},
+ author = {Gustavsen, B.},
+ booktitle = {2001 IEEE Power Engineering Society Winter Meeting. Conference Proceedings},
+ year = {2001},
+ volume = {2},
+ pages = {718--723},
+ publisher = {IEEE},
+ doi = {10.1109/PESW.2001.916943}
+}
+
+@article{Gustavsen2005,
+ title = {Parameter Determination for Modeling System Transients---Part II: Insulated Cables},
+ author = {Gustavsen, B. and Martinez, J. A. and Durbak, D.},
+ year = 2005,
+ journal = {IEEE Transactions on Power Delivery},
+ doi = {10.1109/TPWRD.2005.848774}
+}
+
+@article{Gustavsen2023,
+ title = {A 2D FEM Model for Impedance and Loss Calculation of Armored Three-Core Cables With Inclusion of 3D Pitching Effects},
+ author = {Gustavsen, Bjørn},
+ year = 2023,
+ journal = {IEEE Transactions on Power Delivery},
+ volume = {38},
+ number = {5},
+ pages = {3010--3020},
+ doi = {10.1109/TPWRD.2023.3266868}
+}
+
+@article{Hoidalen2013,
+ title = {Analysis of {{Pipe-Type Cable Impedance Formulations}} at {{Low Frequencies}}},
+ author = {Hoidalen, Hans Kr.},
+ year = 2013,
+ month = oct,
+ journal = {IEEE Transactions on Power Delivery},
+ volume = {28},
+ number = {4},
+ pages = {2419--2427},
+ issn = {0885-8977, 1937-4208},
+ doi = {10.1109/TPWRD.2013.2272343},
+ urldate = {2026-09-03},
+ abstract = {The paper analyses series impedances of screen-less cables enclosed by a common conducting pipe. Established analytical formulas for pipe-type cables parameters are analyzed taking finite pipe thickness and proximity effect into account. Analytical low frequency approximations are developed and analyzed. The analysis shows that a classical formulation mixing infinite and finite pipe thickness can result in incorrect inductance and even negative self-resistance at low frequency. The paper analyses corrections to take a finite pipe thickness into account. Finite element simulations reveal that core-to-core proximity effect is substantial for the analyzed cable design with much lower high frequency inductance than obtained from analytical formulas. The paper also analyses given proximityeffect formulations and propose corrections based on analytical low frequency approximations. The proposed proximity effect corrections improve both resistance and inductance in both common and differential mode.},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+ keywords = {Analytical,FEM,Proximity effect},
+}
+
+@misc{Hoidalen2025,
+ title = {On Proximity Correction of Pipe-Type Cable Parameters with Method of Moment Approach},
+ author = {Høidalen, Hans Kristian and Høyer-Hansen, Martin and Neto, Felipe Camara and Bak, Claus Leth},
+ year = 2025,
+ doi = {10.2139/ssrn.5084160},
+ howpublished = {SSRN preprint}
+}
+
+@article{Kane1995,
+ title = {Multiwire {{Shielded Cable Parameter Computation}}},
+ author = {Kane, Mamadou and Ahmad, Ahmad S. and Auriol, Philippe},
+ year = 1995,
+ month = may,
+ journal = {IEEE Transactions on Magnetics},
+ volume = {31},
+ number = {3},
+ pages = {1646--1649},
+ issn = {0018-9464},
+ doi = {10.1109/20.376350},
+ langid = {english},
+ keywords = {formula-corpus/source}
+}
+
+@article{MartinsBritto2024,
+ title = {Transient {{Electromagnetic Interference}} between {{Overhead}} and {{Underground Conductors}}},
+ author = {{Martins-Britto}, Amauri G. and Papadopoulos, Theofilos A. and Chrysochos, Andreas I.},
+ year = 2024,
+ month = jun,
+ journal = {IEEE Transactions on Electromagnetic Compatibility},
+ volume = {66},
+ number = {3},
+ pages = {983--992},
+ issn = {0018-9375, 1558-187X},
+ doi = {10.1109/TEMC.2024.3376971},
+ urldate = {2026-09-04},
+ abstract = {Electromagnetic interference (EMI) between overhead lines (OHL) and nearby pipelines may cause high-induced voltages. Their computation is important for the safe and reliable operation of the pipeline and necessitates the accurate evaluation of earth conduction effects on the impedance and admittance parameters of all conductors. In this article, a detailed formulation of the impedance and admittance parameters of OHL/pipeline configurations is presented and new expressions for the calculation of the mutual impedance and admittance between the overhead conductors and the buried pipeline are introduced. The mutual parameters for an OHL/pipeline configuration are evaluated by the new formulas and compared with those derived from existing ones, the method of moments and the finite element method. The propagation characteristics are also calculated justifying the use of the mutual admittance, that is typically disregarded in power systems EMI calculations. Transient simulation results with the developed formulas are performed to evaluate the importance of the proposed approach on EMI studies.},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+ keywords = {Electromagnetic interference,Electromagnetic transients,FEM,MoM,Overhead lines,Pipelines},
+}
+
+@article{Papadopoulos2010b,
+ title = {Impedances and {{Admittances}} of {{Underground Cables}} for the {{Homogeneous Earth Case}}},
+ author = {Papadopoulos, Theofilos A. and Tsiamitros, Dimitrios A. and Papagiannis, Grigoris K.},
+ year = 2010,
+ month = apr,
+ journal = {IEEE Transactions on Power Delivery},
+ volume = {25},
+ number = {2},
+ pages = {961--969},
+ issn = {0885-8977, 1937-4208},
+ doi = {10.1109/TPWRD.2009.2034797},
+ urldate = {2026-09-03},
+ abstract = {A general formulation for the calculation of the influence of the earth return path on the impedances and the admittances of underground multiconductor power cable arrangements is presented in this paper. The expressions for the self and mutual earth correction terms are derived by a rigorous solution of the electromagnetic-field equations. The involved semiinfinite integrals are calculated by using a suitable numerical integration technique. The propagation characteristics of a single insulated conductor and of a typical three-phase single-core cable arrangement are investigated and are compared to the corresponding ones obtained by other approaches. Finally, the cable parameters calculated by the proposed method are used in a simulation of a fast transient in a three-phase single-core cable.},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+ keywords = {docling/done,docling/error,Ground return,Line parameters,Numerical integration,Power cables,Underground cables},
+}
+
+@article{Papadopoulos2011,
+ title = {Earth Return Admittances and Impedances of Underground Cables in Non-Homogeneous Earth},
+ author = {Papadopoulos, T.A. and Tsiamitros, D.A. and Papagiannis, G.K.},
+ year = 2011,
+ month = feb,
+ journal = {IET Generation, Transmission \& Distribution},
+ volume = {5},
+ number = {2},
+ pages = {161--171},
+ issn = {1751-8687, 1751-8695},
+ doi = {10.1049/iet-gtd.2010.0228},
+ urldate = {2026-09-03},
+ abstract = {The influence of the stratified earth on the admittances and impedances of underground multiconductor arrangements is examined here. The electromagnetic characteristics of the stratified earth are included in a rigorous solution of the electromagnetic field equations, leading to new expressions for the self and mutual ground admittances and impedances of underground conductors. The new expressions are implemented for both a single insulated conductor and a typical three-phase single-core cable arrangement in various two-layer earth configurations. The derived propagation characteristics are compared with the corresponding ones obtained either by an approximate approach or for the homogeneous earth case. Finally, the modal propagation characteristics calculated by the proposed method are used in the simulation of steady-state high-frequency responses in the modal domain.},
+ langid = {english},
+ keywords = {docling/error,Earth admittance,Ground return,Modal analysis,Multilayer soil,Underground cables},
+}
+
+@article{Patel2014,
+ title = {Proximity-{{Aware Calculation}} of {{Cable Series Impedance}} for {{Systems}} of {{Solid}} and {{Hollow Conductors}}},
+ author = {Patel, Utkarsh R. and Gustavsen, Bjorn and Triverio, Piero},
+ year = 2014,
+ month = oct,
+ journal = {IEEE Transactions on Power Delivery},
+ volume = {29},
+ number = {5},
+ pages = {2101--2109},
+ issn = {0885-8977, 1937-4208},
+ doi = {10.1109/TPWRD.2014.2330994},
+ urldate = {2026-09-03},
+ abstract = {Wideband cable models for the prediction of electromagnetic transients in power systems require an accurate calculation of the cable series impedance as a function of frequency. A surface current approach was recently proposed for systems of round solid conductors, with the inclusion of skin and proximity effects. In this paper, we extend the approach to include tubular conductors, allowing to model realistic cables with tubular sheaths, armors, and pipes. We also include the effect of a lossy ground. A noteworthy feature of the proposed technique is the accurate prediction of proximity effects, which can be of major importance in three-phase, pipe-type, and closely packed single-core cables. The new approach is highly efficient compared to finite elements. In the case of a cross-bonded cable system featuring three-phase conductors and three screens, the proposed technique computes the required 120 frequency samples in only six seconds of CPU time.},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+ keywords = {docling/error,Electromagnetic transients,FEM},
+}
+
+@article{Pollaczek1926,
+ title = {{\"Uber das Feld einer unendlich langen wechselstromdurchflossenen Einfachleitung}},
+ author = {Pollaczek, Felix},
+ year = 1926,
+ journal = {Elektrische Nachrichtentechnik},
+ volume = {3},
+ number = {9},
+ pages = {339--359},
+ langid = {ngerman},
+ keywords = {formula-corpus/source}
+}
+
+@article{Saad1996,
+ title = {A Closed-Form Approximation for Ground Return Impedance of Underground Cables},
+ author = {Saad, O. and Gaba, G. and Giroux, M.},
+ year = 1996,
+ month = jul,
+ journal = {IEEE Transactions on Power Delivery},
+ volume = {11},
+ number = {3},
+ pages = {1536--1545},
+ issn = {08858977},
+ doi = {10.1109/61.517514},
+ urldate = {2026-09-03},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+ keywords = {Analytical,Closed-form,docling/error,Earth impedance,Ground return,Underground cables},
+}
+
+@article{Tsiamitros2008,
+ title = {Earth {{Return Impedances}} of {{Conductor Arrangements}} in {{Multilayer Soils}}---{{Part I}}: {{Theoretical Model}}},
+ shorttitle = {Earth {{Return Impedances}} of {{Conductor Arrangements}} in {{Multilayer Soils}}---{{Part I}}},
+ author = {Tsiamitros, D.A. and Papagiannis, G.K. and Dokopoulos, P.S.},
+ year = 2008,
+ month = oct,
+ journal = {IEEE Transactions on Power Delivery},
+ volume = {23},
+ number = {4},
+ pages = {2392--2400},
+ issn = {0885-8977, 1937-4208},
+ doi = {10.1109/TPWRS.2008.923816},
+ urldate = {2026-09-03},
+ abstract = {The influence of earth stratification on the conductor impedances is investigated in this paper. A general solution of the electromagnetic-field equations for the case of overhead and underground transmission-line conductors of arbitrary topology and multilayer earth is presented. Generalized expressions for the self and mutual impedances of the conductors are derived. All existing approaches result from the generalized equations, when the corresponding approximations are applied. A suitable integration scheme is also proposed for the calculation of the complex integrals involved in the expressions.},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+ keywords = {docling/error,Earth impedance,Ground return,Multilayer soil,Soil modeling},
+}
+
+@article{Uribe2008,
+ title = {Calculating {{Mutual Ground Impedances Between Overhead}} and {{Buried Cables}}},
+ author = {Uribe, F. A.},
+ year = 2008,
+ journal = {IEEE Transactions on Electromagnetic Compatibility},
+ volume = {50},
+ number = {1},
+ pages = {198--203},
+ issn = {0018-9375},
+ doi = {10.1109/TEMC.2007.915286},
+ urldate = {2026-09-03},
+ abstract = {Aerial and buried electric conductors often share the same right of way. When a phase-to-ground fault occurs, high transient-induced overvoltages appear from short-circuit currents to the ground. The mutual ground-loop impedances between both systems are given by the Pollaczek coupling integral that does not possess an analytic closed-form solution, and its integrand is highly oscillatory to perform a direct numerical integration. An efficient and accurate algorithmic evaluation of the Pollaczek coupling integral is presented in this paper. The obtained result is used in the numerical assessment of approximated formulas previously issued by Lucca, Wedepohl, and Comit\textasciiacute e Consultatif International T\textasciiacute el\textasciiacute ephonique et T\textasciiacute el\textasciiacute egraphique.},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+ keywords = {Analytical,Closed-form,docling/error,Earth impedance,Numerical integration,Short circuit,Underground cables},
+}
+
+@article{Wait1978,
+ title = {Excitation of Currents on a Buried Insulated Cable},
+ author = {Wait, James R.},
+ year = 1978,
+ journal = {Journal of Applied Physics},
+ volume = {49},
+ number = {2},
+ pages = {876--881},
+ doi = {10.1063/1.324619}
+}
+
+@article{Weeks1984,
+ title = {Wave Propagation Characteristics in Underground Power Cable},
+ author = {Weeks, W. and Diao, Yi},
+ year = 1984,
+ journal = {IEEE Transactions on Power Apparatus and Systems},
+ volume = {PAS-103},
+ number = {10},
+ pages = {2816--2826},
+ doi = {10.1109/TPAS.1984.318279}
+}
+
+@article{Wise1934,
+ title = {Propagation of {{High-Frequency Currents}} in {{Ground Return Circuits}}},
+ author = {Wise, W.H.},
+ year = 1934,
+ month = apr,
+ journal = {Proceedings of the IRE},
+ volume = {22},
+ number = {4},
+ pages = {522--527},
+ issn = {0096-8390},
+ doi = {10.1109/JRPROC.1934.225868},
+ urldate = {2026-09-03},
+ abstract = {The electric field parallel to a ground return circuit is calculated without assuming that the frequency is so low that polarization currents in the ground may be neglected. It is found that the polarization currents may be included by replacing the r in Carson's well-known formulas by rV1l+i(e-1) 2cXo-.},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+ keywords = {docling/error,Ground return},
+}
+
+@article{Wise1948,
+ title = {Potential {{Coefficients}} for {{Ground Return Circuits}}},
+ author = {Wise, W. Howard},
+ year = 1948,
+ month = apr,
+ journal = {Bell System Technical Journal},
+ volume = {27},
+ number = {2},
+ pages = {365--371},
+ issn = {00058580},
+ doi = {10.1002/j.1538-7305.1948.tb00913.x},
+ urldate = {2026-09-03},
+ langid = {english},
+ keywords = {docling/error,Ground return},
+}
+
+@article{Xue2018b,
+ title = {Generalized {{Formulation}} of {{Earth-Return Impedance}}/{{Admittance}} and {{Surge Analysis}} on {{Underground Cables}}},
+ author = {Xue, Haoyan and Ametani, Akihiro and Mahseredjian, Jean and Kocar, Ilhan},
+ year = 2018,
+ month = dec,
+ journal = {IEEE Transactions on Power Delivery},
+ volume = {33},
+ number = {6},
+ pages = {2654--2663},
+ issn = {0885-8977, 1937-4208},
+ doi = {10.1109/TPWRD.2018.2796089},
+ urldate = {2026-09-03},
+ abstract = {A generalized formulation of earth-return impedance and admittance for underground cables is proposed based on a complete solution of Maxwell equations. The frequency responses of wave propagation characteristics are evaluated by the newly derived formulas and compared to those found from existing formulas in software used for the simulation of electromagnetic transients. Surge simulation results with the proposed formulas show more damping and smooth convergence to steady state in comparison with those with existing formulas. The proposed formulas are validated with a field test on a 110-kV cross-bonded cable. It is also shown that the MoM-So method, augmented with admittance representation, verifies the results found from the new formulas presented in this paper.},
+ copyright = {https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html},
+ langid = {english},
+ keywords = {Earth impedance,Electromagnetic transients,Ground return,MoM,MoM-SO,Underground cables},
+}
+
+@inproceedings{Yang2001,
+ title = {Computation of Cable Parameters for Pipe-Type Cables With Arbitrary Pipe Thicknesses},
+ author = {Yang, Yixin and Ma, J. and Dawalibi, F. P.},
+ year = 2001,
+ booktitle = {IEEE/PES Transmission and Distribution Conference and Exposition},
+ doi = {10.1109/TDC.2001.971316}
+}
diff --git a/docs/src/bib.md b/docs/src/bibliography.md
similarity index 89%
rename from docs/src/bib.md
rename to docs/src/bibliography.md
index 0b1be7e1b..a987675ae 100644
--- a/docs/src/bib.md
+++ b/docs/src/bibliography.md
@@ -1,4 +1,4 @@
# Bibliography
```@bibliography
-```
\ No newline at end of file
+```
diff --git a/docs/src/cable-builder.md b/docs/src/cable-builder.md
deleted file mode 100644
index 5431de25d..000000000
--- a/docs/src/cable-builder.md
+++ /dev/null
@@ -1,512 +0,0 @@
-# CableBuilder Developer Guide
-
-## Extending the Cable Modeling DSL
-
-This document describes the **internal grammar of the CableBuilder API** and the **required steps to add new cable elements**.
-
-The architecture separates responsibilities into four layers:
-
-1. **Shape payloads** — geometric primitives
-2. **Cable parts** — attach electrical meaning
-3. **Builders** — materialization functors
-4. **Specs** — lazy blueprints supporting combinatorics (`Grid`)
-
-All cable layers follow the same extension pattern.
-
----
-
-# 1. Core Concepts
-
-## 1.1 Cable Parts
-
-Cable parts attach **electrical role** to **geometric shapes**.
-
-```julia
-abstract type AbstractCablePart end
-
-@inline r_ex(p::AbstractCablePart) = r_ex(p.shape)
-```
-
-Two built-in roles exist:
-
-```julia
-struct ConductorPart{L,T,S<:AbstractShape{L,T}} <: AbstractCablePart
- cmp::Symbol
- shape::S
- material::Material{T}
-end
-
-struct InsulatorPart{L,T,S<:AbstractShape{L,T}} <: AbstractCablePart
- cmp::Symbol
- shape::S
- material::Material{T}
-end
-```
-
-Both rely on identical promotion logic:
-
-```julia
-function ConductorPart(cmp::Symbol,
- shape::AbstractShape{L,Tshape},
- mat::Material{Tmat}) where {L,Tshape<:Real,Tmat<:Real}
-
- T = promote_type(Tshape,Tmat)
-
- s = convert(AbstractShape{L,T}, shape)
- m = convert(Material{T}, mat)
-
- ConductorPart{L,T,typeof(s)}(cmp,s,m)
-end
-```
-
-Identical pattern applies to `InsulatorPart`.
-
-### Contract
-
-A valid cable part requires:
-
-* `cmp::Symbol` (used to group parts into functional components)
-* `shape <: AbstractShape` (used to determine geometric features of parts)
-* `material::Material` (used to determine physical properties of parts)
-
-No logic is allowed inside the struct.
-
-Promotion must occur in outer constructors.
-
----
-
-# 2. Layout and Shape System
-
-Shapes encode **pure geometry**.
-
-Electrical semantics are handled by cable parts.
-
-## Layout markers
-
-```julia
-abstract type AbstractLayout end
-
-struct Concentric <: AbstractLayout end
-struct SectorShaped <: AbstractLayout end
-```
-
-## Shape interface
-
-```julia
-abstract type AbstractShape{L<:AbstractLayout,T<:Real} end
-```
-
-Every shape must implement:
-
-```julia
-r_in(shape)
-r_ex(shape)
-```
-
-or fallback to the global default. These accessors are **mandatory**.
-
-All geometry logic in downstream physics relies on them.
-
----
-
-# 3. Shape Implementation Pattern
-
-Every shape file contains **three components**.
-
-### 3.1 The Vault (struct)
-
-Strict parametric storage.
-
-No logic.
-
-Example:
-
-```julia
-struct SolidCore{L,T<:Real} <: AbstractShape{L,T}
- r_ex::T
-end
-```
-
----
-
-### 3.2 The Janitor (outer constructor)
-
-Handles promotion.
-
-```julia
-SolidCore{L}(r_ex::T) where {L,T<:Real} = SolidCore{L,T}(r_ex)
-```
-
----
-
-### 3.3 The Diplomat (`convert`)
-
-Allows upgrading precision.
-
-Required for compatibility with:
-
-* `Measurements`
-* grid sampling
-* solver promotion
-
-```julia
-function Base.convert(::Type{<:AbstractShape{L,T}},
- s::SolidCore{L}) where {L,T<:Real}
-
- SolidCore{L,T}(convert(T,r_ex(s)))
-end
-```
-
----
-
-### 3.4 Accessors
-
-```julia
-@inline r_in(s::SolidCore) = zero(typeof(s.r_ex))
-@inline r_ex(s::SolidCore) = s.r_ex
-```
-
-These must always exist, and are defined globally. Cases non-conformal to the typical `r_in` / `r_ex` pattern must specify custom accessors, e.g. `SolidCore`, `Enclosure`.
-
-Never access fields directly outside the shape file.
-
----
-
-# 4. Builders
-
-Builders are **functors that materialize cable parts**.
-
-They do not perform combinatorics.
-
-They receive the current stacking radius.
-
----
-
-## Example: Solid core
-
-```julia
-struct SolidCoreBuilder{P,Tgeom<:Real,Tmat<:Real}
- cmp::Symbol
- r_ex::Tgeom
- mat::Material{Tmat}
-end
-```
-
-Constructor:
-
-```julia
-@inline function SolidCoreBuilder{P}(cmp::Symbol,
- r_ex::Tgeom,
- mat::Material{Tmat}) where {P,Tgeom,Tmat}
-
- SolidCoreBuilder{P,Tgeom,Tmat}(cmp,r_ex,mat)
-end
-```
-
-Materialization:
-
-```julia
-@inline function (b::SolidCoreBuilder{P})(current_r::T) where {P,T<:Real}
-
- current_r != zero(T) &&
- error("Topological violation: Solid core must be at r=0.")
-
- P(b.cmp, SolidCore{Concentric}(b.r_ex), b.mat)
-end
-```
-
----
-
-## Example: Tubular layer
-
-```julia
-struct TubularBuilder{P,Tgeom<:Real,Tmat<:Real}
- cmp::Symbol
- t::Tgeom
- mat::Material{Tmat}
-end
-```
-
-Materialization:
-
-```julia
-@inline function (b::TubularBuilder{P})(current_r::T) where {P,T<:Real}
-
- r_ex = current_r + b.t
-
- P(b.cmp,
- TubularShape{Concentric}(current_r,r_ex),
- b.mat)
-end
-```
-
----
-
-# 5. Specs (Blueprint Layer)
-
-Specs represent **lazy parameter spaces**.
-
-They expand into builders during iteration.
-
-All specs subtype:
-
-```julia
-abstract type AbstractSpec{Target} end
-```
-
----
-
-## SolidCoreSpec
-
-```julia
-struct SolidCoreSpec{P,Tcmp,Tr,M<:AbstractSpec{Material}} <:
- AbstractSpec{SolidCoreBuilder{P}}
-
- cmp::Tcmp
- r_ex::Tr
- mat::M
-end
-```
-
-Constructor:
-
-```julia
-SolidCoreSpec(::Type{P},
- cmp::Tcmp,
- r_ex::Tr,
- mat::M) where {P,Tcmp,Tr,M<:AbstractSpec{Material}}
-```
-
----
-
-## TubularPartSpec
-
-```julia
-struct TubularPartSpec{P,Tcmp,Tt,M<:AbstractSpec{Material}} <:
- AbstractSpec{TubularBuilder{P}}
-
- cmp::Tcmp
- t::Tt
- mat::M
-end
-```
-
-Constructor:
-
-```julia
-TubularPartSpec(::Type{P},
- cmp::Tcmp,
- t::Tt,
- mat::M)
-```
-
----
-
-# 6. Grid System
-
-`Grid` represents **deterministic parameter variation**.
-
-Example:
-
-```julia
-Grid([0.02,0.025,0.03])
-```
-
-Specs store parameters as grids.
-
-Iteration is performed through:
-
-```julia
-Iterators.product(grid_args(spec)...)
-```
-
-Materialization occurs lazily.
-
----
-
-# 7. Builder Materialization
-
-Builders are executed by the stacking engine:
-
-```julia
-build_layer(current_r, builders)
-```
-
-Each builder:
-
-```
-(current_r) -> CablePart
-```
-
-Stacking radius evolves via:
-
-```
-current_r → r_ex(part)
-```
-
----
-
-# 8. API layer
-
-User-facing constructors exist in DSL modules:
-
-Example:
-
-```julia
-Conductor.Solid(cmp,material;r)
-Conductor.Tubular(cmp,material;t)
-```
-
-They construct specs:
-
-```
-SolidCoreSpec(...)
-TubularPartSpec(...)
-```
-
----
-
-# 9. Extending the API
-
-To add a new cable element **all steps below are mandatory**.
-
----
-
-# Step 1 — Implement Shape
-
-File: `newshape.jl`
-
-Required components:
-
-```
-struct NewShape{L,T} <: AbstractShape{L,T}
- fields...
-end
-```
-
-Janitor:
-
-```
-function NewShape{L}(args...) where {L}
- T = promote_type(typeof(arg1), typeof(arg2)...)
- return NewShape{L, T}(convert(T, arg1), convert(T, arg2)...)
-end
-```
-
-Diplomat:
-
-```
-function Base.convert(::Type{<:AbstractShape{L,T}}, s::NewShape{L}) where {L, T <: Real}
- return NewShape{L, T}(convert(T, ...), convert(T, ...), ...)
-end
-```
-
-Accessors:
-
-```
-r_in(s)
-r_ex(s)
-```
-
----
-
-# Step 2 — Implement Builder
-
-```
-struct NewShapeBuilder{P,Tgeom,Tmat}
-```
-
-Constructor:
-
-```
-NewShapeBuilder{P}(args...)
-```
-
-Functor:
-
-```
-(b::NewShapeBuilder)(current_r)
-```
-
-Must return:
-
-```
-P(cmp, NewShape(...), material)
-```
-
----
-
-# Step 3 — Implement Spec
-
-```
-struct NewShapeSpec{P,...} <: AbstractSpec{NewShapeBuilder{P}}
-```
-
-Constructor:
-
-```
-NewShapeSpec(::Type{P}, ...)
-```
-
----
-
-# Step 4 — Define Grid Arguments
-
-If parameters are gridable, ensure they propagate through the global `grid_args` or specialize if necessary.
-
----
-
-# Step 5 — Implement DSL Constructor
-
-Example:
-
-```julia
-function Conductor.NewShape(cmp::Symbol, mat; parameters...)
-```
-
-Must return:
-
-```
-NewShapeSpec(ConductorPart,...)
-```
-
----
-
-# 10. Stacking Engine
-
-Stacking is implemented through tuple recursion.
-
-```julia
-build_layer(r,builders)
-```
-
-Each layer returns:
-
-```
-(part, build_layer(...))
-```
-
-Final object:
-
-```
-CableDesign{Tuple}(parts)
-```
-
----
-
-# 11. Required Guarantees
-
-Every new element must satisfy:
-
-* No abstract fields in builders
-* No closures
-* Pure struct functors
-* Explicit promotion
-* Correct `convert` implementation
-* Valid `r_in` / `r_ex` accessors
-
-Failure to implement any of these breaks stacking or solver compatibility.
-
----
-
-# End of Guide
diff --git a/docs/src/contributing.md b/docs/src/contributing.md
new file mode 100644
index 000000000..5a483b843
--- /dev/null
+++ b/docs/src/contributing.md
@@ -0,0 +1,28 @@
+# Contributing
+
+Use Julia 1.12 and instantiate the package environment before making changes:
+
+```julia
+using Pkg
+Pkg.instantiate()
+Pkg.test()
+```
+
+Format maintained Julia files with `JuliaFormatter.format(".")`.
+Use `type(scope): description` for commit subjects, with a lowercase imperative
+description. Keep the complete subject within 72 characters. A breaking change
+may use `type(scope)!: description`. Commit bodies are optional.
+
+Install Gitlint and enable its native commit hook once per clone:
+
+```bash
+uv tool install gitlint-core
+gitlint install-hook
+```
+
+The hook checks messages against `.gitlint` before creating a commit.
+Gitlint keeps its default exemptions for merge, revert and fixup commits.
+
+Keep pull requests focused. Add tests for changed behavior and update public
+documentation when an API changes. Optional plotting integrations must remain
+outside core loading and must be checked with their dedicated workflows.
diff --git a/docs/src/conveniences.md b/docs/src/conveniences.md
new file mode 100644
index 000000000..d2dfdd575
--- /dev/null
+++ b/docs/src/conveniences.md
@@ -0,0 +1,203 @@
+# Construction vocabulary
+
+The practical construction functions lower directly to `Region`, `Stack`,
+`Group`, `Assembly`, and `Enclosure`. These stateless functions omit serialized wrapper types.
+
+## Regions and ordered layers
+
+```julia
+phase = terminal(
+ :phase,
+ core(copper; r=8e-3),
+ screen(semicon; t=0.8e-3),
+ insulation(xlpe; t=6e-3),
+)
+```
+
+Arguments to `terminal`, `layers`, and `build(CableDesign, ...)` are ordered
+from the center outward. `solid` and `shell` are the general escape hatches for
+physical roles not covered by `core`, `insulation`, `screen`, `sheath`,
+`bedding`, `jacket`, and `filler`.
+
+## Wires, wire strands, and ropes
+
+Declare one fixed wire course with `wires`:
+
+```julia
+screen_wires = wires(
+ copper;
+ shape=Disk(0.5e-3),
+ n=40,
+ r=20e-3,
+ gap_frac=0.02,
+ lay=LayRatio(10),
+)
+```
+
+With a `Disk` geometric boundary, `stranded` creates a center wire and infers the maximum
+complete `6k` course inventory. Without compaction circular wires retain their
+natural shape. `compact=true` requests area-preserving deformation of those
+same complete courses. A `Sector` geometric boundary maps a center strand and complete
+`6k` courses into the sector, then reconstructs each strand as an
+area-preserving clipped disk. Its deformation is intrinsic. It has no
+compaction selector or separately supplied center wire.
+
+```julia
+compacted_core = stranded(
+ copper;
+ shape=Disk(0.5e-3),
+ lay=(LayRatio(13), Pitch(0.15), LayAngle(0.2)),
+ compact=true,
+ boundary=Disk(sqrt(37) * 0.5e-3),
+)
+```
+
+A schedule sharing one definition lifts its raw course values directly:
+
+```julia
+round_core = stranded(
+ copper;
+ shape=Disk(0.5e-3),
+ lay=LayRatio(13, 12, 11),
+ boundary=Disk(3.5e-3),
+)
+```
+
+The explicit tuple form above remains available when course definitions differ.
+
+`rope(item; ...)` applies the same course grammar to an existing physical
+item. Every child retains its internal path declarations. An outer course adds
+its own path. `armor` uses the same ring, path, clearance, and compaction
+operations while resolving its course radius from the preceding geometric boundary.
+
+`@distribute` inserts only `n=capacity()`:
+
+```julia
+automatic = @distribute wires(
+ copper;
+ shape=Disk(0.5e-3),
+ r=20e-3,
+ gap_frac=0.03,
+)
+```
+
+## Tape
+
+One tape function covers conductive, semiconductive, and insulating systems:
+
+```julia
+insulating_tapes = @distribute tape(
+ tape_insulation;
+ section=Rectangle(1.4e-3, 0.5e-3),
+ gap_frac=0.02,
+ lay=LayRatio(10),
+)
+```
+
+This group contributes no terminal. A conductive tape becomes a terminal only
+under an explicit terminal scope:
+
+```julia
+screen_tape = @terminal :screen begin
+ tape(
+ copper;
+ section=Rectangle(3.0e-3, 0.5e-3),
+ n=4,
+ lay=LayRatio(12),
+ )
+end
+```
+
+## Independent members and ducts
+
+`cores(item; n, r, names)` retains one prototype and a repetition pattern.
+`cores(member1, member2, ...)` and `assembly(member1, member2, ...)` preserve
+explicit heterogeneous members and their poses.
+
+```julia
+cells = @assembly begin
+ @at cell_a (-12e-3, 0.0)
+ @at cell_b ( 12e-3, 2e-3) φ=0.1
+end
+
+bank = @duct shape=Rectangle(40e-3, 24e-3) fill=concrete begin
+ cells
+end
+```
+
+The corresponding pipe notation uses the same block grammar:
+
+```julia
+contained = @pipe shape=Disk(20e-3) fill=air begin
+ cells
+end
+```
+
+Homogeneous repeated ducts use a pattern and retain one prototype:
+
+```julia
+bank = duct(
+ cell;
+ formation=Lattice(nx=3, ny=2, dx=12e-3, dy=12e-3),
+ shape=Rectangle(44e-3, 30e-3),
+ fill=concrete,
+)
+```
+
+## Formations
+
+Formation functions return ordinary placed-cable declarations:
+
+```julia
+placements = @trefoil design spacing=0.09 center=(0.0, -1.0) phase=(1, 2, 3) sheath=0
+```
+
+Nonzero one-based values identify active phases. `0` marks a conductor selected
+for grounded or eliminated-conductor reduction. The values are identifiers, not
+electrical polarity or phase-angle signs.
+
+The function forms `trefoil`, `hflat`, and `vflat` accept the same data.
+`at(placements, ...)` composes an outer translation and rotation without
+copying the cable designs.
+
+## Wire-pattern estimates
+
+[`estimate_stranding`](@ref) and [`estimate_screen`](@ref) search deterministic wire
+patterns and return a [`WireEstimate`](@ref). An infeasible estimate retains
+ranked patterns and states which limits were not met.
+
+```julia
+estimate = estimate_stranding(1000.0)
+closest = estimate[:closest_area]
+fewest_layers = estimate[:fewest_layers]
+```
+
+## VDE designation parsing
+
+The qualified [`LineCableModels.DataModel.vdeparse`](@ref) function decodes the
+supported VDE/DIN 0271 and 0276 designation fields:
+
+```julia
+fields = LineCableModels.DataModel.vdeparse("N2XS(FL)2Y 1x630/35 76/132 kV RM")
+```
+
+The returned fields describe the designation. Unparsed compact-token text is
+stored under `:unparsed_stub`.
+
+## Reference
+
+```@docs
+LineCableModels.DataModel.vdeparse
+Base.get
+Base.delete!
+```
+
+```@autodocs
+Modules = [
+ LineCableModels.DataModel.BaseParams,
+ LineCableModels.ParametricBuilder.WirePatterns,
+]
+Order = [:module, :constant, :type, :function, :macro]
+Public = true
+Private = false
+```
diff --git a/docs/src/conventions.md b/docs/src/conventions.md
index 268866c87..d6d38acf3 100644
--- a/docs/src/conventions.md
+++ b/docs/src/conventions.md
@@ -1,244 +1,460 @@
-# Package conventions
+# Conventions
----
+## Documentation language
-## Function and method names
+Use US English in docstrings and project documentation. Preserve API identifiers,
+file paths, quoted titles, and proper names. State what a calculation does,
+define its physical inputs and units, and distinguish implemented behavior from
+approximations and literature results. Omit promotional claims and process
+terminology when a concrete description suffices.
-### Multi-dispatch resolution pattern
-
-The codebase employs a consistent three-tier resolution pattern for handling user input processing through multi-dispatch. This standardized approach allows for predictable code organization and improved maintainability.
-
-#### Resolution pattern naming structure
+LineCableModels follows the SciML formatter style. Run:
```julia
-_resolve_ # Primary resolution function
-├─ _parse_inputs_ # Type conversion & normalization
-└─ _do_resolve_ # Core implementation logic
+using JuliaFormatter
+format(".")
```
-The naming pattern has been carefully selected to reflect the purpose of each dispatch layer:
-
-1. **`_resolve_`**: The primary entry point that coordinates the resolution process. The term "resolve" indicates that the function must determine an appropriate course of action based on input types that are not known in advance. This function validates inputs and delegates to specialized implementations.
-
-2. **`_parse_inputs_`**: The intermediate layer responsible for converting diverse input types into standardized forms that can be processed by the implementation layer. This function normalizes inputs through type-specific conversions.
-
-3. **`_do_resolve_`**: The implementation layer that performs the actual computations or transformations once inputs have been standardized. This function contains the core logic specific to each component type.
-
-#### Function scope and visibility
-
-All functions in this pattern are prefixed with an underscore (`_`) to indicate they are internal implementation details not intended for direct use by the package consumers.
+before committing Julia source.
-### Calculation vs. computation pattern
+## Native-first semantic economy
-The codebase distinguishes between calculation and computation methods through a clear naming convention:
+Use Julia's existing meanings before adding package vocabulary. Check, in
+order, whether the operation is already expressed by Core, Base, a standard
+library, a direct dependency, or an existing package generic.
-#### Calculation methods (`calc_`)
+New names require a domain meaning. They can describe an invariant or numerical method, identify an external format or represent real state. Mere forwarding and field access are insufficient reasons to add a name. Tuple repacking and merging defaults are also insufficient.
-Functions prefixed with `calc_` handle intermediate steps within a broader computational framework. These methods:
+| Prefer | Avoid |
+|:--|:--|
+| `Base.length`, iteration, indexing, `show`, and `showerror` | package copies of collection and display operations |
+| constructors, `promote`, and `convert` | a second conversion vocabulary |
+| another method on an owned generic | a synonym that forwards to that generic |
+| dispatch on a scientific type | a symbol switch or dictionary that repeats dispatch |
+| a local method beside its owner | a global `helpers` or `utils` bucket |
-- Perform specific mathematical operations on well-defined inputs.
-- Typically represent a single conceptual step in a larger process.
-- Return intermediate results that will be used by higher-level functions.
-- Are often associated with specific physical or mathematical formulations.
+For a result that represents a finite collection, implement Julia's collection
+methods as shown below:
-#### Computation methods (`comp_`)
-
-Functions prefixed with `comp_` represent higher-level operations that perform multiple calculation steps to achieve a complete analysis. These methods:
+```julia
+Base.length(result::MyResult) = length(result.values)
+Base.getindex(result::MyResult, index::Integer) = result.values[index]
+Base.iterate(result::MyResult, state...) = iterate(result.values, state...)
+```
-- Coordinate multiple calculation steps toward a final result.
-- Often work with complex objects rather than primitive types.
-- Represent the primary technical capabilities of the toolbox.
-- May store results in appropriate data structures for further processing.
+It does not add `get_results`, `result_count`, and `iterate_results` as parallel
+names.
-This distinction reflects the hierarchical nature of the [`LineCableModels.jl`](@ref) package, where individual calculations support the broader computational objectives of modeling transmission lines and cables.
+Small methods are appropriate when each method defines a dispatch choice or an
+invariant. Tiny forwarding helpers that only rename another operation are not.
+Do not add speculative compatibility shims, runtime `eval`, exception-driven
+feature tests, or lookup tables that duplicate Julia methods.
-### Library management pattern
+Mutating functions use `!` only when the operation can mutate an argument or
+externally visible state.
-The codebase implements a consistent pattern for managing libraries of models and components through standardized naming conventions. This pattern facilitates the storage, retrieval, and management of reusable objects within the framework.
+## Dispatch-driven fixed actions
-#### Library operations naming structure
+Use one public action when an operation has one required stage order. The
+action method shows the complete sequence, and concrete definition types add
+methods for the stages they implement.
```julia
-store_! # Add or update an object in a library
-remove_! # Remove an object from a library
-save_ # Writes the entire library contents to a file
-list_ # Display contents of a library
+function process(definition::AbstractDefinition, source)
+ selected = select(definition, source)
+ product = build(definition, selected)
+ return Product(product)
+end
```
-Each library operation follows a predictable naming convention:
+Good definition types are concrete and passive: their fields state scientific
+choices or completed configuration. Runtime work belongs to stage methods.
-1. **`store_!`**: Adds or updates objects in the specified library. The exclamation mark indicates that this operation modifies the library state.
+```julia
+struct FrequencyReport <: AbstractReportDefinition
+ requests::Tuple
+end
-2. **`remove_!`**: Removes an object from the specified library. The exclamation mark indicates that this operation modifies the library state.
+select(definition::FrequencyReport, source) =
+ observables(source, definition.requests)
+```
-3. **`save_`**: Persists the current state of the library to external storage, typically a file. This operation does not modify the library itself.
+Do not replace type-directed stages with a generic `mode`, `style`, or options
+dictionary interpreted by one large switch. Normalize public symbols once at
+the owner's entry point when symbols are part of the public syntax, then use
+the existing `Val` method family:
-4. **`list_`**: Displays the contents of the library for inspection without modifying its state.
+```julia
+owned_action(selector::Symbol, args...; kwargs...) =
+ owned_action(Val(selector), args...; kwargs...)
-### Object modification pattern
+owned_action(::Val{:example}, args...; kwargs...) = ...
+```
-For operations that modify components within larger structures (e.g., [`AbstractConductorPart`](@ref) within a parent [`ConductorGroup`](@ref)), the codebase employs the `addto_` prefix:
+Use an explicit no-op method only when doing nothing is a valid stage result.
+Reject unsupported definition and source pairs through required stage dispatch
+before partial work. Introduce a mutable context only when several stages
+share buffers, resources, or changing state. CI checks the fixed
+actions listed in [Commons invariants](developers.md) directly. Runtime
+metadata that merely repeats their method definitions is not part of the
+grammar.
+
+## Ownership-centered recursive module layout
+
+Place code first by the owner that defines when it changes, then by its precise
+responsibility. Files, directories, and Julia modules solve different
+problems:
+
+- a file separates one responsibility within an owner.
+- a directory groups several responsibilities that still belong to one owner.
+- a submodule supplies a separate namespace, dependency set, or stated
+ interface.
+- a package is warranted only when the code has independent users and releases.
+
+Grow code recursively:
+
+```text
+single responsibility in owner file
+└─ several responsibilities in owner directory, same module
+ └─ separate namespace or dependency set in child module
+ └─ independent package only when independently consumed
+```
-```julia
-addto_! # Add or modify a subcomponent within a larger component
+A module entry file is an index. It contains the module description, explicit
+imports, public names, includes in dependency order, and deliberate child
+reexports. Constructors, algorithms, validation, plotting descriptions, and
+format translations belong in focused files selected by the owner.
+
+Place a method according to the reason it changes. Keep `observe` methods beside the Engine results they expose. Place methods that draw native Makie figures in the Makie extension. Scientific
+observations and physical preview geometry remain with their owner. Plot
+request normalization, presentation groups, palettes, Makie blocks, layouts,
+widgets, callbacks, and backend activation do not. A method that parses an
+external file belongs with the format owner.
+
+Optional dependencies remain in package extensions. Core source may define
+package-neutral requests and completed values, but it does not import Makie,
+XLSX, Measurements, or Distributions.
+
+`ext/` holds package extensions only. The source of a core submodule is under `src/`,
+also when the submodule runs an external program. PSCAD is in `src/pscad/`. The project
+of its remote runner, which runs apart from the package, is in `src/pscad/remote/`.
+
+Prefer conceptual groupings such as:
+
+```text
+Owner
+├─ types
+├─ interfaces
+├─ constructors
+├─ action
+├─ Base protocols
+└─ owned optional translations
```
-The `addto_!` pattern:
+Avoid global mechanism-first trees such as `types/services/managers/handlers`,
+one module per file or per type, empty directory scaffolds, and a common base
+file that accumulates unrelated methods. Do not split a fixed call sequence
+across files merely to make each stage visually separate.
-- Indicates that a subcomponent is being added to or modified within a parent component.
-- Always includes an exclamation mark to denote state modification.
-- Typically invokes `calc_` methods to update derived properties.
+## Scientific reads, tables, and plots
-This pattern allows for hierarchical composition of components while maintaining a clear distinction from library management operations.
+`observe` reads native scientific values. `ObservedResult` captures detached
+products for one completed gridpoint. Ordinary collections lift that constructor.
+Reports, tables, plots, exports, and saved report inspection consume observations.
-### DataFrame view pattern
+```text
+completed numerical result + completed comparisons + recorded timings
+→ ObservedResult(gridpoint, quantities, errors, timings)
+→ retained selection → tables / existing Makie renderers / persistence
+```
-The codebase implements a consistent pattern for generating `DataFrame` views of complex objects through a standardized naming convention:
+The result owner implements `observation_quantity` and declares its requests.
+`Commons.observation_gridpoint` reads the description captured in completed result
+storage. Completion captures actual physical inputs, formulation selections and
+controls, coordinates, and original point identity. Basic descriptions are always
+retained. Observation and presentation never reconstruct a problem from lazy axes.
+
+The complete primary pairs are R/X, magnitude and angle of Z, or R/L for Z, and G/B,
+magnitude and angle of Y, or G/C for Y. Defaults are R/X and G/B. The strict constructor
+rejects an incomplete pair. Raw plotting conveniences use the same request
+normalizer with `complete_pairs=true`. `plot(line; ydata=(R,))` retains R/X and G/B
+and displays R. `plot(observed; ydata=(R,))` selects retained R only. Different
+representations require a new explicit construction. No consumer derives an
+absent quantity, repeats clipping or acquires a raw source.
+
+Re-observation selects retained products and preserves their recorded units by
+default. Explicit unit changes convert from those recorded units. They never
+reapply native-unit scaling or clipping. Comparison and timing associations
+survive selection. The atomic constructor validates coordinates, dimensions,
+units, masks, and completed-comparison records, including during archive loading.
+
+The shared `Commons.observation_product(points, request)` operation aligns matrix
+coefficients by original indices and converts compatible units for overlays.
+Each trace retains its original frequency samples without interpolation. Z and Y
+products may retain different frequency selections.
+
+UQ acquisition belongs to the UQ owner: `ObservedResult(uq, point, requests)` joins
+that point's primary values and requested statistics, samples, or histograms.
+Product requests omit the point index. Mean/std estimates and precomputed
+histogram/CDF/Q-Q coordinates remain ordinary records in `quantities`. Sampling
+information belongs to `gridpoint.sampling`. Uncertain primary values in an ordinary
+collection use the primary owner and preserve their dependencies.
```julia
-_todf # Convert entity to a `DataFrame` representation
+observed = ObservedResult(parameters, (R, L, G, C))
+tables = ReportBuilder.tabulate(observed) # tables.Z.R, tables.Z.L, tables.Y.G, tables.Y.C
+plot(observed; ydata=(R,))
```
-This pattern:
-
-- Takes an entity object as input and creates a `DataFrame` visualization.
-- Uses the suffix `_todf` to clearly indicate the conversion operation.
-- Produces non-mutating transformations (no exclamation mark needed).
-- Facilitates analysis, visualization, and reporting of complex data structures.
+Each quantity has its own table. A full n×n matrix on m frequencies has m rows and
+1+n² columns, in row-major coefficient order. Both off-diagonals remain present.
+Sparse and diagonal requests preserve original indices and matrix extent.
+Modal vectors use one `:vector` mode axis with original mode positions and
+retained frequency samples. Bare complex modal requests acquire Cartesian
+component pairs. Explicit components display only the selected product.
+`alpha` and `beta` share the physical identities of the real and imaginary
+parts of `gamma`, and `velocity` is a separate real quantity. Modal archives
+written before these coordinate and identity changes must be reacquired.
+`DataFrame(observed)` rejects aggregate conversion and directs the caller to a
+quantity leaf such as `ReportBuilder.tabulate(observed, R)`. `ObservedResult` is
+not a Tables.jl table. CableConstants quantity tables have one operating-frequency
+row and a named column per assembly. XLSX writes one numeric value and standard-deviation workbook per point and quantity.
+Native observation persistence preserves precision and uncertainty dependencies
+across the complete result-reference archive. XLSX preflights all destinations
+and worksheet sizes before writing. Existing files require `overwrite=true`.
+
+Explicit benchmark comparison precedes construction:
-The `_todf` suffix provides a concise and immediately recognizable identifier for functions that expose object data in tabular format.
+```julia
+completed = compare(reference, results, [R, L]; bands=(:all,))
+points = observables(results; comparisons=completed, timings=recorded_timings)
+reference_point = ObservedResult(reference)
+artifact = report(BenchmarkTableDefinition(), points; reference=reference_point)
+plot(artifact; ydata=(R,))
+```
----
+Comparison records join by original result identities. A reference remains
+outside the result collection. External data needs explicit retained identity
+for a benchmark. Absent physical descriptions remain explicitly absent. Arithmetic
+checks cover coordinates, dimensions, units, basis, and frequency agreement.
+Scientific comparability is the caller's responsibility. The samples retain their original frequencies without interpolation.
+`ReportArtifact.observed` and `.reference` contain only observations. `.tables`
+contains the organized tables. Raw conveniences construct and delegate once.
+
+`observation_groups` is the shared grouping owner. Eligibility requires the same
+physical point, relevant selected formulas and controls, quantity and statistical
+meaning, units, uncertainty interpretation, and coordinates. Exact numerical and
+dependency agreement verifies that eligibility. It does not discover equivalence.
+Conflicting numerical values under the same semantics and uncertainty dependencies
+raise a consistency diagnostic. Captured physical fields include owner-defined
+names and units. Common formulation fields are compared structurally before
+labels are formatted.
+All group identities, original observations, tables, and files remain available.
+
+## Numerical reporting
+
+An available scalar is engineering zero precisely when
+`abs(nominal(value)) <= cutoff`. Nonfinite and unavailable values are separate.
+Defaults per meter are R=1e-10 Ω/m, L=1e-15 H/m, G=1e-12 S/m, C=1e-16 F/m.
+X and B use 2πf times their L and C cutoffs. Total quantities scale by a retained
+physical length or require explicit cutoffs. L/C are unavailable at DC. No inferred
+`eps`, matrix-norm, largest-coefficient, or uncertainty contribution is added.
+Complex zero requires both Cartesian components to be zero. Polar products come
+from the original complex values. Clipped values become exact zero, including
+their uncertainty. Unclipped values and source computations preserve their
+uncertainty dependencies. Undefined first-order magnitude retains its components and zero
+nominal magnitude with an explicit reason. Its value and phase remain missing.
+
+Comparison classifies original operands first. Any ineligible sample makes both
+RMS metrics missing for that coefficient and band. Otherwise existing RMS mathematics
+applies to original values. Small and zero errors remain valid. Operand cutoffs
+are never applied to errors. Actual cutoffs, units, selections, settings, and
+missing reasons are retained.
+
+Recorded timings remain associated with the original result and separate
+reference in report tables. Matching elapsed times alone are insufficient to identify a shared event.
+A measurement for a whole computation retains that scope when several observed
+points include it. Reporting does not invent per-point timings.
+
+Each `ObservedResult` stores one point's inputs, selected formulations,
+quantities, comparisons, and execution measurements. Quantity records include
+basis, units, coordinates, applied cutoffs, availability, and missing-value
+reasons. `ReportBuilder.tabulate` creates tables from these records on demand.
+`ReportArtifact` retains `.observed`, a separate `.reference`, and `.tables`.
+FEM completion records identify the selected GetDP executable through
+`getdp_selection`. File checksums verify integrity. Source revisions identify
+the code used for the computation.
+
+## Text display and tables
+
+Human inspection (`show`), scientific extraction (`observe`), detached acquisition
+(`ObservedResult`/`observables`), and tabulation (`tabulate`) are separate actions.
+Each public owned type defines `summary`, two-argument `show`, and `text/plain`
+`show`. Display reads stored state only. It must not run builders, comparisons,
+solvers, or lazy-grid materialization. Ordinary report display renders retained
+tables and never constructs a figure implicitly. Existing Makie axes and the
+plot window own drawing, controls, layout, and backend behavior.
+
+## Docstrings
+
+Docstrings use DocStringExtensions abbreviations so declarations remain aligned
+with the implementation.
+
+### Placement and content
+
+- Place a docstring immediately before the documented module, type,
+ constructor, function, or constant.
+- Use triple double quotes, except for concise field and constant docstrings.
+- Describe implemented behavior. Do not infer equations, units, or defaults
+ from a name.
+- Describe the current API directly. Keep development history, removed APIs,
+ and migration notes out of reference documentation and docstrings.
+- State each fact once.
+- Link related local bindings inline when the relationship helps the reader.
+
+Use `@doc` for an inner constructor written inside a `struct`. An outer
+constructor at module scope uses an ordinary preceding docstring.
+
+### Physical quantities and equations
+
+State the SI unit for every physical argument, return value, field, and
+constant. In Julia docstring source, escape square brackets and LaTeX commands:
-## Module organization
+```julia
+"Series resistance `\\[Ω/m\\]`."
+"Relative permeability `\\[dimensionless\\]`."
+```
-### Exports and visibility
+Comments inside Julia examples use ordinary brackets, such as `# [m]`.
-- Public API functions have no underscore prefix and are explicitly exported.
-- Internal functions use leading underscore prefix (`_function_name`) and are not exported.
-- Modules use `@reexport` to propagate exports from submodules to parent modules.
-- Exports are placed at the top of each module for visibility.
+When code directly evaluates a physical law, approximation, or reduction that
+matters to the method's meaning, include the equation and define its symbols:
-### Module structure
+````julia
+"""
+$(TYPEDSIGNATURES)
-- Main module (`LineCableModels`) reexports from submodules.
-- Submodules (`DataModel`, `Materials`, etc.) handle their own exports.
-- Maximum nesting depth is 3 levels (parent → child → grandchild).
-- Documentation is maintained at all levels using `DocStringExtensions`.
+Return the series impedance:
-### Code navigation
+```math
+Z(f) = R + \\mathrm{j} 2 \\pi f L,
+```
-- Module docstrings should list key exported functions and types.
-- All exported items require docstrings.
-- Internal implementation details use minimal documentation.
+where ``R`` is resistance, ``L`` is inductance, and ``f`` is frequency.
+"""
+````
----
+Accessors, forwarding methods, and bookkeeping functions do not need a
+mathematical section unless they evaluate the documented expression.
-## Framework design pattern
+### DocStringExtensions abbreviations
-### Core architecture
+- `$(TYPEDSIGNATURES)` is the default opening for functions and constructors.
+- `$(SIGNATURES)` is suitable when typed signatures obscure the public call.
+- `$(FUNCTIONNAME)` keeps executable examples aligned with renames.
+- `$(TYPEDEF)` inserts a type declaration.
+- `$(TYPEDFIELDS)` inserts fields, declared types, and field docstrings.
+- `$(FIELDS)` omits declared field types when they would distract from the
+ public meaning.
+- `$(METHODLIST)` is reserved for a multi-method interface whose purpose is to
+ list implementations.
+- `$(IMPORTS)` and `$(EXPORTS)` maintain a module inventory.
-The `LineCableModels.jl` package implements a consistent framework pattern across all modules, designed to provide flexibility while maintaining type stability and performance. The architecture is built around three key components:
+Do not repeat generated text by hand.
-1. **Problem definition**: Defines the physics/mathematical approach
-2. **Solver**: Controls execution parameters
-3. **Workspace**: Centralizes all state during computation
+### Function structure
-This pattern separates *what* is being calculated (problem definition, formulation) from *how* calculations are executed (engine used, solver) and *where* data is stored (workspace).
+Use this section order, omitting sections that do not add information:
-### The workspace pattern
+1. description and any implemented equation.
+2. `# Arguments`.
+3. `# Keywords`.
+4. `# Returns`.
+5. `# Notes` for assumptions or limitations.
+6. `# Errors` for deliberate exceptions.
+7. `# Examples`.
-At the core of the framework is the Workspace pattern, exemplified by `FEMWorkspace` in the `FEMTools` module. This pattern can be replicated across other modules (e.g., `EMTWorkspace`).
+````julia
+"""
+$(TYPEDSIGNATURES)
-A workspace:
+Describe the implemented operation.
-- Acts as a centralized state container.
-- Stores all intermediate computation data.
-- Manages entity tracking and lookup tables.
-- Provides consistent interfaces for data access.
-- Maintains configuration and results.
+# Arguments
-### Type hierarchy
+- `value`: Physical input `\\[unit\\]`.
-Each module follows a consistent type hierarchy:
+# Keywords
-```julia
-AbstractProblemFormulation
- ├── FEMFormulation :> {Darwin, Electrodynamics, ...}
- ├── AbstractFDEMFormulation :> {CPEarth, CIGRE, ...}
- ├── AbstractEHEMFormulation :> {EnforceLayer, EquivalentSigma, ...}
- ├── ...
- └── [Other specialized formulations, concrete or abstract]
-
-AbstractWorkspace
- ├── FEMWorkspace
- ├── EMTWorkspace
- ├── ...
- └── [Other specialized workspaces]
-
-AbstractEntityData
- └── [Domain-specific entity types]
-```
+- `basis`: `:pul` or `:total`. Default: `:pul`.
-This hierarchy enables both specialization and shared interfaces.
+# Returns
-### Data flow
+- Completed value in `\\[unit\\]`.
-Data flows through the system in a consistent pattern:
+# Errors
-1. System definition (LineCableSystem instance).
-2. Problem definition (physics parameters, formulations to employ).
-3. Solver configuration (execution parameters).
-4. Workspace initialization (state container).
-5. Execution (multi-phase processing).
-6. Result extraction (from workspace).
+- Throws `ArgumentError` when `basis` is unsupported.
-This pattern applies regardless of the specific module or calculation type.
+# Examples
-### Multi-phase processing
+```jldoctest
+result = $(FUNCTIONNAME)(1.0; basis=:pul) # [unit]
+@assert isfinite(result)
+# output
+```
+"""
+````
-All modules implement a multi-phase execution pattern with clear separation between phases. For example, the `FEMTools` module follows this pattern:
+List arguments in declaration order and document each returned tuple member.
+Prefer `jldoctest` for a self-contained public example. Examples requiring an
+external executable, graphical interaction, network access, or repository
+fixtures belong in the developer guides.
-1. **Initialization phase**: Setup workspace, load configurations.
-2. **Construction phase**: Create entities based on system definition (may include specific preliminary tasks, e.g. fragments/synchronization steps FEM simulations).
-3. **Processing phase**: Execute main computation loops, store raw results in workspace container.
-4. **Post processing phase**: Assign properties to processed entities.
-5. **Result phase**: Extract and format results.
+### Type and module structure
-This pattern ensures clean separation of concerns, making the code more maintainable.
+Use `$(TYPEDEF)` and `$(TYPEDFIELDS)` for a type. Put a concise docstring above
+each field:
-### State management
+````julia
+"""
+$(TYPEDEF)
-State is managed exclusively through the Workspace, which contains:
+Represent a cable section.
-1. **Configuration state**: Original system, formulation, and opts.
-2. **Entity state**: Collections of typed entities.
-3. **Lookup maps**: Efficient mappings between entities and properties.
-4. **Processing state**: Temporary calculation state.
-5. **Result state**: Final calculation outputs.
+$(TYPEDFIELDS)
+"""
+struct CableSection{T <: Real}
+ "Section thickness `\\[m\\]`."
+ thickness::T
+end
+````
-This centralized approach eliminates global state and ensures thread safety.
+A module docstring begins with the indented module name, states its purpose,
+then uses `$(IMPORTS)` and `$(EXPORTS)` when those lists aid the reader. For a physical constant, use a concise single-line docstring with its symbol and SI
+unit.
-### Implementation example: FEMTools.jl
+## Repository practice
-The FEMTools module exemplifies this pattern:
+Stable releases follow [Semantic Versioning](https://semver.org/).
-- `FEMFormulation`: Physics parameters for FEM simulation.
-- `FEMSolver`: Execution parameters for meshing and solving.
-- `FEMWorkspace`: Central state container for all FEM operations.
-- Entity types: Typed data containers for different geometric elements.
-- Multi-phase workflow: Creation → Fragmentation → Identification → Assignment → Meshing → Solving → Post-processing.
+Commit subjects use scoped Conventional Commits, begin with a lowercase
+description, and stay within 72 characters:
-### Extension to new modules
+```text
+fix(engine): reject unsupported formulation options
+```
-When creating new modules, the following patterns should be followed:
+Every change includes tests at the closest relevant scope. Core tests load only required packages. CairoMakie is an optional dependency. Its rendering extension activates when
+CairoMakie and Makie are loaded. Rendering and other extension paths run in their
+dedicated test environments. Public examples should be executable and self-contained.
-1. Define problem & formulation type (physics parameters).
-2. Define solver type (execution parameters).
-3. Define workspace type (state container).
-4. Implement entity types specific to the domain.
-5. Implement multi-phase workflow with clear separation.
-6. Use the workspace pattern for state management.
-7. Follow the standard data flow.
+## Testing requirements
-This framework ensures consistency, maintainability, and performance across all modules within the package.
+The [developer testing requirements](developers.md#testing-requirements) define
+release status, regression terminology, test scope, architecture, and the
+95% coverage gate.
+The harness verifies correctness of the implementation and architectural conformance.
+Scientific acceptance is outside it. User-selected numerical snapshots are
+deferred until after the first stable publication. Do not duplicate those requirements
+as a separate validation or baseline-approval scheme.
diff --git a/docs/src/data-model.md b/docs/src/data-model.md
new file mode 100644
index 000000000..ba0537851
--- /dev/null
+++ b/docs/src/data-model.md
@@ -0,0 +1,1018 @@
+# Cable data model
+
+Cable construction follows one path:
+
+```text
+physical declaration
+ ↓
+ @cable / build
+ ↓
+completed CableDesign
+ ↓
+ @system / build
+ ↓
+completed LineCableSystem ──────────┐
+ ├→ LineParametersProblem → compute → LineParameters
+@earth / build → EarthModel ────────┘
+```
+
+The construction macros specify physical order and electrical terminals.
+
+## Constructing a cable
+
+The following declaration describes one coaxial cable. Expressions inside
+`@cable` run from the center outward. `@terminal` assigns one electrical name
+to every conductive descendant in its block.
+
+```julia
+copper = Material(kind=:conductor, rho=1.7241e-8)
+xlpe = Material(kind=:insulator, rho=1.0e14, eps_r=2.3)
+
+design = @cable "example" begin
+ @terminal :phase begin
+ core(copper; r=10e-3)
+ insulation(xlpe; t=8e-3)
+ end
+end
+```
+
+`design` is a completed `CableDesign`. A second resolution call is unnecessary.
+Its physical declaration remains authoritative while geometry and terminal
+indices are derived once by `build`.
+
+Place the cable, complete the system declaration, and construct an ordinary
+computation problem:
+
+```julia
+system = @system "example-system" line_length=1.0 begin
+ @at design (0.0, -1.0) phase=1
+end
+
+problem = LineParametersProblem(
+ system;
+ earth_props=homogeneous(rho=100.0),
+ frequencies=[50.0],
+)
+parameters = compute(problem)
+```
+
+System `phase` values are one-based active phase identifiers. They label retained
+electrical matrix coordinates and include no voltage polarity or phase-angle
+meaning. Use `0` for a conductor selected as a grounded or eliminated conductor by
+the line-parameter reduction.
+
+System placement composes an outer transform with the design. It does not
+rewrite the design's local geometry.
+
+## Declaring earth
+
+`homogeneous(...)` declares one homogeneous earth. A layered model is
+declared from the earth surface downward with `layer(...)`. The semi-infinite air
+layer is implicit:
+
+```julia
+earth = @earth begin
+ layer(rho=100.0, eps_r=10.0, thickness=5.0)
+ layer(rho=500.0, eps_r=20.0)
+end
+```
+
+Omitting `thickness` makes that earth layer semi-infinite, so layers with a
+finite thickness must precede the bottom half-space. Use
+`@earth vertical_layers=true` for vertical interfaces, or supply
+`air_layer=layer(...)` when the air properties must be explicit. Layer
+properties may contain `Grid` values. The result is then a
+`Gridspace{EarthModel}` through the same construction path.
+
+The completed `EarthModel` is immutable. Its read-only `layers` tuple begins
+with the implicit or explicitly supplied air layer, followed by the declared
+earth layers in block order. Programmatic code that already has a complete
+layer collection uses `build(EarthModel, layers; ...)`. Earth models are never
+extended incrementally with `add!`.
+
+## Terminals and physical tags
+
+Terminal names and physical tags have different jobs:
+
+```julia
+@terminal :a begin
+ core(copper; r=4e-3, tag=:core)
+ insulation(xlpe; t=2e-3, tag=:insulation)
+end
+```
+
+`:a` is the retained electrical terminal. `:core` and `:insulation` identify
+physical regions locally. The same local tags may be used under another
+terminal because qualified identities are derived from the tree.
+
+A nonconductive repeated group is valid and contributes no terminal. Request a separate terminal explicitly for a conductive screen:
+
+```julia
+@terminal :screen begin
+ sheath(copper; t=0.5e-3, tag=:metallic_screen)
+end
+```
+
+## Repeated conductors
+
+`stranded` accepts circular wire shapes. For a `Disk` geometric boundary, it infers the
+largest complete family of concentric courses: one center wire and exactly
+`6k` wires in course `k`. With `compact=nothing`, those wires remain circles on
+their natural course radii.
+
+```julia
+core_part = stranded(
+ copper;
+ shape=Disk(0.5e-3),
+ lay=LayRatio(13, 12, 11),
+ boundary=Disk(4.0e-3),
+)
+```
+
+`boundary` is the authoritative finished core. The wire radius and geometric boundary
+infer the maximum complete `6k` course inventory. A lay schedule must match the
+radial courses found by that inventory. No `Ring`, layer count, or
+wire count is stored as a second description of the same geometry.
+
+Omitting `compact` preserves the natural circular wires. `compact=true`
+selects the maximum complete `6k` inventory admitted by area and deforms every
+strand while preserving its source area:
+
+```julia
+compact_core = stranded(
+ copper;
+ shape=Disk(0.5e-3),
+ compact=true,
+ lay=LayRatio(11),
+ boundary=Disk(sqrt(7) * 0.5e-3),
+)
+```
+
+A `Sector` geometric boundary infers one bundle-center strand and complete `6k` courses,
+with total inventory `1 + 3L(L+1)`. The circular sites are mapped into the
+resolved sector and retained while a prescribed-area power diagram allocates
+space. Each strand is a disk grown inside its allocated cell until its clipped
+area matches the source wire area. Free portions remain round. Contact faces
+follow cell boundaries. Sector deformation is intrinsic, so it has no separate
+compaction choice. All remaining area belongs to the declared fill material.
+
+Course schedules are ordinary physical tuples. Wrap a complete schedule in
+`Grid` only when the schedule itself should vary.
+
+A stranded construction may prescribe the aggregate geometric boundary separately from
+the geometry of each strand:
+
+```julia
+sector_core = stranded(
+ copper;
+ shape=Disk(0.5e-3),
+ boundary=Sector(
+ span=deg2rad(119),
+ r_base=1.10e-3,
+ r_back=10.24e-3,
+ fillet=1.02e-3,
+ ),
+)
+```
+
+`shape` specifies each source strand and its conserved area, while sector
+reconstruction determines the resolved outline. The geometric boundary constrains
+the aggregate shape without adding a homogeneous conductor or a fictitious wire
+at the cable origin.
+Rectangular strands are admitted only in a circular core with an explicit
+center wire. They are always bent area-preservingly into annular courses.
+
+## Independent cores and enclosures
+
+`@assembly` preserves the terminals of independent members:
+
+```julia
+phase_a = @terminal :a begin
+ core(copper, Sector(span=pi / 3, r_base=1e-3, r_back=4e-3))
+ insulation(xlpe; t=1e-3)
+end
+
+phase_b = @terminal :b begin
+ core(copper, Sector(span=pi / 3, r_base=1e-3, r_back=4e-3))
+ insulation(xlpe; t=1e-3)
+end
+
+members = @assembly begin
+ @at phase_a (-5e-3, 0.0)
+ @at phase_b ( 5e-3, 0.0) φ=pi
+end
+```
+
+`pipe` and `duct` describe containment. Both accept block notation when several
+members make the physical nesting clearer:
+
+```julia
+contained = @pipe shape=Disk(15e-3) fill=air begin
+ @at phase_a (-5e-3, 0.0)
+ @at phase_b ( 5e-3, 0.0)
+end
+```
+
+Nested ducts use the same operation. There is no separate duct-bank object.
+
+When repeated sectors share an origin, their intrinsic span must equal the
+angular pitch imposed by the repetition. This keeps neighboring sides parallel.
+Bare sectors must retain positive physical side clearance. A conformal outer
+insulation may close that clearance to zero, but it may not overlap its
+neighbor.
+
+## Stored construction types
+
+The notation and practical functions lower immediately to the stored grammar:
+
+```text
+Material + primitive
+ ↓
+ Region
+ ↓
+Stack / Group / Assembly / Enclosure
+ ↓
+ build
+ ↓
+ CableDesign
+```
+
+`Stack` owns ordered outward composition. `Group` repeats and coalesces
+conductive descendants into zero or one terminal. `Assembly` preserves child
+terminal identities. `Enclosure` defines a containing domain together with its fill and contents, and an optional wall. These types remain available for extension
+code and unusual geometry. Ordinary cable declarations use the vocabulary
+shown above.
+
+Primitives describe intrinsic local geometry. Construction resolves them
+against a geometric boundary and records absolute geometry in `PlacedRegion` values
+inside `CableGeometry`. `EmptyBoundary` represents the first stacking state.
+
+Adding a primitive requires local `resolve`, `boundary`, `area`, `centroid`, and
+`support` methods rather than a central type switch.
+
+## Sector cores
+
+`Sector` describes one material-neutral cable-sector cross-section with an
+optional fillet. Its symmetry axis is local `+x`. Placement and terminal
+identity remain outside the primitive.
+
+```julia
+sector = Sector(
+ span=2pi / 3,
+ r_base=1.10e-3,
+ r_back=10.24e-3,
+ fillet=1.02e-3,
+)
+
+phase = @terminal :phase begin
+ solid(copper, sector; tag=:core)
+ insulation(xlpe; t=0.5e-3)
+end
+
+sectorized = @cable "three-core-sector" begin
+ cores(phase; n=3, r=0, names=(:a, :b, :c))
+end
+```
+
+`resolve` derives exact arc contacts and composes each `Pose2`. A pose applied to a
+point, `pose(point)`, rotates the point and then translates it. `area`,
+`perimeter`, `centroid`, and `support` use the exact geometric boundary. `tessellate`
+returns ordinary coordinate tuples solely for renderers and mesh adapters.
+Resolving `Shell(t)` against one sector produces its exact parallel boundary.
+A common layer around the disconnected assembly requires an explicit
+`Enclosure`.
+
+The package-owned formulation converts each sector conductor and conformal
+dielectric to equivalent-area circles only while constructing its numerical
+input. The exact sectors and their centroids remain authoritative in the
+completed design and in persisted declarations.
+
+`design.origin` retains the physical declaration used to build the cable.
+`design.geometry` is its resolved geometry. Serialized cable-design records
+store the declaration under `"origin"`. The outer JSON document uses `"root"`
+for its top-level value.
+
+## Parameter spaces
+
+Only an explicit `Grid` introduces variation:
+
+```julia
+designs = @cable "radius-study" begin
+ @terminal :phase begin
+ core(copper; r=Grid((8e-3, 10e-3, 12e-3)))
+ insulation(xlpe; t=8e-3)
+ end
+end
+
+eltype(designs) === CableDesign
+```
+
+Every block macro forwards `combine=:product` or `combine=:zip` to the same
+construction used by its functional form. `@cable` also accepts
+`nominal_data=(...)`. That descriptive data follows each completed design
+without affecting its physical resolution.
+
+Iteration and stochastic realization return completed ordinary designs. Raw
+tuples, vectors, polygon points, course schedules, and frequency vectors remain
+atomic unless wrapped in `Grid`.
+
+## Formulation support
+
+A physically valid design may build even when a formulation does not support
+its geometry. `DataModel.flatten(design, frequency)` performs local scalar circuit
+reductions. It combines conductor resistances in parallel, calculates GMR
+recursively and combines dielectric admittances in series. Inverse conversions
+then give effective material properties. The returned component payload is flat,
+and the design remains unchanged.
+
+`homogenize(design)` is an explicit request for a new homogeneous
+`CableDesign`. Independent assembly members are flattened separately. The
+operation calculates no mutual coupling, earth return, or line-parameter
+matrix. The selected engine separately validates whether its formulation
+supports the resulting cable topology.
+
+Homogeneous dielectric geometry retains a `RadialDielectric`: the original
+insulation and semicon materials with their logarithmic radius weights.
+In computation, the selected law is evaluated for each constituent at each
+frequency, and the radial responses combine in series.
+`:default` remains lossless before and after homogenization. Explicitly
+selected lossy formulations retain their frequency dependence. The displayed
+resistivity is the DC series value and the displayed permittivity is the
+lossless series value, not a broadband complex-permittivity fit.
+
+Scalar exports such as PSCAD select a formulation and a reference frequency
+explicitly. Their native frequency laws remain approximations to the original
+multi-material response away from that reference point.
+
+`CableConstants(design)` uses this reduction through the
+Engine-owned `CableConstantsProblem → CableConstantsFormulation → compute`
+workflow. It evaluates one concentric assembly at a time at the requested
+temperature and frequency. It contains no earth-return calculation. The
+innermost terminal is active and every outward terminal is grounded. The
+result stores aligned `R`, `L`, `C`, and `G` vectors with one entry per
+assembly.
+
+## Persistence and tables
+
+JSON records authoritative materials, physical declarations, patterns, paths,
+compaction laws, poses, terminal names, tags, and explicit Grid declarations.
+Decoding invokes the same constructors. Resolved geometry, terminal
+maps, engine workspaces, and solver results are not serialized.
+
+Model declarations use their bounded `text/plain` displays. Electrical tables
+come from `CableConstants(design)` or observed `LineParameters` results.
+
+## Supported conductor formations
+
+The following gallery uses the high-level block macros: `@cable` completes a
+design, `@terminal` names its conductive descendants, and `@pipe` / `@duct`
+express containment. Shapes and leaf operations such as `stranded` remain
+ordinary calls inside those blocks. Each example uses simple insulation so
+the conductor formation remains visible.
+
+### Solid circular and sector cores
+
+A solid conductor may use either a circular disk or the exact filleted sector
+primitive. Insulation follows the resolved geometric boundary in both cases.
+
+```@example supported_formations
+using LineCableModels
+using CairoMakie
+
+gallery_copper = Material(kind=:conductor, rho=1.7241e-8, mu_r=1.0)
+gallery_xlpe = Material(kind=:insulator, rho=1.0e14, eps_r=2.3)
+gallery_air = Material(kind=:insulator, rho=Inf, eps_r=1.0)
+gallery_jacket = Material(kind=:insulator, rho=1.0e13, eps_r=2.8)
+gallery_interstitial = Material(kind=:insulator, rho=2.0e12, eps_r=3.5)
+gallery_concrete = Material(kind=:insulator, rho=100.0, eps_r=5.0, mu_r=1.0)
+
+solid_circular = @cable "solid-circular" begin
+ @terminal :core begin
+ core(gallery_copper; r=3.5e-3)
+ insulation(gallery_xlpe; t=1.0e-3)
+ end
+end
+
+solid_sector = @cable "solid-sector" begin
+ @terminal :core begin
+ solid(
+ gallery_copper,
+ Sector(
+ span=deg2rad(118),
+ r_base=1.5e-3,
+ r_back=7.0e-3,
+ fillet=0.6e-3,
+ );
+ tag=:core,
+ )
+ insulation(gallery_xlpe; t=1.0e-3)
+ end
+end
+
+preview(
+ [solid_circular, solid_sector];
+ backend=:cairo,
+ display_plot=false,
+ controls=false,
+).figure
+```
+
+### Circular stranded cores
+
+Omitting `compact` preserves each strand as a circle on its concentric `6k`
+courses. `compact=true` infers a complete `6k` inventory by area and deforms it
+while preserving each strand area and identity.
+
+```@example supported_formations
+circular_strand_radius = 0.45e-3
+circular_strand_count = 1 + 6 + 12
+
+circular_stranded = @cable "circular-stranded" begin
+ @terminal :core begin
+ stranded(
+ gallery_copper;
+ shape=Disk(circular_strand_radius),
+ lay=LayRatio(14, 12),
+ boundary=Disk(5circular_strand_radius),
+ fill=gallery_air,
+ )
+ insulation(gallery_xlpe; t=1.0e-3)
+ end
+end
+
+circular_compacted = @cable "circular-compacted" begin
+ @terminal :core begin
+ stranded(
+ gallery_copper;
+ shape=Disk(circular_strand_radius),
+ lay=LayRatio(14, 12),
+ compact=true,
+ boundary=Disk(sqrt(circular_strand_count) * circular_strand_radius),
+ fill=gallery_air,
+ )
+ insulation(gallery_xlpe; t=1.0e-3)
+ end
+end
+
+preview(
+ [circular_stranded, circular_compacted];
+ backend=:cairo,
+ display_plot=false,
+ controls=false,
+).figure
+```
+
+Bounded circular and sector compaction preserves continuous input uncertainty:
+cell weights are differentiated through their prescribed-area constraint, and
+clipped strands through their area constraint. This retains motion of cell
+boundaries, sites, centroids, scale and fillets. It does not model mechanical
+deformation. Nominal polygons, strand areas, filler regions and terminal groups
+remain the same construction used by deterministic rebuilding.
+
+Course counts and clipping choices are discrete nominal decisions. Linear
+uncertainty propagation is valid locally within a stable topology, not across a
+course-count transition. A joint uncertainty declaration must preserve feasible
+geometry throughout its support. See [joint geometric inputs](gridspace.md#Feasible-geometric-dependence).
+
+### Rectangular stranded core
+
+Rectangular source strands require a circular center wire. They are always
+bent into contiguous annular courses. The radial deformation preserves the
+area of every source rectangle. A one-strand course remains a complete annulus.
+The specified `boundary` determines the strand inventory, but the resolved
+physical boundary is the actual occupied metal disk. No outer `stranded_fill`
+film is generated. Insulation starts at that occupied geometric boundary. Rectangular
+`stranded` declarations return a bounded `Group`, while circular and
+sector stranding retain their material-complete `Enclosure` declarations.
+
+When a filled circular-wire course follows the core, its declared filler fills the space from the occupied core boundary to the course enclosure, including
+the interstices between wires. Explicit wire-center radii remain fixed.
+Contextual `Ring(...; r=nothing)` radii follow the occupied core boundary.
+Explicit enclosing layers and coatings are retained, including thin layers.
+
+```@example supported_formations
+rectangular_stranded = @cable "rectangular-stranded" begin
+ @terminal :core begin
+ stranded(
+ gallery_copper;
+ center=Disk(0.55e-3),
+ shape=Rectangle(0.75e-3, 0.35e-3),
+ lay=LayRatio(13),
+ boundary=Disk(3.5e-3),
+ fill=gallery_air,
+ )
+ insulation(gallery_xlpe; t=1.0e-3)
+ end
+end
+
+preview(
+ rectangular_stranded;
+ backend=:cairo,
+ display_plot=false,
+ controls=false,
+).figure
+```
+
+### Sectorized stranded cores
+
+A sectorized phase core contains its own bundle-center strand and complete
+`6k` courses. The source wire area and sector area determine the inventory.
+Mapped circular sites retain these identities. Prescribed-area power cells
+allocate space, and a clipped-disk area solve determines each copper shape.
+The polygons approximate circular arcs while preserving the source areas.
+
+The supplied 0.55 mm wire radius below admits 37 wires and 74.23% copper fill.
+The second design explicitly changes the wire radius to obtain 93% fill with
+the same inventory and geometric boundary. Compaction cannot remove the remaining void
+while preserving both the source wire area and its count.
+
+```@example supported_formations
+sector_boundary = Sector(
+ span=deg2rad(118),
+ r_base=1.5e-3,
+ r_back=8.0e-3,
+ fillet=0.6e-3,
+)
+sector_stranded = @cable "sector-stranded" begin
+ @terminal :core begin
+ stranded(
+ gallery_copper;
+ shape=Disk(0.55e-3),
+ boundary=sector_boundary,
+ fill=gallery_air,
+ )
+ insulation(gallery_xlpe; t=1.0e-3)
+ end
+end
+
+dense_sector_wire_radius = sqrt(
+ 0.93 * area(LineCableModels.DataModel.resolve(
+ LineCableModels.DataModel.EmptyBoundary(), sector_boundary
+ )) / (37pi)
+)
+sector_dense = @cable "sector-stranded-93-percent" begin
+ @terminal :core begin
+ stranded(
+ gallery_copper;
+ shape=Disk(dense_sector_wire_radius),
+ boundary=sector_boundary,
+ fill=gallery_air,
+ )
+ insulation(gallery_xlpe; t=1.0e-3)
+ end
+end
+
+preview(
+ [sector_stranded, sector_dense];
+ panel_titles=("supplied wire, 74.23% fill", "resized wire, 93% fill"),
+ backend=:cairo,
+ display_plot=false,
+ controls=false,
+).figure
+```
+
+### Three- and four-core sector cables
+
+A complete sector cable preserves the electrical identity of every core. Each
+solid or stranded sector has its own insulation before `cores` rotates
+the members into a three- or four-phase assembly. The outer `@pipe` fills the
+interstices and adds one common tubular insulation layer. A sector's angular
+span equals the assembly pitch, which makes each pair of facing wedge sides
+parallel. The rows below show solid and intrinsically compacted stranded cores.
+At the supplied 0.35 mm strand radius, both stranded cores contain 37 wires.
+The three-core sector has 66.62% copper fill and the four-core sector 88.53%.
+
+```@example supported_formations
+three_core_sector = Sector(
+ span=2pi / 3,
+ r_base=0.60e-3,
+ r_back=5.0e-3,
+ fillet=0.20e-3,
+)
+four_core_sector = Sector(
+ span=pi / 2,
+ r_base=0.60e-3,
+ r_back=5.0e-3,
+ fillet=0.20e-3,
+)
+
+sector_wall = insulation(gallery_xlpe; t=0.8e-3, tag=:tubular_insulation)
+
+solid_three_member = @terminal :segment begin
+ solid(gallery_copper, three_core_sector; tag=:solid_sector)
+ insulation(gallery_xlpe; t=0.25e-3)
+end
+solid_three_core_sector = @cable "solid-three-core-sector" begin
+ @pipe shape=Disk(5.5e-3) fill=gallery_air wall=sector_wall begin
+ cores(solid_three_member; n=3, r=0, names=(:phase_1, :phase_2, :phase_3))
+ end
+end
+
+solid_four_member = @terminal :segment begin
+ solid(gallery_copper, four_core_sector; tag=:solid_sector)
+ insulation(gallery_xlpe; t=0.25e-3)
+end
+solid_four_core_sector = @cable "solid-four-core-sector" begin
+ @pipe shape=Disk(5.5e-3) fill=gallery_air wall=sector_wall begin
+ cores(solid_four_member; n=4, r=0, names=(:phase_1, :phase_2, :phase_3, :phase_4))
+ end
+end
+
+stranded_three_member = @terminal :segment begin
+ stranded(
+ gallery_copper;
+ shape=Disk(0.35e-3),
+ lay=LayRatio(14),
+ boundary=three_core_sector,
+ fill=gallery_air,
+ )
+ insulation(gallery_xlpe; t=0.25e-3)
+end
+stranded_three_core_sector = @cable "stranded-three-core-sector" begin
+ @pipe shape=Disk(5.5e-3) fill=gallery_air wall=sector_wall begin
+ cores(stranded_three_member; n=3, r=0, names=(:phase_1, :phase_2, :phase_3))
+ end
+end
+
+stranded_four_member = @terminal :segment begin
+ stranded(
+ gallery_copper;
+ shape=Disk(0.35e-3),
+ lay=LayRatio(14),
+ boundary=four_core_sector,
+ fill=gallery_air,
+ )
+ insulation(gallery_xlpe; t=0.25e-3)
+end
+stranded_four_core_sector = @cable "stranded-four-core-sector" begin
+ @pipe shape=Disk(5.5e-3) fill=gallery_air wall=sector_wall begin
+ cores(stranded_four_member; n=4, r=0, names=(:phase_1, :phase_2, :phase_3, :phase_4))
+ end
+end
+
+preview(
+ [
+ solid_three_core_sector,
+ solid_four_core_sector,
+ stranded_three_core_sector,
+ stranded_four_core_sector,
+ ];
+ layout=(2, 2),
+ panel_titles=(
+ "solid - three cores",
+ "solid - four cores",
+ "stranded - three cores",
+ "stranded - four cores",
+ ),
+ backend=:cairo,
+ display_plot=false,
+ controls=false,
+).figure
+```
+
+### Milliken core
+
+A Milliken core contains one cable-center wire and six separately bounded stranded
+wire segments, each with its own bundle-center strand and `6k` courses.
+`milliken` binds every conductor to one terminal. The segment
+geometric boundaries govern packing without becoming overlapping material domains. One
+explicit fill occupies the interstices around the center and segment wires. Its
+center-wire radius is inferred after resolving one segment so that the center
+is tangent to the innermost strand in every rotated segment.
+
+This example deliberately uses a densely filled segment: a source-wire radius
+of approximately **0.238628 mm** gives **61 wires per segment**, with the
+inventory `1 + 6 + 12 + 18 + 24`, and **93% segment copper fill**. There are
+366 segment wires plus the shared central conductor. The latter has an inferred
+radius of approximately 0.560 mm. Including the inter-segment separators, the
+complete core is approximately **84.6% copper by area**. These dimensions describe an illustrative
+design. They were not measured from a cable photograph.
+
+The radius is chosen from ``a=\sqrt{\eta A_{\mathrm{segment}}/(61\pi)}``,
+with ``\eta=0.93``. Only the radius and geometric boundary are passed to `milliken`:
+the constructor still infers the complete-course inventory. Copper fill within
+a segment is distinct from copper fill across the complete core, which also
+includes the central conductor and the material between segments.
+
+```@example supported_formations
+milliken_segment_boundary = Sector(
+ span=pi / 3,
+ r_base=0.45e-3,
+ r_back=5.0e-3,
+ fillet=0.18e-3,
+)
+milliken_design = @cable "six-segment-milliken" begin
+ @terminal :phase begin
+ milliken(
+ gallery_copper;
+ shape=Disk(0.23862789195414474e-3),
+ segment=milliken_segment_boundary,
+ segments=6,
+ fill=gallery_interstitial,
+ )
+ insulation(gallery_xlpe; t=1.0e-3)
+ end
+end
+
+preview(
+ milliken_design;
+ title="Milliken - six 61-wire segments and a central conductor",
+ backend=:cairo,
+ display_plot=false,
+ controls=false,
+).figure
+```
+
+### Four-pair umbilical
+
+An umbilical can contain complete subcables rather than copies of one
+terminal. Here four screened two-core subcables surround a central polyethylene
+(PE) filler, with four more PE fillers between them. Each subcable contains two
+copper cores with high-density polyethylene (HDPE) insulation, two PE fillers,
+an explicit nonwoven (TNT) filling region, a copper sheath and a PE jacket.
+The complete assembly has thermoplastic elastomer (TPE) filling, an outer TNT
+wrap and a finite galvanized-steel armor layer.
+
+The dimensions and material constants below are **illustrative**, chosen to
+reproduce the supplied cross-section's composition without claiming a
+manufacturer specification. TNT is represented as a homogeneous insulating
+material. The galvanized steel is one effective material, without a separate
+zinc coating. This example demonstrates construction and preview, not support
+for this topology in every numerical backend.
+
+The ordinary Julia function describes one reusable subcable with the public
+macros. Its name prefix gives each copy two independent core terminals and one
+sheath terminal. Enclosing them in `@pipe` does **not** short them together.
+All dimensions in the code are in meters.
+
+```@example supported_formations
+umbilical_pe = Material(kind=:insulator, rho=1.0e15, eps_r=2.3)
+umbilical_hdpe = Material(kind=:insulator, rho=1.0e15, eps_r=2.4)
+umbilical_tnt = Material(kind=:insulator, rho=1.0e13, eps_r=3.0)
+umbilical_tpe = Material(kind=:insulator, rho=1.0e12, eps_r=3.5)
+umbilical_steel = Material(kind=:conductor, rho=1.5e-7, mu_r=100.0)
+
+function umbilical_pair(prefix)
+ first_core = @terminal Symbol(prefix, :_a) begin
+ core(gallery_copper; r=1.5e-3, tag=:copper_core)
+ insulation(umbilical_hdpe; t=0.65e-3, tag=:hdpe_insulation)
+ end
+ second_core = @terminal Symbol(prefix, :_b) begin
+ core(gallery_copper; r=1.5e-3, tag=:copper_core)
+ insulation(umbilical_hdpe; t=0.65e-3, tag=:hdpe_insulation)
+ end
+ pair_wall = @terminal Symbol(prefix, :_sheath) begin
+ sheath(gallery_copper; t=0.10e-3, tag=:copper_sheath)
+ jacket(umbilical_pe; t=0.35e-3, tag=:pe_jacket)
+ end
+ pair_filler = filler(umbilical_pe, Disk(1.45e-3); tag=:pe_filler)
+
+ return @pipe shape=Disk(4.5e-3) fill=umbilical_tnt wall=pair_wall begin
+ @at first_core (-2.25e-3, 0.0)
+ @at second_core (2.25e-3, 0.0)
+ @at pair_filler (0.0, -2.85e-3)
+ @at pair_filler (0.0, 2.85e-3)
+ end
+end
+
+umbilical_pair_design = @cable "umbilical-subcable" begin
+ umbilical_pair(:pair)
+end
+
+umbilical_legend_labels = Dict(
+ :pipe_fill => "TNT fill", :tpe_fill => "TPE fill",
+ :pe_filler => "PE filler", :pe_jacket => "PE jacket",
+ :hdpe_insulation => "HDPE insulation", :tnt_wrap => "TNT wrap",
+)
+
+preview(
+ umbilical_pair_design;
+ title="Umbilical subcable - two cores and a separate copper sheath",
+ legend_labels=umbilical_legend_labels,
+ backend=:cairo,
+ display_plot=false,
+ controls=false,
+).figure
+```
+
+The top and bottom subcables are rotated by 90° with `@at`. Their complete
+geometry rotates together. The 4 independent subcables retain **8 core
+terminals and four sheath terminals**, plus the common armor terminal.
+Each remaining region is assigned an insulating material, including the
+interstices. The construction excludes implicit air and unassigned gaps.
+
+```@example supported_formations
+umbilical_filler = filler(umbilical_pe, Disk(2.0e-3); tag=:pe_filler)
+umbilical_diagonal = 9.85e-3 / sqrt(2)
+umbilical_wall = @terminal :armour begin
+ bedding(umbilical_tnt; t=1.5e-3, tag=:tnt_wrap)
+ sheath(umbilical_steel; t=2.0e-3, tag=:galvanized_steel)
+end
+
+umbilical_design = @cable "four-pair-umbilical" begin
+ @pipe shape=Disk(12.5e-3) fill=umbilical_tpe wall=umbilical_wall begin
+ @at umbilical_pair(:east) (7.2e-3, 0.0)
+ @at umbilical_pair(:north) (0.0, 7.2e-3, pi / 2)
+ @at umbilical_pair(:west) (-7.2e-3, 0.0)
+ @at umbilical_pair(:south) (0.0, -7.2e-3, pi / 2)
+ umbilical_filler
+ @at umbilical_filler (umbilical_diagonal, umbilical_diagonal)
+ @at umbilical_filler (-umbilical_diagonal, umbilical_diagonal)
+ @at umbilical_filler (-umbilical_diagonal, -umbilical_diagonal)
+ @at umbilical_filler (umbilical_diagonal, -umbilical_diagonal)
+ end
+end
+
+preview(
+ umbilical_design;
+ title="Umbilical - four screened pairs with steel armour",
+ legend_group=region -> region.source.material == umbilical_tpe ?
+ :tpe_fill : region.source.tag,
+ legend_labels=umbilical_legend_labels,
+ backend=:cairo,
+ display_plot=false,
+ controls=false,
+).figure
+```
+
+### Rope formation
+
+`rope` repeats a complete stranded core as one central member surrounded by six
+peers. Here each member's six course wires have `LayRatio(12)` about their own
+member axis. The 6 outer members also have `LayRatio(18)` about the rope
+axis. These are separate paths: the accepted approximation **multiplies their
+overlength factors**. Both lays contribute to the overlength.
+The enclosing terminal and insulation belong to the finished rope.
+
+```@example supported_formations
+rope_member_radius = 0.28e-3
+strand_lay = LayRatio(12)
+rope_lay = LayRatio(18)
+rope_member = stranded(
+ gallery_copper;
+ shape=Disk(rope_member_radius),
+ lay=strand_lay,
+ compact=true,
+ boundary=Disk(sqrt(7) * rope_member_radius),
+ fill=gallery_air,
+)
+
+rope_design = @cable "stranded-rope" begin
+ @terminal :core begin
+ rope(
+ rope_member;
+ layers=1,
+ n=6,
+ lay=rope_lay,
+ )
+ insulation(gallery_xlpe; t=1.0e-3)
+ end
+end
+
+preview(
+ rope_design;
+ backend=:cairo,
+ display_plot=false,
+ controls=false,
+).figure
+```
+
+For local helix radius ``r`` and pitch ``p``, the length factor is
+``\lambda=\sqrt{1+(2\pi r/p)^2}``. With `LayRatio(q)`, ``p=2rq``, so
+``\lambda(q)=\sqrt{1+(\pi/q)^2}``, independent of the local radius.
+For a wire following both paths,
+
+```math
+\lambda_{\mathrm{wire}}=\lambda_{\mathrm{strand}}\lambda_{\mathrm{rope}}.
+```
+
+The central wire of the central member has neither path and factor 1. Its six
+neighbors have only the strand factor. The central wire of each outer member
+has only the rope factor. The remaining 36 wires have both. The table below
+evaluates these factors using [`overlength`](@ref). Its radius argument is
+immaterial for these `LayRatio` laws. A `Pitch` law would require the actual
+local radius of each path.
+
+```@example supported_formations
+strand_factor = overlength(Helix(strand_lay), rope_member_radius)
+rope_factor = overlength(Helix(rope_lay), rope_member_radius)
+
+using DataFrames
+DataFrame(
+ wires=["central wire", "central member: course", "outer members: centers",
+ "outer members: courses"],
+ count=[1, 6, 6, 36],
+ strand=[1.0, strand_factor, 1.0, strand_factor],
+ rope=[1.0, 1.0, rope_factor, rope_factor],
+ total=[1.0, strand_factor, rope_factor, strand_factor * rope_factor],
+)
+```
+
+These factors multiply each wire's resistance before the parallel reduction.
+One blanket factor is not applied to all 49 wires. The transverse wire areas
+and preview are unchanged by the lay choices.
+
+### 3 coaxial cores in a pipe
+
+3 independently named solid-core coaxials form a trefoil inside a circular
+pipe. The pipe includes one outward insulating wall.
+
+```@example supported_formations
+coaxial_member = @terminal :phase begin
+ core(gallery_copper; r=1.6e-3)
+ insulation(gallery_xlpe; t=1.2e-3)
+end
+coaxial_trefoil = cores(
+ coaxial_member;
+ n=3,
+ r=3.5e-3,
+ names=(:a, :b, :c),
+ φ0=-pi / 2,
+)
+coaxial_wall = jacket(gallery_jacket; t=1.0e-3, tag=:pipe_insulation)
+coaxial_pipe_design = @cable "coaxial-trefoil-pipe" begin
+ @pipe shape=Disk(7.0e-3) fill=gallery_air wall=coaxial_wall begin
+ coaxial_trefoil
+ end
+end
+
+preview(
+ coaxial_pipe_design;
+ backend=:cairo,
+ display_plot=false,
+ controls=false,
+).figure
+```
+
+### Mixed pipe formations in an elliptical duct
+
+The composition grammar is not restricted to geometries currently admitted by
+every numerical backend. Here a pipe containing three stranded coaxials sits
+beside a pipe containing three insulated sector-stranded cores, all inside one
+elliptical duct. The duct interior remains air. Its concrete wall is a finite
+normal offset of the ellipse rather than a zero-thickness outline.
+
+```@example supported_formations
+duct_round_member = @terminal :round begin
+ stranded(
+ gallery_copper;
+ shape=Disk(0.30e-3),
+ compact=true,
+ boundary=Disk(sqrt(7) * 0.30e-3),
+ fill=gallery_air,
+ )
+ insulation(gallery_xlpe; t=0.8e-3)
+end
+duct_round_trefoil = cores(
+ duct_round_member;
+ n=3,
+ r=2.8e-3,
+ names=(:r1, :r2, :r3),
+ φ0=-pi / 2,
+)
+duct_round_wall = jacket(gallery_jacket; t=0.8e-3, tag=:round_pipe_insulation)
+duct_round_pipe = @pipe shape=Disk(6.0e-3) fill=gallery_air wall=duct_round_wall begin
+ duct_round_trefoil
+end
+
+duct_sector_boundary = Sector(
+ span=2pi / 3,
+ r_base=0.8e-3,
+ r_back=3.8e-3,
+ fillet=0.3e-3,
+)
+duct_sector_member = @terminal :sector begin
+ stranded(
+ gallery_copper;
+ shape=Disk(0.30e-3),
+ boundary=duct_sector_boundary,
+ fill=gallery_air,
+ )
+ insulation(gallery_xlpe; t=0.6e-3)
+end
+duct_sector_trefoil = cores(
+ duct_sector_member;
+ n=3,
+ r=0.0,
+ names=(:s1, :s2, :s3),
+)
+duct_sector_wall = jacket(gallery_jacket; t=0.8e-3, tag=:sector_pipe_insulation)
+duct_sector_pipe = @pipe shape=Disk(5.5e-3) fill=gallery_air wall=duct_sector_wall begin
+ duct_sector_trefoil
+end
+
+duct_wall = jacket(gallery_concrete; t=1.5e-3, tag=:concrete_duct_wall)
+mixed_duct_design = @cable "mixed-elliptical-duct" begin
+ @duct shape=Ellipse(24.0e-3, 10.0e-3) fill=gallery_air wall=duct_wall begin
+ @at duct_round_pipe (-8.0e-3, 0.0)
+ @at duct_sector_pipe (8.0e-3, 0.0)
+ end
+end
+
+preview(
+ mixed_duct_design;
+ backend=:cairo,
+ display_plot=false,
+ controls=false,
+).figure
+```
diff --git a/docs/src/developers.md b/docs/src/developers.md
new file mode 100644
index 000000000..65ae1bc16
--- /dev/null
+++ b/docs/src/developers.md
@@ -0,0 +1,476 @@
+# Commons invariants
+
+LineCableModels has three global computation roles: a complete problem and a formulation that selects how to calculate it, followed by a completed result. Package
+modules may add concrete types below these roots, but they do not add parallel
+computation entry points.
+
+The type trees below are generated from the loaded package during the
+documentation build.
+
+```@example grammar_type_trees
+using LineCableModels
+
+println("Problem definitions")
+print(Main.DocumentationTrees.type_tree(AbstractProblemDefinition))
+println("\nFormulations")
+print(Main.DocumentationTrees.type_tree(AbstractFormulation))
+println("\nResults")
+print(Main.DocumentationTrees.type_tree(AbstractProblemResult))
+```
+
+`Combinatorial`, `LinearError`, and `MonteCarlo` occupy the same formulation
+tree. UQ uses the same computation supertypes. Its completed
+collections are subtypes of `AbstractUncertaintyResult`, while deterministic finite
+collections are subtypes of `AbstractParametricResult`.
+
+`LineParamsDomain` is independent of this computation grammar. It tags the
+physical coordinate system of a completed line-parameter matrix:
+
+```@example grammar_type_trees
+print(Main.DocumentationTrees.type_tree(LineCableModels.LineParamsDomain))
+```
+
+## Fixed actions
+
+One declarative action defines a fixed sequence selected by an abstract definition
+type:
+
+| Action | Definition root | Fixed sequence |
+|:--|:--|:--|
+| `ReportBuilder.report` | `AbstractReportDefinition` | `select → tabulate → illustrate → encode → write → ReportArtifact` |
+
+```@example grammar_type_trees
+using LineCableModels.ReportBuilder: AbstractReportDefinition
+
+println("Report definitions")
+print(Main.DocumentationTrees.type_tree(AbstractReportDefinition))
+```
+
+Each action has one method at its declared abstract root. Concrete definitions
+implement stage methods. They do not specialize the public action itself.
+Required stages are declared with RequiredInterfaces where the type family
+admits that form. Optional stages have an explicit no-op at the abstract root.
+Plotting is deliberately not such an action: the optional Makie extension
+constructs native figures directly from published observations.
+
+Observation uses a different structure. Result owners add `observe` methods.
+Commons owns one `observables(source, requests::Tuple; ...)` publication
+method. Standalone arrays and external result types do not need a shared
+abstract observation type.
+
+## Architecture checks
+
+The architecture that the quality, core and integration tests protect requires:
+
+- the action and its abstract root to belong to the same module.
+- one public action method, with no more-specific definition methods.
+- every fixed stage to remain visible in the declared action.
+- every package-owned concrete definition to implement its required stages.
+- every package-owned definition to remain below its declared root.
+- one Commons-owned observation publication method with positional requests.
+- wide scientific tables whose quantity and unit metadata remain attached to
+ their columns.
+- no calls to another package module's private functions or types, including
+ through aliases, and no private wrappers whose methods only forward unchanged
+ arguments to the same package-owned function. Owner-local numerical kernels
+ and type-dispatch branches remain valid.
+
+A new definition must satisfy these standards. Native interface and import checks,
+tests through actual composed consumers and the structural guards below provide the
+protection. Unchanged forwarding wrappers remain an advisory ReLint finding.
+
+The quality selection also runs advisory source diagnostics. Fatou applies the
+explicit rules in `fatou.toml`. ReLint identifies runtime-evaluation spellings,
+empty or constant-result catches, and unchanged private forwarding methods.
+These scans read all Julia files under `src/` and `ext/` without executing them.
+They report candidates for review, including typed forwarding methods whose
+dispatch purpose has not been determined. Macro expansion, arbitrary binding
+ownership, and global architectural redundancy are outside their analysis.
+Source findings do not fail the job. Tool setup errors, incomplete scans, and
+failed integration controls do. The scans leave source files unchanged and
+do not contribute executed-code coverage. See the
+[test instructions](https://github.com/Electa-Git/LineCableModels.jl/blob/main/test/README.md#advisory-source-diagnostics)
+for pinned tool installation, commands, and the retained CI report.
+
+Integration tests also count actual blueprint lowering calls: one per selected
+design point, shared across its formulation alternatives and frequency sweep.
+They check material evaluation before local calculations, paired exterior
+calculation before assembly and reduction at each frequency and explicit
+phase-to-modal result transport without another geometry lowering. The visual
+suite applies the ownership checks to the loaded Makie extensions and verifies
+that material colors consume `Material` or `EarthLayer` objects directly.
+
+`validate(subject, context...)` checks an input before its consumer uses it. It throws on
+invalid input and returns its subject. It may warn on input that is computable but
+questionable.
+
+The `validate` tests check owner dispatch, required interfaces, unchanged valid
+inputs and rejection of damaged inputs through validation and computation. The
+standards require checks to remain with the defining validator. Inspection of every method body requires a separate review beyond representative behavioral tests.
+
+The native FEM environment uses pinned GetDP 3.5.0. Keep tests of its actual
+execution, extraction, terminal identity, material and option transport and resume
+behavior. Physical cross-backend accuracy and domain-convergence acceptance
+belong to explicit research work under the testing requirements below. See the
+[test commands](https://github.com/Electa-Git/LineCableModels.jl/blob/main/test/README.md) for the implementation checks and their
+execution environments.
+
+The workspace provides the buffers, and formulas use them.
+`Commons.initialize_buffers` is the one action that builds buffers, for every formula and
+every part of the workflow. A formula or shared component builds its own buffers in its
+`initialize_buffers` method, usually as a named tuple of preallocated arrays. The method
+returns them in the workspace's `buffers` record. A named `*Buffers` type exists only for a
+component that several owners share, in `Commons`, such as `ReductionBuffers`. The
+storage vocabulary guard below checks the names.
+
+### Structural guards
+
+`test/quality/architecture.jl` enforces the ownership rule stated in `AGENTS.md`,
+which assigns one owner, one name and one implementation to a concept and one
+meaning to a name. The guards load the
+package with the extensions that `explicit_imports.jl` loads. They enumerate every
+package method and parse every Julia file under `src/` and `ext/`.
+
+- Ownership (`ownership`). A core method that extends a function owned by another package
+ module mentions a type owned by its own module or a descendant. For a constructor,
+ the function owner is the owner of the constructed type. For methods of the root
+ module, functions and types of its submodules count as owned by another module.
+ Extension methods are exempt.
+- Placement (`placement`). No core method is defined in a file under `ext/`. The home of a
+ module is the directory that holds its `.jl` file. The nearest home
+ around a method's file belongs to the method's module or one of its ancestors.
+- Direction (`direction`). The submodules have the order Units, Commons, TextDisplay,
+ PlotBuilder, Materials, Earth, DataModel, Engine, ParametricBuilder, UQ, ReportBuilder,
+ ImportExport, PSCAD. Engine's child modules, the formula families and ModalAnalysis, take
+ Engine's position. `MODULE_OWNERS` in
+ `test/support/taxonomy.jl` defines this order once, for the guards and for the test
+ runner. The lowered code of a submodule method references earlier submodules, the
+ ancestors and descendants of its own module, and no later submodule. Each top-level submodule has a position in
+ the order. The root module and the extensions are exempt.
+- Names (`names`). Functions and types owned by different package modules have
+ distinct names. The per-family `Formula` types are exempt.
+- Shadowing (`shadowing`). Each name that the root module exports is in scope after
+ `using LineCableModels`. It refers to the same object as the name that Base,
+ LinearAlgebra, Statistics, Random, Dates or Logging exports. A name that only a
+ submodule exports stays in that namespace, and a name that DataFrames also exports
+ does not conflict.
+- validate returns its subject (`validate`). Each `validate` definition names its first
+ positional argument. Each path through its body returns that argument or calls
+ `throw`, `rethrow` or `error`. A body that does more than return the argument also uses
+ it in that work, so an identity method passes and a check of another argument fails. The
+ guard reads source code. A path that it cannot classify counts as a violation. Its
+ baseline table is empty.
+- Reserved verbs (`reserved_verbs`). No function name starts with `validate_`, `check_`,
+ `require_`, `assert_`, `verify_` or `ensure_`, after any leading `_` characters.
+ `validate` is the input-check verb.
+- Symbol switches and probes (`switches`). Each source file has counts of `applicable`
+ calls, `@eval` calls, and comparisons of a `kind` field against symbols, negated
+ comparisons included. These counts can decrease and never increase.
+- Storage vocabulary (`storage`). A `*Workspace` struct has exactly the fields `input`,
+ `plan` and `buffers`, and optionally `trace`. No function name ends in `_workspace` or
+ `_storage`, and only `initialize_buffers` ends in `_buffers`. Only `Commons` defines a
+ `*Buffers` struct, and no type name ends in `Buffer`, `Storage`, `Scratch` or `Cache`.
+ No field, named tuple key, parameter, local or loop variable takes the name `work`,
+ `scratch`, `storage` or `cache`. The guard reads source code. The modal and
+ cable-constant workspaces keep other fields until their restructuring, and the
+ baseline lists them.
+- Dispatch convention (`dispatch`). A formula method takes the formula object first, and
+ its `Val` tags follow it. The guard examines each function with a method whose first
+ argument is a formula, a subtype of `AbstractFormulation`, or a `Union` that contains
+ one. Another method of that function whose first argument is a `Val`, or a `Union` that
+ contains one, is a violation. In any function, a first argument `Val{ID}`, or a `Union`
+ that contains one, where `ID` is an identifier that a formula family registers through
+ `formulas`, is a violation: it passes the formula as its tag. Type constructors are
+ exempt, such as `Formula(::Val{ID})`. The guard reads the method table. Its baseline
+ table is empty.
+
+`Commons` contains only definitions that several owners use. An owner is the root
+module, a top-level submodule or a package extension. The Commons guards stop
+helpers from accumulating elsewhere.
+
+- Commons admission (`commons`). Each function, type and constant defined in `Commons` is
+ exported or declared `public`, has a docstring and is named in a test file under
+ `test/unit/commons/`. A hook is a `Commons` function with a method defined outside
+ `Commons`. The user API is the `Commons` names that the root module exports or
+ declares `public`. Unless a name is user API, methods of at least two owners
+ outside `Commons` use it. Unless it is a hook or user API, it has an entry in the
+ reserved vocabulary. A use is a reference in lowered code, a method
+ signature, a method extension, a supertype or a field type. Uses by a public
+ `Commons` definition count for the definitions it uses. A definition with one
+ remaining owner moves to that owner. Algorithm-local helpers are nested functions
+ inside the public definition.
+- Reserved vocabulary (`vocabulary`). `VOCABULARY` maps each `Commons` public name to the
+ definition names it reserves. Outside `src/commons/`, no function, constant or
+ assigned local takes a reserved name. A local assigned from a call to the
+ reserving definition is exempt, as in `μ0 = vacuum_permeability(T)`.
+- Literal fingerprints (`fingerprints`). Outside `src/commons/consts.jl`, no numeric literal
+ contains the digits `8854187` or `299792458`, and no statement that names `π`
+ contains `1e-7` or a power of a base containing 10 with exponent `-7`.
+- Clones of Commons bodies (`clones`). Function bodies are tokenized with each identifier replaced by its
+ role (call, field or name), and compared through runs of 8 tokens. Statements of
+ the form `x || throw(...)` or `x && throw(...)`, with `throw`, `rethrow` or
+ `error`, are left out. No function body under `src/` or `ext/` contains 75 % of the
+ runs of a `Commons` public function body of at least 30 tokens. Hooks are not
+ compared.
+- Tiny private helpers (`helpers`). A tiny helper is a private module-level function with one
+ method and at most three body statements. Exactly one method references it, and
+ no test file names it. Files under `test/quality/` and `test/tools/` are not test
+ files here. An exact forwarder counts as a tiny helper for any number of
+ references. Its body is one call with the forwarder's own arguments,
+ unchanged and in order, keywords included. Inline a new tiny helper, import the
+ owner's definition, or test it directly.
+- Root freeze (`root`). The number of functions, types and constants defined by the root
+ module does not grow. New shared definitions go to `Commons` under Commons admission.
+
+Each guard is a function of its inputs. The negative controls of the architecture
+guards and the negative controls of the Commons guards apply each guard to a probe
+package with planted violations and to a clean probe package. A guard reports exactly
+the planted violations and nothing in the clean probe.
+
+`test/quality/architecture_baseline.toml` records the violations present when the guards
+were introduced, with one table per guard. A table whose violations are all removed
+stays in the file, empty. The ratchet keeps comparing it, and its guard fails on any
+violation. Every table name must appear in `TABLES`, or the architecture test fails.
+Keys contain no line numbers. An unlisted violation or a count above its entry fails. A
+listed entry without a live violation, or a count below it, also fails. A change that
+removes a violation deletes or lowers its entry. `test/tools/architecture_inventory.jl`
+prints the live inventory in the baseline format. Fix a new violation in the source.
+Never add it to the baseline.
+
+Entries can be deleted or lowered, and none can be added or raised. Before the tests,
+the quality CI job runs `test/tools/baseline_ratchet.jl` against the pull request
+base or the previous push. An added entry or a raised count fails the job. The same
+ratchet checks `test/quality/preservation.toml`, described below, in the direction that
+each of its tables declares. Its allocation table admits new keys, as the preservation
+locks describe.
+
+The ratchet first applies to the earlier keys the file renames that git detects, and the
+module renames of renamed or moved entry files `.jl` that declare their module on
+both sides. A module's full name on each side comes from the entry files of the enclosing
+directories, so an entry file that moves into another module's directory takes that
+module's name as a prefix. An
+ownership key has the form `defining module | Owner.function | file`. A change of `Owner`
+alone, the module that defines the extended function, keeps the entry and its count.
+Any other key renamed without a matching git rename counts as added. A table absent from the
+earlier baseline belongs to a guard introduced since, and the ratchet lists it
+without comparing its keys. Run
+`julia test/tools/baseline_ratchet.jl HEAD` to compare local changes with the last
+commit.
+
+### Preservation locks
+
+A refactor keeps type stability, allocations, speed and results. Quality checks and
+local tools protect them. `test/quality/preservation.toml` records the counts
+of the quality checks. Each table declares its direction in `[directions]`. The
+`@inferred` table is a floor, which may only rise. The JET and allocation tables are
+ceilings, which may only fall. A quality check fails when a count passes its limit. It
+also fails when a count improves on the table, so the table always records the current
+state. A change that improves a count updates the table in the same commit.
+`test/tools/baseline_ratchet.jl` rejects a lowered floor or a raised ceiling against
+the earlier revision.
+
+- `@inferred` floor. The number of `@inferred` uses in each test file. Deleting a
+ failing inference test lowers the floor and fails.
+- JET ceilings. `JET.report_opt` with `target_modules = (LineCableModels,)` analyses the
+ frequency-loop kernels with the concrete arguments of the default coaxial computation
+ of a three-phase cable system: `_solve!`, `cable_impedance!`, `cable_potential!`,
+ `earth!`, `reduce_line_matrices!` and the modal `decompose!` of each formula. The
+ table counts the reports of each kernel. The current reports all come from the boxed
+ earth-return records.
+- Allocation ceilings. `preservation_corpus(n)` in `test/support/scenarios.jl` defines one
+ scenario per computation shape. Each scenario lists the positional arguments of
+ `compute` for a coaxial cable system, three bare wires in air and in earth, a modal
+ composition, CableConstants, a product and a zip parametric study, LinearError and
+ MonteCarlo with a fixed seed. `three_layer` puts the coaxial system on an earth of three
+ soil layers with the default formulation, which consumes the `:default` reduction.
+ `three_layer_before_fd` uses the same earth with a dispersive soil law before the
+ bottommost reduction. `test/tools/allocations.jl` runs the corpus at 2 and at 4
+ frequencies in a new process and prints the table rows. A row records the allocation
+ count of one warmed-up call, which must repeat exactly on each call, and the smallest
+ byte total of three calls. Measurements stores a partial derivative only when it is
+ nonzero. Whether a round-off derivative is exactly zero depends on the machine's last
+ bits. So a count may differ from its row by the scenario's allowance, the number of
+ uncertain real scalars that its result publishes. Real and imaginary parts count
+ separately. The tool computes the allowances from the live results and does not record
+ them. Scenarios of plain numbers have allowance zero and compare exactly. Byte totals
+ fail above the recorded total plus 512 B for byte accounting and the allowance times one
+ derivative entry. The counts depend on what the
+ process computed before. New scenarios go at the end of the corpus. A change that
+ reorders or edits a scenario records again every row from that scenario onward. An
+ allocation key is a corpus scenario, and a new scenario cannot hide another scenario's
+ count. So the ratchet accepts a new row when every row of the earlier revision is still
+ present. A row that appears while another disappears is a rename, and the ratchet
+ rejects it.
+
+A test that moves to another file keeps its `@inferred` uses. The ratchet accepts a
+lower `@inferred` floor when the removed `@inferred` lines appear again in another file,
+unchanged apart from indentation, and cover the difference.
+
+The JET and allocation tables hold for the environment in `[environment]`: the Julia
+version and the SHA-256 of the committed `Manifest.toml`. The CI quality job uses that
+version and instantiates that Manifest. The other CI jobs delete the Manifest and
+resolve the newest compatible versions. In another environment, both checks fail with
+a request to record the tables again. A dependency update records both tables again in
+the same change as the new `Manifest.toml`, with the counts of
+`test/tools/allocations.jl` and of the JET check. A JET or allocation ceiling may rise only in a change that also changes
+`[environment]`.
+
+The local tools compare the working tree with a git revision `REF`. Each checks out
+`REF` in a temporary git worktree. That test environment starts from the repository
+`Manifest.toml`, so both sides use the same dependency versions where `REF` allows
+them. Each side runs its own revision's corpus. A revision without one uses the working
+tree's copy, and the report says which copy each side used.
+
+The timing comparison is `julia --project=test test/tools/performance.jl REF`. One worker
+process per side holds its corpus. Both workers run on the same logical CPU, a
+performance core on a hybrid machine. Only one of them runs at any moment. Each
+scenario runs in 6 rounds of 0.5 s per side, and the side that goes first alternates.
+Machine load only adds time. Each side keeps its fastest sample. When the fastest
+samples of the rounds on one side differ by more than 2 %, the scenario is unstable and
+has no verdict. Separate builds of identical code differ by up to about 4 % on the
+development machine, because each worktree builds its own package image. That is the
+noise floor of the tool. A scenario more than 10 % slower fails with exit status 1. A
+scenario 5 to 10 % slower is a possible slowdown below the resolution of the tool, and
+an unstable scenario also gives exit status 2. Exit status 2 means no verdict, not a
+regression. A possible slowdown that repeats over three runs goes to a person as a likely
+regression. Both revisions run on the machine that runs the tool, a developer machine or a
+CI runner. Other load on that machine makes scenarios unstable.
+The exact performance checks are the `@inferred` floor and the JET and allocation
+ceilings. The timing comparison catches large algorithmic slowdowns.
+
+The workflow `.github/workflows/timing.yml` runs the timing comparison on a CI runner,
+with the Julia version and the `Manifest.toml` of the quality job. Each push to a pull
+request compares the new head with the previous one. A manual run of the workflow
+compares with the commit given as its `base` input. Exit status 1 fails the run. Exit
+status 2 means no verdict, and the run passes with a warning that lists those scenarios.
+A push never waits for an idle local machine.
+
+The equivalence check is `julia --project=test test/tools/equivalence.jl REF [ALLOWED]`.
+On each side, `test/tools/fingerprint.jl` records every node of the inputs and the result
+of each scenario, with the type of the node and the bits of its value. Type names consist
+of the module that defines them followed by the name. The recording also counts
+the points that each parametric scenario materializes and the blueprint lowerings of its
+designs. The optional `ALLOWED` file declares the expected differences, one per line:
+
+```text
+rename | Old => New
+scenario | path prefix | value
+```
+
+Renames apply to whole identifiers in the type names, path segments and Symbol values
+recorded at `REF`. Each declaration states one kind of difference: `value`, `type` or
+`path`. An empty prefix declares the whole scenario. A `value` declaration covers
+different values under the same type and never hides a changed type. Any undeclared
+difference fails. The report lists renames and declarations that match no difference.
+
+## Developer paths
+
+- [Extension API](extensions.md) lists the equation methods and definition types omitted
+ from the user API reference.
+- [Computational engine](engine.md) covers formulations, options, supplemental
+ computation output, and external implementations.
+- [Makie plotting](plotting.md) covers the small high-level API and native ownership.
+- [Conventions](conventions.md) defines placement, dispatch, naming, and
+ docstring rules.
+
+## Testing requirements
+
+### Release status and regressions
+
+The package has no published stable API. Development changes update the
+implementation, callers, and relevant tests together.
+
+In this repository, a regression test protects against a bug detected in a
+published stable release. It identifies the reported tracker issue and affected release, together with the protected behavior. Tests of current development behavior belong
+to the behavior, mathematical implementation, integration, or architecture
+suites, according to what they exercise.
+
+Tests follow the current API and its intended behavior. Preserve independent
+scientific expectations when updating callers. Architectural tests verify each module through its consumers and native interfaces.
+
+### Verification coverage
+
+The harness checks that the current implementation works and follows the
+codebase standards. It exercises actual public workflows and their owned
+kernels: calculations, dispatch, units, ordering, data transport, errors,
+resource handling and side effects. Tests use current input builders and small,
+distinguishable examples with an expected result or rejection.
+
+Mathematical implementation tests remain appropriate. Checking an implemented
+formula against a directly calculable result, a matrix reduction against a
+constrained solve, or a derivative of a simple function verifies code correctness.
+It does not certify the physical applicability of the model. Imports, shapes,
+finiteness and round trips provide useful limited checks. They do not replace
+value assertions where the implemented behavior has a decidable expectation.
+
+Float32 support means that valid ordinary inputs containing Float32 values work
+without type-induced crashes. It includes no additional numerical accuracy
+promise. Do not impose a high-precision reference target on Float32 and then
+change owned numerical code to meet it. No Float32-specific widening, compensated
+arithmetic or precision infrastructure is justified by such a test. Preserve
+existing dispatch, hooks, uncertainty and supported types. Keep API conversions
+when an actual API or dependency interface requires them. A requested tolerance
+does not create an accuracy guarantee.
+
+Scientific validity, comparison of physical approximations, broad accuracy
+claims, convergence research and scientific acceptance are outside the test
+harness. Scientific acceptance belongs to the researcher's interpretation, not
+the test runner. Such research is not a required CI job, and unfinished scientific
+evidence is not a failing software check. Apply this distinction to passing and failing
+experiments alike.
+
+A fabricated failure is as unacceptable as a fabricated success. Verify an
+assertion's premise before treating its outcome as a product defect. Correct or
+remove a defective criterion with its reason. Do not preserve it merely because
+it was fixed before execution. Do not change inputs, outputs or tolerances just
+to obtain a pass. Preserve observed differences and distinguish assertion
+failures from execution errors or unavailable verification.
+
+### Architecture and coverage
+
+Architectural tests protect current responsibilities through real behavior and
+native method and interface checks: owner-local dispatch, fixed report stages,
+observation and table owners, validated inputs, optional integrations and
+caller-owned state. A conforming new leaf must work through the actual composed consumer.
+
+The existing source-amended production line-coverage gate remains at 95%, with
+its current `src/` and `ext/` inventory. Measure executed code, then cover actual
+missing behavior in its existing test owner. Assertion counts, obsolete guards,
+denominator changes and fabricated expectations cannot satisfy this objective.
+Keep incomplete executions and real bugs visible. Coverage does not turn them
+into successes.
+
+## External interface methods
+
+`test/quality/explicit_imports.jl` loads the numerical, XLSX and Cairo adapters
+explicitly. All mechanical ownership and import checks remain active. Each
+exception identifies an exact consumer, owner and name for a documented
+upstream interface that lacks a Julia `public` annotation. Access to
+package-private names remains prohibited.
+
+The following external interfaces are used by the package:
+
+| Access | Supported use |
+| --- | --- |
+| `CairoMakie.activate!` | Documented [backend activation](https://docs.makie.org/stable/explanations/backends/cairomakie.html). Cairo adapter only. |
+| `Base.IOError` | Native I/O exception, including [filesystem errors](https://docs.julialang.org/en/v1/base/file/). Renderer export errors and FEM recovery from file and process errors. |
+| `Base.unalias` | Documented native preventative-copy operation in Julia 1.12's `base/abstractarray.jl`. Used only by the allocating Kron entry point in `Commons` to preserve source and destination aliasing. The reduction path uses separate preallocated buffers. |
+| `Base.get_extension` | Identifies the loaded Cairo extension. Its public `CairoMakie` binding supplies the native save backend. |
+| `Makie.automatic` | Documented [native attribute default](https://docs.makie.org/stable/api). Renderer only. |
+| `Makie.current_backend` | Documented [backend-dependent API default](https://docs.makie.org/stable/api). Renderer only. |
+| `Makie.get_ticks`, `Makie.get_tickvalues` | Documented [axis extension hooks](https://docs.makie.org/stable/reference/blocks/axis.html). Renderer only. |
+| `Makie.pseudolog10` | Documented [axis scale](https://docs.makie.org/stable/reference/blocks/axis.html). Renderer only. |
+| `Makie.inverse_transform` | Documented [custom axis scale interface](https://docs.makie.org/stable/reference/blocks/axis.html#xscale). The renderer applies axis margins to full uncertainty bounds in the selected scale, then maps them back without a second inverse-scale mapping. |
+| `Makie.CategoricalConversion` | Documented [categorical axis conversion](https://docs.makie.org/stable/reference/generic/dimensional/). Renderer assembly axes only. |
+| `Makie.defaultlimits` | The documented native scale-default hook named by Makie's Axis attributes. The renderer queries it only when an empty axis changes scale. The native scale then supplies its valid interval. |
+| `propertynames`, `Makie.default_theme` | Axis attributes use `propertynames` and the native `palette` keyword. Scatter attributes use the exported [`default_theme`](https://docs.makie.org/v0.24/explanations/recipes) method. |
+| `LegendElement.plots` | Associates legend glyphs with source plots through the documented [LegendElement extension interface](https://github.com/MakieOrg/Makie.jl/blob/v0.24.13/Makie/src/makielayout/types.jl). |
+| `on`, `off`, `ObserverFunction.observable` | Manage visibility subscriptions using the documented [`ObserverFunction.observable`](https://juliagizmos.github.io/Observables.jl/stable/#Observables.ObserverFunction) notification target. |
+| `Makie.fast_string_boundingboxes(Text)` | Public attribute subscriptions query the documented [text bounds](https://github.com/MakieOrg/Makie.jl/blob/v0.24.13/Makie/src/basic_recipes/text.jl), retaining marker-space extents. |
+| `GridLayoutBase.remove_from_gridlayout!` | Retained as the [maintainer-prescribed nested-layout removal](https://discourse.julialang.org/t/makie-removing-gridlayouts/103935) workaround. It is not an exported stable API. Only this renderer call is admitted. Existing legend recreation and layout tests protect it. |
+
+These integration methods are scoped to the supported Makie 0.24 family.
+The layout workaround needs review when that compatibility range changes.
+The owned legend, uncertainty visibility, series style and colorbar endpoint tests
+exercise the actual affected paths. Behavioral compatibility requires execution of the affected paths. Negative gate controls reject unlisted renderer
+internals, different consumers and package-owned private calls.
diff --git a/docs/src/docstrings.md b/docs/src/docstrings.md
deleted file mode 100644
index 01ee70ffb..000000000
--- a/docs/src/docstrings.md
+++ /dev/null
@@ -1,197 +0,0 @@
-# Docstrings
-
-The following docstring standards are generally adopted across the codebase.
-
-## General principles
-
-1. **Placement:** Docstrings must immediately precede the code entity (struct, function, module, constant) they describe.
-2. **Delimiter:** Use triple double quotes (`"""Docstring content"""`) for all docstrings, *except* for individual struct field documentation.
-3. **Conciseness:** Avoid redundancy. Information should be presented clearly and concisely in the appropriate section.
-4. **Tone:** Use formal, precise scientific language suitable for technical documentation. Avoid contractions, colloquialisms, and ambiguous phrasing.
-
-## Physical unit formatting
-
-All variables corresponding to physical quantities must be annotated with their SI units and according to the following rules:
-
-1. **Mandatory units:** ALL arguments, return values, struct fields, and constants representing **physical quantities** MUST have their SI units specified.
-2. **Dimensionless quantities:** Physical quantities that are dimensionless MUST be explicitly marked as `\\[dimensionless\\]`.
-3. **Non-physical quantities:** Do *not* add unit annotations to arguments, variables, or fields that do not represent physical quantities (e.g., counters, flags, indices).
-4. **Standard format:** Units MUST be enclosed in double-backslash escaped square brackets: `\\[unit\\]`.
- - **Correct:** `\\[m\\]`, `\\[Hz\\]`, `\\[Ω\\]`, `\\[H/m\\]`, `\\[dimensionless\\]`
- - **Incorrect:** `[m]`, `\[m]`, `m` (as a standalone unit identifier)
-5. **Exception for example comments:** Inside ` ```julia` code blocks within the `# Examples` section, use *regular* (non-escaped) square brackets for units within comments.
- - **Correct:** ```julia result = calculation(10.0) # Output in [m]```
- - **Incorrect:** ```julia result = calculation(10.0) # Output in \\[m\\]```
-6. **Common units:** Use standard SI abbreviations (e.g., `m`, `s`, `kg`, `A`, `K`, `mol`, `cd`, `Hz`, `N`, `Pa`, `J`, `W`, `C`, `V`, `F`, `Ω`, `S`, `T`, `H`, `lm`, `lx`, `Bq`, `Gy`, `Sv`, `°C`). Use the Unicode middle dot `·` for multiplication where appropriate (e.g., `\\[Ω·m\\]`).
-
-## Mathematical formulation formatting
-
-1. **Requirement:** Mathematical formulas rendered using LaTeX are MANDATORY *only* for functions/methods whose names start with the prefix `calc_`.
-2. **Location:** For `calc_` functions, the LaTeX formula MUST be placed within a ```math ...``` block inside the `# Notes` section.
-3. **Forbidden:** Do NOT include ```math``` blocks or LaTeX formulations for any functions or methods *not* prefixed with `calc_`.
-4. **LaTeX escaping:** Within documentation text AND inside ```math``` blocks, all LaTeX commands (like `\frac`, `\mu`) MUST have their backslashes escaped (`\\`).
- - **Correct:** `\\mu_r`, ```math \\frac{a}{b}```
- - **Incorrect:** `\mu_r`, ```math \frac{a}{b}```
-
-## Documentation templates
-
-The subsections below contain templates for different types of code elements.
-
-### Structs
-
-- **Main docstring:** Use `$(TYPEDEF)` for the signature and `$(TYPEDFIELDS)` to list the fields automatically. Provide a concise description of the struct purpose.
-
- ```julia
- """
- $(TYPEDEF)
-
- Represents a physical entity with specific properties...
-
- $(TYPEDFIELDS)
- """
- struct StructName
- # Field definitions follow
- end
- ```
-
-- **Field documentation:**
- - Place *directly above* each field definition.
- - Use single-line double quotes: `"Description with units \\[unit\\] or \\[dimensionless\\] if applicable."`
- - Do NOT use `""" """` block quotes or inline comments (`#`) for documenting struct fields.
-
-
-### Constructors (inside or outside structs)
-
-- ALL constructors MUST be documented using the `@doc` macro placed immediately before the `function` keyword or the compact assignment form (`TypeName(...) = ...`). This applies even to default constructors if explicitly defined.
-- **Format:** Use `$(TYPEDSIGNATURES)`. Include standard sections (`Arguments`, `Returns`, `Examples`).
-
- ````julia
- @doc """
- $(TYPEDSIGNATURES)
-
- Constructs a [`StructName`](@ref) instance.
-
- # Arguments
-
- - `arg_name`: Description including units `\\[unit\\]` if physical.
-
- # Returns
-
- - A [`StructName`](@ref) object. [Optionally add details about initialization].
-
- # Examples
-
- ```julia
- instance = $(FUNCTIONNAME)(...) # Provide meaningful example values
- ```
-
- """
- function StructName(...)
- # Implementation
- end
- ````
-
-### Functions / methods
-
-- **Format:** Start with `$(TYPEDSIGNATURES)`. Follow the section order described.
-
- ````julia
- """
- $(TYPEDSIGNATURES)
-
- Concise description of the function's purpose.
-
- # Arguments
-
- - `arg1`: Description, units `\\[unit\\]` if physical. Specify `Default: value` if applicable.
- - `arg2`: Description, `\\[dimensionless\\]` if physical and dimensionless.
-
- # Returns
-
- - Description of the return value, including units `\\[unit\\]` if physical. Document multiple return values individually if using tuples.
-
- # Notes (OPTIONAL - MANDATORY ONLY for `calc_` functions for the formula)
-
- [For `calc_` functions: Explanation and formula]
- ```math
- \\LaTeX... \\escaped... \\formula...
- ```
-
- # Errors
-
- - Describes potential errors or exceptions thrown.
-
- # Examples
-
- ```julia
- result = $(FUNCTIONNAME)(...) # Use representative values. Add expected output comment.
- # Example: result = $(FUNCTIONNAME)(0.02, 0.01, 1.0) # Expected output: ~0.0135 [m]
- ```
-
- # See also
-
- - [`related_package_function`](@ref)
- """
- function function_name(...)
- # Implementation
- end
- ````
-
-- **Section order:**
- 1. Description (no heading)
- 2. `# Arguments`
- 3. `# Returns`
- 4. `# Notes` (Only if needed; mandatory for `calc_` functions)
- 5. `# Errors` (Only if needed)
- 6. `# Examples`
- 7. `# See also` (Only if needed)
-- **Spacing:** Ensure exactly one blank line separates the description from `# Arguments` and precedes every subsequent section heading.
-- **Examples:** Use the `$(FUNCTIONNAME)` macro instead of hardcoding the function name. Use meaningful, realistic input values. Include expected output or behavior in a comment, using *non-escaped* brackets for units (`[unit]`).
-- **See also:** Only link to other functions *within this package* using `[`function_name`](@ref)`. Do not link to Base Julia functions or functions from external packages unless absolutely necessary for context. Only include if the linked function provides relevant context or alternatives.
-
-### Modules
-
-- **Format:** The first line must be the module name indented by four spaces. Use `$(IMPORTS)` and `$(EXPORTS)` literals.
-
- ````julia
- """
- ModuleName
-
- Brief description of the module purpose within the broader package (e.g., for [`Package.jl`](index.md)).
-
- # Overview
-
- - Bullet points describing key capabilities or features provided by the module.
-
- # Dependencies
-
- $(IMPORTS)
-
- # Exports
-
- $(EXPORTS)
- """
- module ModuleName
- # Contents
- end
- ````
-
-### Constants
-
-- **Format:** Use a single-line docstring with double quotes (`"..."`). Include a brief description, the symbol of the constant if standard (e.g., `μ₀`), its value, and its units using the `\\[unit\\]` format.
-
- ```julia
- "Magnetic constant (vacuum permeability), μ₀ = 4π * 1e-7 \\[H/m\\]]."
- const μ₀ = 4π * 1e-7
- ```
-
-## Common mistakes to avoid
-
-Double-check the docstrings to avoid these common errors:
-
-- **Missing `@doc` for constructors:** ALL constructors require the `@doc` macro before their definition.
-- **Incorrect struct field docstrings:** Use single-line `"..."` *above* the field, not block `"""..."""` quotes or inline `#` comments.
-- **Incorrect section order:** Follow the specified order for function docstring sections precisely.
-- **Hard-coding function names in examples:** Always use `$(FUNCTIONNAME)`.
-- **Incorrect unit formatting:** Ensure `\\[unit\\]` syntax is used everywhere except comments within `Examples` blocks (`[unit]`). Double-check escaping (`\\`) for LaTeX.
-- **Adding math formulas to non-`calc_` functions:** Math blocks are *only* for functions prefixed with `calc_`.
diff --git a/docs/src/engine.md b/docs/src/engine.md
new file mode 100644
index 000000000..f75b1d04f
--- /dev/null
+++ b/docs/src/engine.md
@@ -0,0 +1,1468 @@
+# Computational engine
+
+LineCableModels separates the physical problem, selected equations, and numerical
+execution. Source equations and bibliography belong in the implementing formula
+file. Backend/formula methods select an implementation through Julia dispatch.
+
+Each family owns a `:default` routing identifier and resolves it to an explicit
+implementation before evaluation. Literature references remain attached to
+the equations without determining their software names.
+
+| Family | Registered choices |
+|---|---|
+| Internal impedance | `:default`, `:schelkunoff1934`, `:wedepohl1973` |
+| Insulation impedance | `:default`, `:ametani1980` |
+| Pipe impedance | `:default`, `:none` |
+| Insulation admittance, semicon admittance | `:default`, `:lossless`, `:lossy` |
+| Local shunt geometry | `:default` (equivalent annular layer), `:equivalent`, `:boundary` |
+| Earth impedance | `:default`, `:unified`, `:carson1926`, `:pollaczek1926`, `:gary1976`, `:wedepohl1973`, `:saad1996`, `:ametani2009`, `:lucca1994`, `:wise1934`, `:xue2018` |
+| Earth admittance | `:default`, `:unified`, `:ideal`, `:pollaczek1926`, `:wise1948`, `:xue2018` |
+| Frequency-dependent soil properties | `:default`, `:constant`, `:alipio2014`, `:cigre2019`, `:datsios2019`, `:longmire1975`, `:messier1985`, `:portela1999`, `:scott1967`, `:visacro1987`, `:visacro2012` |
+| Equivalent earth | `:default`, `:bottommost` |
+| Modal transformation | `:default`, `:chrysochos2014`, `:vieira2026`, `:wedepohl1996` |
+| Temperature-dependent resistivity | `:default`, `:linear` |
+
+The internal default and `:schelkunoff1934` retain Schelkunoff's tubular
+conductor expressions. Insulation impedance's default and `:ametani1980`
+retain the annular magnetic term documented by Ametani.
+Both earth defaults route to `:unified`, which implements the supplied circumferentially averaged framework
+with complete enclosed-current normalization. The coaxial backend also evaluates
+`:gary1976` aerial impedance, `:saad1996` and `:wedepohl1973` buried impedance,
+`:lucca1994` mixed impedance, and `:ideal` external potential coefficients.
+Other registered earth identities throw "not yet implemented". They never substitute Unified.
+Independent external-backend and consumer-defined implementations are unaffected.
+Dielectric `:default` selections
+route to explicit `:lossless` equations. `:lossy` retains conductivity and the
+material's supplied polarization losses. The FrequencyDependent `:default`
+routes to `:constant`, which preserves static properties. Its explicit
+literature relations model measured soil dispersion. Their references remain
+attached to the equations without determining their software names.
+EquivalentHomogeneous selects the basement when explicitly requested, or when a
+homogeneous earth formula meets an earth model with more than two layers, and the
+modal default performs Levenberg–Marquardt tracking. The EquivalentHomogeneous
+default is a package-defined selection rather than an author equation.
+
+## Coaxial computation
+
+The workflow is design, system, problem, formulation, then `compute`. Initialization
+flattens the selected designs into blueprint tables. It binds equations to their required inputs and selected output entries before allocating one
+`LineParametersWorkspace`.
+
+The computation evaluates fixed-temperature conductor properties and required
+frequency-dependent earth tables, then follows this order at each frequency:
+
+1. Complete equivalent-earth evaluation and dielectric material admittivities.
+2. Calculate cable-local impedance and potential coefficients.
+3. Calculate the selected exterior impedance and potential coefficients together
+ through `earth!`.
+4. Assemble primitive matrices, apply the selected reductions and store Z/Y.
+
+`BeforeFD` and `AfterFD` retain their distinct ordering. Material-law calls receive
+valid material objects. Field formulas receive completed material properties.
+Their wave numbers and mathematical approximations do not replace those values.
+Boundary-shunt coefficients remain blueprint-time quantities.
+
+The workspace input and plan retain geometric tables and bind equations
+to their inputs and outputs. Its buffers hold evaluated material tables, local coefficients,
+separate exterior Z/P destinations, primitive and reduction matrices, QuadGK storage
+and any numerical arrays that the selected equations build in `initialize_buffers`. Trace
+storage is optional. Mutable calculation buffers belong to each independent computation.
+
+Ordinary earth equations evaluate their indexed cases. A coupled equation may
+require full-system inputs even when only some of its entries are selected.
+Those inputs and compatible Z/P consumers are bound during initialization.
+Compatible consumers calculate one response per frequency. Different
+configurations calculate separately and publish their selected entries before
+reusing buffers. The binding records the correspondence between inputs and
+selected output entries. Each call evaluates a fresh response.
+
+## Internal shunt geometry
+
+`shunt_model` selects the cable-local geometry approximation independently of
+the dielectric material laws. Both `Formulation()` and
+`CableConstantsFormulation()` default to `shunt_model=:default`: the equivalent
+annular layer, with no boundary solve. `:equivalent` selects it explicitly.
+
+`insulation_admittance` and `semicon_admittance` select the material
+admittivity κ [S/m]. For one homogeneous annulus,
+the shunt branch is `y = 2πκ/log(ro/ri)` [S/m]. Successive dielectric layers
+combine in series. This is the same reduced geometry used by the coaxial
+backend, including its equivalent wire-screen geometry.
+
+Select `shunt_model=:boundary` to resolve eligible open wire and finite-tape
+groups inside a closed circular shield before the frequency sweep. This local
+shunt calculation supplies a coupled terminal operator that retains direct
+coupling across open intermediate screens. The same blueprint construction
+supplies `CableConstants`.
+
+The resolved local calculation preserves physical filler permittivity, finite
+tape thickness, conductor terminal membership and concentric dielectric layers.
+It couples all intermediate terminals in a qualifying domain together. Round
+wires use auxiliary sources. Tapes use integrated corner-weighted charges on
+their two circular faces and two ends. The inner core retains the existing
+equivalent-core assumption, including bounded circular, rectangular, compacted
+and sector stranded constructions. This calculation supplies the internal
+shunt contribution and requires the radial terminal ordering of coaxial lowering.
+Series impedance, earth return, and matrix reductions use their respective
+selected formulations.
+
+Qualification currently requires an explicitly filled annular host, continuous
+concentric dielectric paths, exposed whole wire and tape geometric boundaries, and a closed
+circular reference shield. Separate shielded assemblies are handled in their
+own local frames. Overlapping same-terminal faces, interacting courses in
+different hosts, nonconcentric dielectric interfaces and noncircular reference
+shields retain the existing equivalent-coaxial treatment. They are not fed to
+an inapplicable circular Green function. The resolved path currently uses the
+built-in `:lossless` insulation or semicon laws or their `:default` aliases.
+Lossy or custom constitutive selections are unsupported by `:boundary` and raise `BoundarySolveError` for
+eligible domains. Select `:equivalent` to retain their radial calculation without
+losing conductivity or frequency dependence.
+
+Inspect `details(result).data.shunt_model` for requested and effective model, domain
+terminal ranges, solve counts, and diagnostics. Boundary-resolved domains
+coexist with ordinary radial intervals outside their coverage. `effective`
+describes the qualifying domains. `:mixed` indicates explicit fallback in some
+of them. Numerical grid residuals and small-coupling indicators are not
+certified error bounds. Whole-matrix convergence can conceal substantial
+relative changes in weak individual couplings.
+
+Formulation-aware blueprint construction uses bounded dense storage and
+in-place pivoted QR. The completed `CableBlueprint` owns lossless terminal
+capacitance and potential coefficients, local terminal coverage and numerical
+outcomes. Workspaces consume these coefficients without performing a boundary solve.
+Repeated equivalent domains share coefficient matrices within the same
+construction call. Formulations with identical local selections share blueprints
+even when earth-return choices differ. Independent uncertainty sources prevent
+sharing, and dense geometric boundary matrices are released after construction.
+
+Select the formulation and call `compute` directly:
+
+```julia
+formulation = Formulation(shunt_model=:boundary)
+result = @time compute(problem, formulation)
+```
+
+The compute call constructs the selected local shunt model. Blueprints with the default
+equivalent annular layer contain no geometric boundary blocks, extract no geometric boundary domains, and evaluate
+no geometric boundary material law. Their reports describe the concentric assembly ranges.
+No geometric boundary audit is implied.
+Boundary coefficients are independent of the frequency grid and external earth
+model. They are reused across the frequency sweep, but a fresh `compute` call
+constructs fresh blueprints: there is no process-global or cross-call cache.
+Changed geometry and Monte Carlo realizations receive new coefficients.
+Do not mutate coefficient arrays shared by completed blueprints.
+
+Production reports unmet quadrature targets, estimated rank, reciprocity and
+positive-capacitance concerns as warnings. The computed finite result is retained
+without retry, repair or substitution. Dense-storage limits, degenerate columns,
+actual solve failure and nonfinite physical results remain errors. The independent
+validation grid and derivative step-refinement are opt-in:
+
+```julia
+boundary = formula(:boundary;
+ parameters=(fallback=:error,),
+ options=(
+ resolution=(wire=64, order=32, quadrature=256, modes=1024),
+ integration=(rtol=1e-8, atol=1e-10, maxevals=100_000),
+ audit=false,
+ ))
+formulation = Formulation(shunt_model=boundary)
+```
+
+Set `audit=true` for the independent checks. Unaudited residual fields are
+`nothing`, not zero. The audit warns on unmet criteria and does not alter the
+computed terminal matrix.
+The integration tolerances control dimensionless logarithmic moments, not
+the error in an individual terminal coupling.
+
+See [`BoundarySolveError`](@ref) and [`LineCableModels.Engine.ShuntModel.Formula`](@ref)
+for the failure categories and formula selections.
+
+Actual failures propagate by default. An explicit `parameters=(fallback=:equivalent,)`
+permits the equivalent annular layer as replacement only after recognized numerical or unsupported-law
+failures, with a warning and recorded reason. Finite-result quality warnings do
+not trigger that fallback. Invalid inputs and unexpected
+exceptions propagate. UQ wrappers reject automatic fallback. Choose strict
+`:boundary` or `:equivalent` for the whole study. Monte Carlo also checks that
+shunt-model coverage remains fixed across realizations and never retries a
+`BoundarySolveError` as a geometry rejection.
+
+With Measurements loaded, local sensitivities preserve the original correlated
+inputs. The nominal QR is reused in an implicit least-squares derivative that
+includes its residual term. Centered kernel derivatives use one step in
+production. The audit also checks step halving. Derivative matrices
+are streamed in bounded blocks rather than stored
+as dense Measurement arrays. This is fixed-topology linear propagation, not a
+claim about differentiability across a contact or strand-count transition.
+Each Monte Carlo realization constructs its own physical geometry and material
+operator. Identical cables and formulations within that realization share it.
+Concentricity checks include coordinate dependencies: two centers with equal
+nominal positions but independent uncertainty do not qualify as concentric.
+The local geometric boundary calculation uses Float64 workspaces. It does not promise
+arbitrary-precision geometric boundary accuracy when surrounding scalar inputs use a
+wider type.
+
+### Numerical method and attribution
+
+The annular Green function is assembled from classical cylindrical Laplace
+harmonics: `r^m` and `r^-m`, with `1` and `log(r)` for the zero mode.
+[Schelkunoff1934](@cite), Eqs. (122)-(124), p. 573, gives this radial basis
+in its treatment of cylindrical fields in the small-radius-to-wavelength
+limit. Here the same basis solves the electrostatic potential problem.
+[Sunde1968](@cite), Section 1.5, Eq. (1.20), p. 11, supports the coaxial
+capacitance normalization. Section 1.6, Eqs. (1.39)-(1.42), p. 15, gives
+the load and reflection transformation applied here in log-radius coordinates.
+Matching the dielectric interfaces and grounded inner and outer boundaries
+assembles these ingredients into the implemented layered annular kernel.
+
+The finite-thickness, layered-Green-function, whole-face charge approach has
+precedent in [Bernal1997](@cite), especially Section IV, Eq. (10). Their
+Maxwell-weighted Chebyshev/Galerkin formulation is not reproduced verbatim:
+this implementation uses a concentric circular Green function, normalized
+Jacobi face charges and oversampled boundary collocation with pivoted QR.
+
+[Campione2018](@cite), Section 2, Eq. (3), provides a cable-screen application
+of dielectric image factors `(εhost − εadjacent)/(εhost + εadjacent)`, also
+used in the extracted nearest-interface terms here. Their cylindrical braid
+is locally approximated by a plane. That work is not a derivation of this
+implementation's concentric annular kernel or radial layer recursion.
+
+The corner weights encode the finite-energy edge behavior of
+[Meixner1972](@cite). Corners on dielectric interfaces use the actual adjacent
+permittivities, following the metal-dielectric wedge principle of
+[VanBladel1985](@cite). The homogeneous exponent is insufficient for all corners. [Classen2011](@cite), Sections 2 and 4, supports incorporating known
+singularities into an approximation space. Its FIT/DG methods are not this
+tape element. The implemented corner equation and normalized charge measure
+are documented below.
+
+The need for special evaluation near source boundaries is discussed by
+[HelsingOjala2008](@cite), Section 1. Here logarithmic direct and image moments use
+adaptive Gauss–Kronrod quadrature after a cosine change of variable, while
+the smooth remainder uses Gauss–Jacobi quadrature. This is a different
+algorithm from their rational-quadrature scheme.
+
+The following internal numerical methods document the equations, charge
+normalization and quadrature needed to reproduce the calculation. These implementation details are internal to the calculation. Source placement,
+resolution controls, validation thresholds and uncertainty differentiation
+remain package-specific choices, not accuracy guarantees supplied by these
+references.
+
+```@docs
+LineCableModels.Engine.ShuntModel._shunt_load
+LineCableModels.Engine.ShuntModel._shunt_kernel_coefficients
+LineCableModels.Engine.ShuntModel._shunt_junction_exponent
+LineCableModels.Engine.ShuntModel._shunt_tape_faces
+LineCableModels.Engine.ShuntModel._shunt_log_moments
+LineCableModels.Engine.ShuntModel._shunt_capacitance
+```
+
+## Formula selection
+
+```julia
+selected = Formulation(
+ earth_impedance=formula(:default;
+ options=(integration=(method=:quad, options=(rtol=1e-8,)),)),
+ earth_admittance=formula(:default),
+ earth_properties=formula(:default),
+)
+result = compute(problem, selected; options=(trace=true,))
+```
+
+`FormulaDefinition` includes an identifier, physical `parameters`, numerical
+`options`, and an optional formula-local `equivalent_earth`. It is a passive
+request. A family constructor resolves a symbol or declaration to a concrete
+selection. A completed user-owned selection passes through unchanged. Built-in
+formula lists enumerate the implementations supplied by the package.
+
+An `Expression(selection, operation, Val(...), ...)` is one expression of the
+selected formula: the family operation, called with that selection first. `formula_id` supplies descriptive metadata. The family constructor
+resolves `:default` to a concrete implementation before computation.
+
+For the analytical families, the default routes are:
+
+| Family | Explicit implementation |
+|---|---|
+| Internal impedance | `:schelkunoff1934` |
+| Insulation impedance | `:ametani1980` |
+| Earth impedance and potential coefficients | `:unified` |
+| Insulation and semicon admittivity | `:lossless` |
+| Frequency-dependent earth properties | `:constant` |
+| Equivalent homogeneous earth | `:bottommost` |
+| Temperature-dependent resistivity | `:linear` |
+| Modal transformation | `:chrysochos2014` |
+| Local shunt geometry | `:equivalent` |
+| Pipe contribution | `:none` |
+
+Each selected equation enforces its applicability requirements. `:none` accepts
+coaxial geometry, which does not require an additional pipe term. Conductive pipe systems
+require a pipe formulation. `:unified` requires supported earth geometry. PSCAD defines its native selections. FEM defines its field equations and accepts supported
+material selections.
+
+Numerical defaults belong to `formulation_options(::Expression{<:MySelection,
+typeof(operation), ...})`. Empty defaults exclude controls. Unknown or unused
+numerical sections are errors. The selected formula provisions QuadGK storage
+through `initialize_buffers` when its required indexed consumers need integration.
+Full-system earth equations provision their required storage through the same
+selected-formulation dispatch.
+
+Custom types implement the existing family operation and expose `parameters`
+and `options` records. Internal selections expose per-surface options.
+Earth selections expose `equivalent_earth`. Constructors own parameter checking and
+option normalization.
+Implement `formula_id`, `description`, `NamedTuple`, and `formulation_options`
+for scientific inspection and persistence. A saved declaration is not executable
+code. Unknown saved leaf identities remain passive identities.
+
+Unified accepts a prescribed longitudinal argument [1/m] through
+`formula(:unified; options=(Γ=value,))`. Use a finite scalar for a value that applies to every sample. A finite vector aligns one-to-one with the problem's frequency vector,
+in the supplied order. Zero is the default. Material constitutive laws evaluate
+material objects before field calculations. Selected earth equations consume
+those properties and calculate their own wave numbers and field approximations.
+
+The implemented equations use `s=jω` and the positive-time `exp(jωt)` phasor
+convention. For the same real field expressed as `F̂₋ exp(Γ₋ x-jωt)`, the
+positive-time representation has `F̂₊=conj(F̂₋)` and `Γ₊=conj(Γ₋)`. Complex
+material coefficients and response phasors must use that convention consistently.
+Supply Γ in this positive-time convention. The API uses its value directly.
+Uncertainty propagation applies to the physical material inputs.
+
+`EarthPair` includes conductor row and column indices, integer source-target layer indices,
+heights, horizontal separation and an explicit self radius. Self means the same
+conductor. Distinct conductors in one layer remain mutuals. A self pair has zero
+horizontal separation, with its radius supplied separately. The geometry substitution
+needed by a published self expression occurs at equation evaluation.
+
+The external expression signatures are:
+
+```julia
+earth_impedance(formula::MyEarthImpedance, ::Val{Kind}, ::Val{S}, ::Val{T}, functor, workspace)
+earth_potential_coefficient(formula::MyEarthPotential, ::Val{Kind}, ::Val{S}, ::Val{T}, functor, workspace)
+```
+
+`Kind` is `:self` or `:mutual`. Source `S` is the matrix column, and target `T`
+is the row. Layer 1 is air. Soils occupy layers 2 through N. Required methods
+are invoked through indexed dispatch. Before the frequency loop, the plan checks
+that the formula has a method for each interaction. Domain-defining methods accept
+the Functor and the workspace without extra subtype constraints. Numerical
+specializations can optimize an admitted case. Julia method availability determines
+which expressions can be selected.
+
+Each earth slot also accepts an explicit NamedTuple recipe:
+
+```julia
+selected = Formulation(earth_impedance = (
+ earth = formula(:unified),
+))
+```
+
+The same syntax applies independently to `earth_admittance`. The labels resolve
+`(1,1)`, `(2,2)` and the two cross-layer directions before the existing indexed
+method dispatch. Each case retains its own formula, numerical options and
+material inputs. The example covers an all-buried problem. Whole-family
+omission or `nothing` routes to `:default`. An explicit recipe must cover every
+required case. Allocation and evaluation include only the leaves used by those
+cases. True layered inputs require a scalar selection.
+
+The default potential formulation supplies both mixed directions. Selecting a
+default leaf for only part of a matrix still assembles its complete auxiliary
+system and then selects the requested final entries. Other selected output
+equations never become inputs to its axial-field or source-potential coefficients.
+
+The workspace binds every required ordered pair before frequency evaluation.
+Geometry and layer indices follow the same order. Each direction retains its
+inputs and output position. Assembly, reduction and modal transformation preserve
+the returned ordered entries.
+No implicit reciprocity operation supplies a missing equation or averages its result.
+
+Compatible impedance and potential selections share one bound calculation
+with explicit output indices for each quantity. Distinct controls retain separate
+calculations and material arrays. The bound calculations and their equation
+groups are concrete tuples. Conductor interactions and numerical storage remain
+arrays. The complete scan specializes on those tuples before entering its
+frequency loop, while the workspace type remains independent of conductor layout.
+
+Repeated numerical interactions use the shared `Engine.earth!` traversal of
+the parts of each earth calculation. During workspace construction,
+`same_physical_state(formula, a, b, geometry)` compares the invariant arithmetic inputs
+of two conductor pairs. The default compares their destination indices. A formula
+compares the inputs it reads instead only when its arithmetic does not use the indices.
+These inputs serve reuse alone: expressions consume the existing `EarthPair` and
+evaluated material data, without a second pair representation or callback interface.
+
+Earlier interactions belong to the same bound equation and resolved controls. At each
+frequency, Engine uses `same_physical_state` to compare their current material
+values. It evaluates each distinct interaction and distributes the resulting
+scalar or tuple of coefficients. Equal nominal values with independent
+uncertainty sources remain distinct. Each invocation overwrites representative
+indices and diagnostic ranges. The calculation starts afresh at each frequency. Geometry
+changes require a new workspace. Numerical types and tolerances are preserved.
+
+The source and target layers in the signatures of a formula's methods declare the
+media it handles. Layer 1 is air and layer 2 the first earth layer. A formula admits a
+layer when one of its methods accepts it as source or target, whatever the types of
+the other arguments. On an earth model with more layers, a formula that admits layers
+1 and 2 at most consumes its explicit `equivalent_earth` reduction. Without one, it
+consumes the EquivalentHomogeneous `:default` reduction. Any admitted layer from 3 to
+the deepest layer of the model gives the formula the whole layered earth and its
+interfaces.
+
+The computation checks before the frequency loop that the formula has a method for
+each interaction. Its `ArgumentError` states the number of earth layers and the
+highest of them that the formula admits. Arbitrary-layer
+Green-function generation remains deferred. Buried
+placement in a vertical multilayer earth is rejected because its physical layer
+indexing has no defined origin in the present geometry definition.
+
+The analytical impedance selections execute direct indexed equations: `:gary1976`
+for aerial self and mutual, `:saad1996` or `:wedepohl1973` for buried self and mutual,
+and `:lucca1994` for both mixed directions. The `:ideal` potential selection
+uses electrostatic images for aerial pairs and zero external potential whenever
+either conductor is buried. Insulation contributions remain in the total matrix.
+Earth-kernel quadrature and extra buffers are unnecessary for these methods.
+The following selection combines their impedance and potential equations:
+
+```julia
+selection = Formulation(
+ earth_impedance = (air=:gary1976, earth=:saad1996, mixed=:lucca1994),
+ earth_admittance = :ideal,
+)
+phase = compute(problem, selection; options=phase_options)
+```
+
+The numerical-method docstrings state each formula's material approximations.
+The formulas consume the material values evaluated by the frequency loop.
+Registered identities with unimplemented numerical methods raise an error.
+The defaults supply air-air, earth-earth and both mixed directions with
+independent medium permeabilities. Every circumference must lie wholly in its
+half-space and exterior circles must not overlap. The default requires homogeneous
+earth or an explicitly globally consistent equivalent earth. Indexed dispatch
+for arbitrary layer numbers is required for extension methods. It does not implement
+new multilayer Unified equations.
+
+Unified computes the averaged axial-field coefficient and the referenced
+source-potential coefficient of each conductor pair in one expression,
+[`source_coefficients`](@ref LineCableModels.Engine.EarthAdmittance.source_coefficients(::Union{LineCableModels.Engine.EarthImpedance.Formula{:unified}, LineCableModels.Engine.EarthAdmittance.Formula{:unified}}, ::Union{Val{:self}, Val{:mutual}}, ::Union{Val{1}, Val{2}}, ::Union{Val{1}, Val{2}}, ::Any, ::Any)),
+which returns them in that order.
+This expression uses the same indexed traversal as the ordinary impedance and
+potential expressions. Both coefficients are required by either
+selected physical output and are calculated from the selected Unified formulation.
+
+The [complete-current matrix calculation](@ref LineCableModels.Engine.earth!(::Union{LineCableModels.Engine.EarthImpedance.Formula{:unified}, LineCableModels.Engine.EarthAdmittance.Formula{:unified}}, ::Any, ::Any))
+converts the source coefficients into physical exterior matrices before Engine
+copies selected entries. Compatible impedance and potential selections share
+one calculation and factorization. Incompatible controls or equivalent-media
+selections retain separate calculations. Internal conductor and insulation
+contributions retain the existing composition and terminal reductions.
+
+`axial_field`, `source_potential`, and `current_map` are physical work-array
+names. Their documented exponential source-column scaling cancels from the
+right solves. Per-conductor factors use Engine-resolved layers and radii.
+Geometry validation remains with the problem and system. The shared spectral
+kernels own contour and cancellation treatment. Generic integration consumes
+the complete integrand without selecting a physical field or voltage reference.
+
+With `options=(trace=true,)`, `trace.Zg` and `trace.Pg` expose the exterior
+matrices. The returned total line admittance also includes insulation effects.
+`trace.integrals` retains the native integral values and QuadGK error estimates,
+with formula, frequency, receiver-source and term identifiers. These estimates
+are not final-matrix error bounds. Trace-off computations do not retain such history.
+For repeated inputs, each logical receiver-source retains the evaluated integral
+and its error estimate with that position's context. Warnings retain the same
+requested controls and affected positions without repeating quadrature.
+
+Select an equivalent homogeneous earth on each consuming formula:
+
+```julia
+selected = Formulation(
+ earth_impedance=formula(:default;
+ equivalent_earth=formula(:default; order=:before)),
+ earth_admittance=formula(:default;
+ equivalent_earth=formula(:default; order=:after)),
+ earth_properties=formula(:default),
+)
+```
+
+The reduction's expression
+`equivalent_material(formula, Val(kind), Val(s), Val(t), functor, workspace)` receives the
+physical selectors. The input of its Functor stores all physical layer properties
+(`rho`, `eps_r`, `mu_r`), the model, the physical pair, the frequency and the options. Its
+result is one `EarthMaterial`. It defines its numerical sections independently
+of the external equation. The consuming source explicitly admits compatible
+reductions. An explicit reduction applies to every consumer, a layered formula included.
+
+The default selects the deepest soil layer. Martins-Britto et al.
+[Martins-BrittoLopes2020](@cite) found that deep-layer conductivity predominated
+in magnetic ground-return impedance for the multilayer soil cases they studied.
+The default resistivity follows this limited approximation. Accuracy depends
+on layer contrasts and frequency. Selecting one layer does not implement the
+paper's equivalent-conductivity formula or establish the accuracy of the selected
+permittivity and permeability. Choose another built-in rule or a user-owned subtype of
+`EquivalentHomogeneous.AbstractRule` implementing `equivalent_material`.
+
+`:after` applies the selected frequency law to physical layers first. `:before` reduces static
+properties and applies that same law to the resulting material. Physical and
+effective pairs remain distinct in the binding, and reductions run for each
+ordered interaction on which they depend. Layerwise evaluated properties are reused
+between consumers when needed. The air material remains static.
+
+The following declaration sets numerical options for a user-defined outer
+surface equation:
+
+```julia
+using LineCableModels: Expression, formulation_options, FormulationOptions
+const II = LineCableModels.Engine.InternalImpedance
+# MyConductor is a concrete InternalImpedanceFormulation owned by the user.
+formulation_options(
+ ::Expression{<:MyConductor,typeof(II.internal_impedance),Tuple{Val{:outer}}},
+) = FormulationOptions()
+```
+
+The equation is `II.internal_impedance(selected::MyConductor, ::Val{:outer},
+functor, workspace)`. The formula's `Functor` method builds its state once per
+conductor and frequency, from the input `(r_in, r_ex, rho, mu_r, jω)`. All surface
+equations of that selection share this state. An integral equation declares its own integration section. An
+algebraic equation does not inherit another formula's numerical options.
+
+`InternalImpedance.surface_impedances(resolved_formula, r_in, r_ex, rho, mu_r, jω)`
+returns `(inner,outer,transfer)` coefficients in Ω/m, with per-kind
+numerical options applied. Internal kinds have no earth-layer selectors. Assemblers
+own the current-basis transformation and matrix placement. The deferred pipe
+contribution concerns one contained metal and its enclosing pipe. Recursive
+assembly and pipe equations are outside this implementation.
+
+Internal impedance takes one formula, built-in or user-owned. Its inner, outer and
+transfer surfaces are the expressions of that formula, and they share its conductor
+state. Before the frequency loop, the computation checks that the formula has an
+expression for each surface impedance that the geometry needs: all three with a tubular conductor,
+otherwise `outer`. The internal term is called `transfer`. Earth `self`/`mutual`
+interaction names are unchanged.
+
+Equation numerical sections belong to `FormulationOptions`. The existing
+`formulation_options` methods validate and normalize them at the defining stage:
+
+Spectral integration uses adaptive Gauss–Kronrod quadrature (`method=:quad`)
+over the full spectral interval. Its controls are:
+
+```julia
+integration = (method = :quad, options = (rtol = 1e-8, atol = 0.0, maxevals = 10^7))
+```
+
+`SpectralIntegral(f)`, the formulation of a Sommerfeld-type integral over the spatial
+Fourier variable, stores only the complete callable integrated over `[0, Inf)`. A formula includes its weights, any admissible contour and the
+coordinate Jacobian in `f`, and supplies numerical subdivision points to
+`integrate`. Engine knows none of those physical choices. Its half-line map
+has no finite cutoff. Algebraic cases omit both integration calls and
+integration controls. They still receive the same computation workspace.
+
+QuadGK retains physical scalar types, including BigFloat and correlated
+Measurements inputs. Numerical error norms and sampling coordinates are nominal.
+Physical values and derivatives are not replaced by nominal values.
+
+Quadrature returns its native `(value, estimated_error)`. An unmet requested
+target produces a warning and returns the finite value without an outer retry,
+tolerance tightening or matrix-error rejection. Invalid inputs, nonfinite values
+and singular physical solves remain errors. The human judges accuracy. Meaningful
+matrix-accuracy assertions belong in tests.
+
+Selected numerical formulas within one computation share reusable segment storage.
+The main workspace holds formula-owned subdivision and coupled-response arrays.
+`initialize_buffers` allocates storage for both earth and local formulas through
+dispatch on the selected formulation. Each formula supplies the complete
+integrand and its numeric subdivision hints. Compatible
+unified consumers share one current calculation per frequency and publish the
+completed entries directly. Independent computations never share mutable buffers.
+
+Each family explicitly includes its supported formula files. A file returns one identifier. An `Expression` holds a selected formulation, its indexed case and
+the family-owned operation.
+The frequency loop calls the bound methods directly. Register each distinct
+equation under its own identity.
+
+Result details retain one requested and resolved formulation record. Its `requested`
+field preserves the declaration. `methods` records the selected identities,
+physical parameters and owner-held numerical controls, including each explicit
+equivalent-earth choice and order. Unspecified numerical defaults remain owned by
+the selected equation.
+Absence of an equivalent-earth selection remains `nothing`.
+
+PSCAD extends the same equation generics with a variant that takes the PSCAD
+formulation in place of the runtime arguments:
+
+```julia
+earth_impedance(selection::EarthImpedance.Formula, ::Val{Kind}, ::Val{S}, ::Val{T}, pscad::PSCADFormulation)
+earth_potential_coefficient(selection::EarthAdmittance.Formula, ::Val{Kind}, ::Val{S}, ::Val{T}, pscad::PSCADFormulation)
+internal_impedance(selection::InternalImpedance.Formula, ::Val{Kind}, pscad::PSCADFormulation)
+```
+
+These methods compile native settings. Each actual ordered pair is validated
+using the Engine's physical geometry. A complete native settings record is used
+for project export, execution, readback and numerical-input fingerprinting.
+Unsupported selections fail before export. All selected formula types and scientific
+identifiers belong to the Engine, including equations awaiting an owned numerical
+implementation. PSCAD's defaults resolve explicitly to:
+
+```julia
+internal_impedance = :wedepohl1973
+insulation_impedance = :ametani1980
+earth_impedance = (air=:carson1926, earth=:pollaczek1926, mixed=:lucca1994)
+earth_admittance = :ideal
+```
+
+The earth-impedance composition is retained and described by its `air`, `earth`,
+and `mixed` branches. Ametani and Lucca select only mixed mutual impedance.
+The reverse ordered pair uses the same reciprocal model. `:ametani2009` retains
+the journal publication year. PSCAD's help incorrectly dates that article as 2005.
+Layer 1 is air and layer 2 is soil. Carson/Gary apply to `(1,1)`, and Pollaczek/Wedepohl/Saad to `(2,2)`.
+
+The `:ideal` external potential model uses Maxwell's electrostatic image
+coefficients for aerial conductors, with zero external coefficients for buried
+self and mutual pairs, as well as mixed air-buried pairs. Cable insulation contributes its separate
+potential coefficients. In this mathematical model, lossless admittance is purely
+imaginary, while capacitance is real.
+
+PSCAD has no independent potential-model selector. Real PSCAD 5.1 computations
+with lossless insulation and direct earth-return integration showed a small
+aerial conductance. At 1 kHz, one diagonal entry had
+`real(Y) = 5.4551e-13 S/m` and `imag(Y) = 5.3337e-8 S/m`, with a raw phase of
+`89.999414°`. The Gary/Wedepohl computation gave `90°` and agreed with the ideal
+image-potential reference. Buried and mixed mutual entries were zero in both.
+A general error bound and an explanation of its internal cause require further investigation.
+The adapter retains `:ideal` as the requested scientific identity, documents this
+native deviation in result assumptions and preserves the complete native complex
+matrices without zeroing conductance or substituting another equation.
+
+Conductor inner, outer and transfer impedances use the registered
+`:wedepohl1973` analytical approximations. Magnetic insulation impedance uses the
+registered `:ametani1980` annular expression. PSCAD's native component name is
+only an XML binding and does not identify an equation. These distinctions follow
+PSCAD's [matrix derivation](https://www.pscad.com/webhelp-pscad-v5.1.0-ol/EMTDC/Transmission_Lines/Deriving_System_Y_and_Z_Matrices.htm)
+and [external potential matrix](https://www.pscad.com/webhelp-pscad-v5.1.0-ol/EMTDC/Transmission_Lines/Mutual_Impedance_with_Earth_Return.htm).
+The coaxial backend implements `:ideal` potential coefficients. Internal
+impedance `:wedepohl1973` remains unimplemented on that backend. Its PSCAD
+dispatch is available.
+
+PSCAD dispatch maps retained equations to native settings. Gary1976 maps to PSCAD's
+`DERISEMLYEN` spelling. This does not create a second mathematical registration. Carson1926
+(overhead) and Pollaczek1926 (underground) map to native direct numerical integration.
+These names follow PSCAD's [documented earth-return selections](https://www.pscad.com/webhelp-pscad-v5.1.0-ol/EMTDC/Transmission_Lines/Mutual_Impedance_with_Earth_Return.htm).
+They identify the requested native controls, not a guarantee that native equations,
+material assumptions or results equal LCM's implementations. The exported ground
+permittivity remains the supplied material value.
+PSCAD's earth-impedance `:default` retains the explicit three-branch composition above. PSCAD rejects analytical
+kernel selections or numerical controls it cannot execute. FEM accepts only its four constitutive
+selections and rejects analytical kernel keywords at construction. It executes
+resolved material contributions without a second author registration. Constitutive
+selections passed to PSCAD remain subject to its documented export limits.
+
+PSCAD export applies the selected temperature law to the same resolved conductor
+materials used by the analytical engine. It evaluates each physical dielectric
+layer before radial homogenization, explicitly enables native loss-tangent
+handling and retains the equivalent dielectric at its 50 Hz reference frequency.
+The native loss-tangent cap is 10.
+The aerial shunt setting uses the component's minimum, `1e-38 S/m`. These native
+limits and the complete exported project accompany the results. Frequency-dependent
+soil laws are rejected until their native parameter convention is verified. A
+Julia material law is never converted into guessed Portela coefficients.
+
+FEM batches reuse a field solve only when effective material, mesh and execution
+inputs agree. Every request retains its metadata and independent result arrays.
+Saved-run checks include inputs, implementation sources, executable identity and
+artifact checksums. Incomplete compatible runs resume missing jobs. UI and explicit
+remeshing requests execute separately. See [FEM](fem.md) for execution details.
+
+`pipe_impedance=formula(:default)` uses the same selection grammar. Concentric
+assemblies do not require an additional pipe term. Eccentric or multicore conducting
+enclosures fail explicitly on the coaxial backend. FEM retains its supported
+physical enclosure geometry.
+
+## Cable-material temperature dependence
+
+Select the constitutive law in the formulation and prescribe the operating
+condition in the problem:
+
+```julia
+selected = Formulation(temperature_dependence=formula(:default))
+problem = LineParametersProblem(system; temperature=80.0, frequencies=[50.0,1000.0],
+ earth_props=homogeneous(rho=100.0))
+result = compute(problem, selected)
+```
+
+The Materials-owned `TemperatureDependent` family evaluates electrical
+resistivity. Its `:default` routes to `:linear`, which implements
+``\rho(T)=\rho_0[1+\alpha(T-T_0)]`` using each material's reference calibration.
+`temperature_dependence=nothing` retains reference resistivity. The same slot
+is available in `CableConstantsFormulation` and `LineCableModelsFEM`.
+Temperature is prescribed in this electromagnetic computation. No thermal
+rating or temperature-field equation is implied.
+
+Conductors consume the evaluated resistivity. Insulation/semicon constitutive
+relations receive an ephemeral material with evaluated resistivity before their
+electromagnetic equation. The stored reference material remains unchanged.
+Original radial dielectric constituents are evaluated before aggregation.
+
+A custom temperature law is a concrete selection, with no function-valued field:
+
+```julia
+const TD = LineCableModels.Materials.TemperatureDependent
+struct ExponentialResistivity{P,O<:FormulationOptions} <: TD.TemperatureDependentFormulation
+ parameters::P
+ options::O
+end
+function ExponentialResistivity(; scale=1000.0)
+ isfinite(scale) && scale > 0 || throw(ArgumentError("scale must be positive [K]"))
+ ExponentialResistivity((scale=scale,), FormulationOptions())
+end
+function TD.temperature_resistivity(law::ExponentialResistivity, functor, workspace)
+ (; material, temperature) = functor.input
+ return material.rho * exp((temperature - material.T0) / law.parameters.scale)
+end
+LineCableModels.formulation_options(
+ ::LineCableModels.Expression{<:ExponentialResistivity,typeof(TD.temperature_resistivity)}) = FormulationOptions()
+LineCableModels.formula_id(::ExponentialResistivity) = :exponential_resistivity
+LineCableModels.description(::ExponentialResistivity; compact=false) = "Exponential resistivity"
+Base.NamedTuple(law::ExponentialResistivity) =
+ (identifier=formula_id(law), parameters=law.parameters, options=law.options.data)
+LineCableModels.formulation_options(law::ExponentialResistivity) =
+ law.options
+selected = Formulation(temperature_dependence=ExponentialResistivity())
+```
+
+The law defines its validity domain. All responses require positive real
+resistivity [Ω·m], finite for conductors. The built-in linear approximation also
+enforces `|T-T₀| < 150` K and a positive finite linear factor. The problem itself
+validates finite temperature without imposing an unselected law.
+
+Scalar equation suffixes are uniform: physical inputs, `parameters`,
+`options`, then `workspace`. Insulation and semicon laws return finite
+admittivity [S/m], normalized to `Complex{T}` for the input scalar type `T`.
+A response requiring a wider scalar type is rejected. Precision and measurement
+uncertainty are never silently discarded. Soil laws return `EarthMaterial`.
+Their fitted coefficients are checked at construction, before a frequency sweep.
+
+## Finite formulation selection
+
+The final formulation constructors participate in the same `Gridspace`
+grammar as physical problem construction. Any line-parameter method slot or
+the complete options tuple may be an explicit finite source:
+
+```julia
+formulations = Formulation(
+ insulation_admittance = Grid((
+ :lossy,
+ :default,
+ )),
+ earth_properties = Grid((
+ :constant,
+ :longmire1975,
+ )),
+ combine = :product,
+)
+```
+
+The result is `Gridspace{LineParametersFormulation}`. Every point is a
+completely resolved scalar formulation. Before `compute` is called, each `Grid`,
+symbol selector and `FormulaDefinition` has been resolved. `combine=:zip` aligns formulation fields and
+broadcasts singleton fields. This local composition is separate from
+`Combinatorial`, which always evaluates the Cartesian product of problem and
+formulation points.
+
+`CableConstantsFormulation`, `ModalAnalysisFormulation`, and backend
+formulation constructors follow the same rule. A deterministic `Grid` of
+already completed formulations, including externally supplied formulations is also accepted by
+`Combinatorial`.
+
+For each selected problem, the Coaxial collection dispatch validates and
+lowers the physical declaration once. LineParameters flattens each design and
+constructs `LocalCableData` plus geometry and index input once before creating a
+separate workspace for every formulation. CableConstants performs its own
+calculation sequence, also flattening each design once. Formula-dependent mutable matrices,
+earth/EquivalentHomogeneous values, reduction maps, and trace buffers remain workspace-local.
+The generic collection dispatch simply invokes established scalar `compute`
+methods and supports external problem-formulation pairs without a
+new registration layer.
+
+## Earth-free cable constants
+
+`CableConstantsProblem`, `CableConstantsFormulation`, and `CableConstants`
+belong to Engine. They reuse the registered internal-impedance,
+insulation-impedance, insulation-admittance, and semicon-admittance formulas,
+and the same earth-free local primitive assemblers used by LineParameters.
+Each computation has its own solve and reduction methods.
+The default bundle is:
+
+```julia
+CableConstantsFormulation(
+ internal_impedance = formula(:default),
+ insulation_impedance = formula(:default),
+ insulation_admittance = formula(:default),
+ semicon_admittance = formula(:default),
+)
+```
+
+`Engine.flatten(LineCableModelsCoaxial(), design, formulation)` supplies a
+frequency-independent, unreduced `CableBlueprint`. Omitting the formulation
+uses the default annular model. Contiguous components sharing one radial center
+form one concentric assembly. Explicit boundary shunt coefficients are completed
+during flattening. Frequency-dependent constitutive evaluation and conductor
+temperature corrections remain in the computation. The Engine retains
+each assembly's innermost terminal, grounds every additional outward terminal,
+assembles and reduces the local N-terminal series-impedance matrix, and combines
+the physical dielectric layers in radial series. A one-terminal assembly uses
+the declared outer dielectric boundary directly. It does not require a metallic
+sheath. Earth impedance, earth admittance, EquivalentHomogeneous, Γ, position, transposition,
+and bundle reduction never enter this workflow.
+
+`CableConstants(design; temperature=20, frequency=50)` is the convenience
+entry point. CableConstants admits only the 50 Hz and 60 Hz datasheet base
+frequencies. The result owns `cores`, aligned `R/L/C/G` vectors, and the
+evaluation frequency. A conventional coaxial cable has one row and supports
+`only(constants)`.
+
+## Completed-result read side
+
+`AbstractCoreResult` marks direct LineCableModels-owned computation results.
+`CableConstants` and `LineParameters` are the current core result types.
+`AbstractResultSpace{T}` marks completed finite collections of stored core
+results. Its element type remains open so an external solver's concrete result
+can be stored without subtyping a LineCableModels type. Result-space
+constructors reject abstract element types and nested result-space envelopes.
+
+Core results own their scientific extraction methods.
+[`observe`](@ref) reads native numerical values through function-object
+selectors:
+
+```julia
+observe(parameters, Z)
+observe(parameters, L, 1, 1, Colon())
+observe(parameters, Y, angle, 1, 1, Colon())
+```
+
+The public `Z`, `L`, and other quantity accessors delegate to these methods,
+which define the R/X/L/G/B/C extraction formulas.
+
+Direct numerical access remains available:
+
+```julia
+parameters.Z[1, 1, :]
+@view parameters.Y[1, 1, :]
+Z(parameters, 1, 2)
+@observe parameters L[1, 2, :]
+```
+
+`@observe` expands the indexed expression to `observe(parameters, L,
+1, 2, Colon())`. With no source argument it constructs the same plain request
+tuple without reading a result:
+
+```julia
+request = @observe R[:, :, :]
+magnitude_request = @observe (Z, abs)[:, :, :]
+magnitude = @observe parameters (Z, abs)[1, 2, :]
+```
+
+Each request tuple contains the selected quantity and its coordinates.
+`quantity(Z, abs)` and `quantity(Z, angle)` identify the transformed quantities.
+
+[`ObservedResult`](@ref) detaches the selected scientific representation of one
+completed gridpoint. `observables` lifts the same construction over collections:
+
+```julia
+observed = ObservedResult(parameters, (R, L, G, C); length_unit=:kilo)
+resistance = observe(observed, R)
+quantity_tables = LineCableModels.ReportBuilder.tabulate(observed)
+quantity_tables.Z.R
+plot(observed; ydata=(R,))
+```
+
+The 4 sections are `gridpoint`, `quantities`, `errors`, and `timings`.
+Coordinates, units, cutoffs, availability reasons, and uncertainty dependencies
+belong to the detached records. No raw result, lazy builder, or parent collection
+is retained. Physical inputs and actual formulation descriptions are captured
+when the computation completes, independently of optional tracing.
+
+`Commons.observation_requests` owns request normalization. Primary line results
+retain one complete representation per available family. The series choices are
+`R/X`, magnitude and angle of Z, or R/L. The shunt choices are G/B, magnitude
+and angle of Y, or G/C. An atomic request for R alone
+fails. Raw plotting and table conveniences complete the pair through that same
+operation and display the requested selection. Observed-input methods only select
+retained quantities. Frequency is a coordinate of each product.
+
+`Commons.observation_quantity` owns acquisition, unit conversion, and resolution.
+`Commons.observation_groups` establishes display groups from the original
+physical identity, relevant formulation controls, statistical meaning, output
+coordinates, and uncertainty dependencies. Both reporting and plotting consume
+that decision. All individual observations and quantity tables remain available.
+
+`LineCableModels.Units` owns `Unit`, `UnitExpr`, `Quantity`, `units`,
+`quantity`, `native_unit`, `display_unit`, `scale_factor`, `label`, and
+`symbol`. The plotting extension derives scientific axes from publication payloads.
+ReportBuilder derives human-facing tables through `report`. Both consumers use
+the quantities, transforms, and unit metadata supplied by the observation grammar.
+
+`Quantity{Q}` is a fieldless typed identity for extension methods and internal
+publication payloads. Ordinary calls use scientific selector functions:
+
+```julia
+label(R)
+symbol(Z, angle)
+native_unit(R, :pul)
+display_unit(Z, abs, :total)
+```
+
+The selector methods delegate to the quantity's label and unit methods. An
+external selector adds its identity and metadata at the Units API:
+
+```julia
+function profile_response end
+
+LineCableModels.Units.quantity(::typeof(profile_response)) =
+ LineCableModels.Units.Quantity{:profile_response}()
+
+LineCableModels.Units.native_unit(
+ ::LineCableModels.Units.Quantity{:profile_response},
+) = LineCableModels.Units.units(:base, :ohm)
+
+LineCableModels.Units.display_unit(
+ ::LineCableModels.Units.Quantity{:profile_response},
+) = LineCableModels.Units.units(:milli, :ohm)
+
+LineCableModels.Units.label(
+ ::LineCableModels.Units.Quantity{:profile_response},
+) = "Profile response"
+
+LineCableModels.Units.symbol(
+ ::LineCableModels.Units.Quantity{:profile_response},
+) = "u"
+
+label(profile_response)
+display_unit(profile_response)
+```
+
+`Quantity`, `Unit`, and `UnitExpr` remain qualified extension vocabulary. The
+package root exports only the six metadata functions used with scientific
+selectors.
+
+Higher-order results remain containers of owned products. `result`,
+`statistics`, `samples`, and `histograms` select those products. UQ reads trial results with
+`observe`, while its retained statistics, samples, and histograms implement
+the same selector grammar for later publication. Monte Carlo run settings and
+resolved point values are read through `root_seed`, `point_seed`,
+`trial_count`, `confidence`, `cdf_tolerance`, and `sampling_distribution`.
+
+## Coaxial workspace and supplemental output
+
+`LineCableModelsCoaxial` solves concentric coaxial assemblies. A sector,
+stranded conductor or otherwise nonconcentric part must be represented by the equivalent
+round and concentric properties owned by DataModel before it reaches this backend.
+The backend then owns frequency scans, self and mutual line parameters, earth
+effects, and reduction. DataModel supplies the equivalent cable properties.
+ModalAnalysis defines transformations to modal coordinates.
+
+`LineParametersWorkspace` is the coaxial backend's per-computation working
+state. Its constructor adapts a completed physical system once. It binds equations and constructs cable and reduction indices, and allocates numerical buffers. Material
+evaluation follows workspace construction. QuadGK arrays are allocated when
+a selected equation requires integration. Its shunt solvers consume DataModel's ordered physical
+dielectric layers directly, so the analysis is independent of the frequency
+used when a lossy homogeneous export representation is requested.
+
+The workspace separates four owned concerns:
+
+- `input`: immutable numerical input derived from the problem.
+- `plan`: index maps, geometry, bound earth calculations and the reduction plan, fixed
+ before the frequency loop.
+- `buffers`: mutable storage reused while solving each frequency.
+- `trace`: optional diagnostic matrices allocated before the loop.
+
+The ordinary result is always `LineParameters`. Requesting
+`options=(trace=true,)` retains completed diagnostic arrays under
+`details(parameters).data.trace`.
+
+## Modal transformations
+
+Modal decomposition is independent of the backend that produced fully coupled
+phase-domain matrices. `LineCableModels.ModalAnalysis` defines its own problem,
+formulation, registered formula files, and default backend:
+
+```julia
+phase = compute(line_problem, line_formulation)
+modal = compute(
+ ModalAnalysisProblem(phase),
+ ModalAnalysisFormulation(
+ formula(:default; options=(iteration=(convergence=1e-8,),)),
+ ),
+)
+rebuilt = transform(PhaseDomain, modal)
+```
+
+`LineCableModelsModal` is the default backend for this workflow. Each formula
+has one selected `initialize_buffers` and `decompose!` route. It fills the
+modal-to-phase `Tv` and `Ti` bases and the retained propagation roots. The
+common calculation changes coordinates with `Tv \ (Z * Ti)` and
+`Ti \ (Y * Tv)`.
+
+The modal `LineParameters` result includes `ModalDomain(operators, gamma)` as
+its domain value. The stored bases preserve the resolved mode
+order, scaling, and complex phase convention and make the transformation
+bidirectional without rerunning the decomposition. An operator-less modal
+result cannot be constructed through the admitted `LineParameters` interface.
+The formula selection and numerical diagnostics live in computation details.
+The domain holds numerical state. Inverse conversion uses that state without
+rerunning the decomposition.
+
+The retained modal formula is selected by `ModalAnalysisFormulation()`.
+Explicit controls use `formula(:default; options=(iteration=(convergence=1e-8,),))`.
+A custom decomposition is a completed formulation implementing
+`Commons.initialize_buffers(selected, T, input, plan, common)` and
+`ModalAnalysis.decompose!(selected, workspace, parameters, options)`.
+The latter fills `workspace.Tv`, `workspace.Ti`, and `workspace.roots`.
+`ModalAnalysisFormulation` retains its
+requested declaration and resolved selection for the common inspection protocol.
+The default tracks eigenpairs with Levenberg–Marquardt iteration, retaining a
+matched conventional eigensolution when iteration fails. Its bibliography stays
+in `src/engine/modalanalysis/formulas/chrysochos2014.jl`.
+
+[`ComputationDetails`](@ref) is an immutable record of supplemental computation
+output, parameterized by its named-tuple payload. Access its fields through `.data`.
+[`computation_details`](@ref) returns the fixed-key details record supplied by the
+formulation's owner. Higher-order computations dispatch on `typeof(formulation)`
+when collecting these records. A formulation without a method raises `MethodError`.
+
+[`ParametricResult`](@ref), [`LinearErrorResult`](@ref), and
+[`MonteCarloResult`](@ref) store the concrete type of the details record. Retention is
+disabled by default, so `details(result) == ComputationDetails()`. The higher-order formulation
+defines the retention option:
+
+```julia
+Combinatorial(formulation; options=(retain_details=true,))
+LinearError(formulation; options=(retain_details=true,))
+MonteCarlo(formulation; trials=100, options=(retain_details=true,))
+```
+
+Parametric and linear computations retain `ComputationDetails(points=records)`, with one record
+per core result. Monte Carlo retains `trials`, `failures`, and
+`failure_summary`, each aligned by Gridspace point. `trials` contains one inner
+computation record per accepted trial. Each failure record contains the
+attempt, target trial, failure stage, realized argument tuple, error type and
+message, and a bounded stack summary. Statistics, samples, histograms, seeds,
+and accepted-trial counts remain dedicated result fields.
+
+## Formulation options
+
+[`formulation_options`](@ref) validates values that alter the mathematical
+computation represented by a formulation. Dispatch uses the formulation owner
+type rather than the public construction selector:
+
+```julia
+formulation_options(LineParametersFormulation, FormulationOptions(reduce_bundle=false))
+```
+
+The default line-parameter formulation owns:
+
+- bundle and Kron reduction, `reduce_bundle=true` and `kron_reduction=true` by
+ default.
+- ideal transposition, `ideal_transposition=false` by default. When selected, the
+ retained ``Z`` and potential-coefficient matrix ``P`` are averaged over cyclic
+ transposition, and ``Y`` is the inverse of the averaged ``P`` times ``j\omega``.
+
+The normalized `FormulationOptions` record is stored in
+`LineParametersFormulation.options`. Read its payload through `.data`.
+`PSCADFormulation` uses the shared physical options and currently requires
+unreduced, untransposed matrices.
+
+Connection assignments use one-based active phase IDs. A zero assignment marks
+a grounded or eliminated conductor, while repeated active IDs identify conductors
+that belong to the same bundle.
+
+`Formulation()` constructs the default coaxial formulation.
+`LineParametersFormulation` defines its formulation options, and
+`LineCableModelsCoaxial` executes it. External backends are selected through
+their registered Symbol or `Val` selectors.
+
+Modal formulas include an `iteration` section containing convergence, iteration
+count, damping and the `:matched` or `:none` fallback settings. The computation action
+accepts `offdiagonal_tolerance` separately. Frequency continuation belongs to one
+run. Result details record the frequency indices where matched eigensolutions were
+used. The stored voltage and current operators are retained for inverse transformation.
+
+## Computation options
+
+[`computation_options`](@ref) validates values belonging to one execution.
+Selected equation controls belong to `FormulationOptions`, even when they set
+quadrature or iteration tolerances. Backend execution options govern output,
+tracing, logging and callbacks.
+
+Public `options=(...)` keywords accept ordinary named tuples or the appropriate
+owned record. They wrap tuples once before passing them to the owner. Direct
+`computation_options(Owner, options)` calls require `ComputationOptions`. Direct
+`formulation_options` calls require `FormulationOptions`. The owner checks keys,
+fills defaults and validates values. Constructing a wrapper alone does none of
+that. Normalized controls are forwarded without reapplying normalization.
+
+The 3 record types preserve the exact payload type, including callback
+and sampler types. Access payload fields through `.data`. Implicit conversions and tuple forwarding are unsupported. Immutability is shallow: contained
+arrays are not copied or frozen. Nested numerical groups, physical parameters,
+scientific products, axes and plotting attributes remain ordinary named tuples.
+
+`Combinatorial`, `LinearError` and `MonteCarlo` normalize their own execution
+options inside their constructors, including positional construction.
+The complete set of Monte Carlo controls is stored in `MonteCarlo.options` and passes through
+`computation_options(MonteCarlo, options)`. Keyword shorthand remains available:
+
+```julia
+MonteCarlo(formulation; options=(trials=100, seed=42, retain_details=true))
+MonteCarlo(formulation; trials=100, seed=42, options=(retain_details=true,))
+```
+
+These two calls are equivalent. Supplying the same key both as a keyword and
+inside `options` raises `ArgumentError`. Unknown keys and invalid values also
+raise `ArgumentError`.
+
+`ParametricProblem(space, ComputationOptions(...))` stores options for the **inner computation**.
+Grid, batch, combinatorial and uncertainty traversal forward that record to the
+selected core solver, whose `computation_options` method validates it. The
+problem cannot normalize it at construction because the solver has not yet
+been selected. Traversal retention and Monte Carlo sampling controls belong to
+the higher-order formulation's own `ComputationOptions` record.
+
+### Scan timing and progress
+
+Line-parameter computations accept `timing::Bool=false` independently of logging:
+
+```julia
+execution = ComputationOptions(timing=true, verbosity=(default=0, progress=1))
+result = compute(problem, formulation; options=execution)
+scan = details(result).data.timing
+```
+
+For a higher-order computation, keep these controls in
+`ParametricProblem(problem_space, execution)`. Cable-constant computations and
+modal actions retain their existing option handling.
+
+One measurement describes one materialized problem and formulation over the
+complete requested frequency vector. The owned backend uses `Base.@timed` around
+its existing workspace construction, solve, and validated result construction.
+Batch-shared input construction, timing attachment, `on_result`, and subsequent
+progress logging lie outside the measured expression. 3 executed formulations produce
+three measurements. Shared input construction is neither duplicated nor apportioned.
+Compilation that occurs before the measured expression begins is not included.
+
+| Backend | Fields in `details(result).data.timing` |
+|---|---|
+| Owned | `wall_seconds`, `bytes`, `gc_seconds`, `compile_seconds`, `recompile_seconds` |
+| FEM | `wall_seconds`, `constraint_seconds`, `assembly_seconds`, `solve_seconds`, `output_seconds`, `worker_wall_seconds` |
+| PSCAD | `wall_seconds`, `compile_call_seconds` |
+
+All durations are in seconds. `bytes` is Julia allocation volume, not retained
+size or peak memory. Native `wall_seconds` covers caller-side execution and
+completed result construction after shared input construction. FEM phase durations sum
+the existing native column measurements. `worker_wall_seconds` sums newly
+executed process durations, not elapsed scan time. PSCAD `compile_call_seconds`
+measures the worker's `line.compile()` call and excludes output-readiness waiting.
+Caller Julia allocation measurements do not describe native memory use in either backend.
+
+Fully reused, partially recovered, and within-batch reused results have empty
+timing records `(;)`. A fresh complete scan retains available measurements and
+uses `nothing` for unavailable optional native metrics. With `timing=false`, the
+field is absent. Required native timing files and recovery checks remain active.
+FEM records reuse, recovery and factorization facts in `details(result).data.fem.run`.
+PSCAD execution facts remain in `details(result).data.execution`.
+
+Parametric and linear-error results keep measurements in their scalar values.
+Monte Carlo retains one record per accepted and stored trial at
+`details(result).data.timing[population_index][accepted_trial_index]`, independently
+of `retain_details`, `return_samples`, and histogram retention. Failed attempts
+are not successful measurements, and aggregate mean and standard-deviation values do not receive scan
+timing. Supported scientific serialization preserves optional timing, including
+through the Measurements extension. Records without timing remain readable.
+
+Progress uses ordinary structured Julia `@info` messages with `_group=:progress`.
+The effective level is `get(verbosity, :progress, verbosity.default)`. Other
+messages use the nearest explicitly configured module ancestor, then `default`.
+Levels 0, 1, and 2 permit warnings, information, and debug messages respectively.
+The caller's current logger still controls acceptance and output: verbosity does
+not override a `NullLogger`, parent threshold or custom filter. Explicit FEM file
+logging and native Gmsh/GetDP/PSCAD diagnostic settings retain their own behavior.
+
+An outer traversal suppresses child progress while preserving other options and
+backend diagnostics. It logs start and successful completion and checks for
+intermediate publication at existing completion points, at most every five
+seconds per traversal.
+ETA uses an exponential moving average with weight 0.2 on the newest completed
+interval. Parametric intervals are divided by the returned batch's result count.
+MC intervals between accepted trials include rejected attempts. MC resets the
+sampling estimate for each population and estimates overall remaining time only
+after a population completes. Estimates use the current traversal's completed
+intervals.
+Successful completion is reported after result construction and callbacks.
+
+For bounded repetition, use [`LineCableModels.benchmark`](@ref):
+
+```julia
+measurement = LineCableModels.benchmark(; samples=3, warmup=1) do
+ compute(problem, formulation;
+ options=(timing=true, verbosity=(default=0,)))
+end
+result, timings = measurement
+```
+
+The defaults are one measured repetition and no warmup. Positive measured counts
+and nonnegative warmup counts must be integers other than `Bool`. The function
+returns `(; result, timings)` with the final scientific result and detached small
+measurements for each repetition. Scalar records stay scalar. Ordinary batches
+retain formulation order. Parametric results retain problem-index-fastest value
+order. Linear-error results retain value order. MC retains population and trial order.
+Missing requested timing raises an error. Empty reuse records remain empty.
+
+The callable defines options, seeds, callbacks, and reuse. The returned timings
+are the measurements recorded by each computation. Earlier results are released
+after their timing records are extracted. Read the repetition records through
+the returned `timings` value.
+
+The coaxial backend accepts:
+
+```julia
+(
+ verbosity = (default = 0,),
+ output_basis = :pul,
+ trace = false,
+ timing = false,
+ on_result = nothing,
+)
+```
+
+`trace=true` preallocates the workspace's trace record and attaches
+the retained matrices to `details(result).data.trace` after computation.
+
+Coaxial, FEM, and PSCAD computations accept an optional callable
+`on_result(problem, index, result)`. It runs synchronously after each completed
+formulation, including a reused result, before computing the next selection.
+The index refers to the submitted formulation collection (`1` for a scalar
+call). Manual campaigns can save each completed result before the rest of the batch finishes. The callback must not mutate its arguments. Its return
+value is ignored and any exception stops execution. The default is `nothing`.
+
+The PSCAD backend accepts:
+
+```julia
+(
+ output_stem = "case_name",
+ remote = remote_config,
+ verbosity = (default = 0, PSCAD = 2),
+ output_basis = :pul,
+ on_result = nothing,
+)
+```
+
+`remote` must be a `PSCAD.RemoteConfig`. `output_stem` names files
+created by that execution. Both values belong to that execution rather than `PSCADFormulation`.
+
+Construct a station from a user-selected TOML filename:
+
+```julia
+station = PSCAD.RemoteConfig(ENV["LINECABLEMODELS_PSCAD_CONFIG"])
+result = compute(problem, Formulation(:pscad); options=(remote=station,))
+```
+
+Pass the configuration filename explicitly. This example reads it from a
+caller-selected environment variable. Loading the TOML file reads station
+settings and resolves paths. Keep machine settings outside version control.
+A sanitized template is provided in `examples/pscad/remote.example.toml`.
+Relative `local_root` paths are resolved against the configuration file's directory.
+`local_root` and `shared_root` expose the same files on caller and station.
+`remote_root` is separate station scratch storage. Executable and station paths
+are supplied by the user.
+
+The default `transport="ssh"` supports ordinary SSH aliases, tunnels, and network
+addresses. `transport="local"` invokes PowerShell directly from a Windows caller.
+For a custom wrapper, use `transport="command"` and an argument array such as
+`command=["ts", "ssh", "{host}", "--direct", "--"]`. Exact `{host}` arguments are
+replaced by the configured host. Encoded PowerShell arguments are appended without
+shell evaluation. Caller-defined `remote_command` methods remain supported.
+
+Fresh calls execute PSCAD once per distinct native request. Explicit
+`resume_run_directory=:latest` or a completed-run path requests verified native
+reuse. `solver_identity` optionally pins the installation. Reuse checks the current
+station, exported project, complete native settings, worker sources, and outputs.
+Resuming requires a complete version-4 run record that passes these checks.
+`work_root` defaults to the configured `local_root`. These are computation options.
+
+PSCAD requires deterministic inputs, including base frequency, and rejects
+measurement objects even when their standard deviation is zero. It returns normal
+phase-domain `LineParameters`, with both matrices permuted to the requested terminal
+order. Stored native outputs retain their original ordering.
+
+Both option sets are ordinary `NamedTuple`s, aliased as
+[`FormulationOptions`](@ref) and [`ComputationOptions`](@ref). Callers can
+compose them with `merge`. Each owner rejects unknown keys and returns a
+fixed-key normalized tuple. There is no general fallback and no conversion
+from dictionaries, pairs, or `nothing`.
+
+`MonteCarlo` defines a separate outer computation-option tuple. Its normalized
+keys are `retain_details`, `on_error`, and `max_failures`. `on_error=:fail` is
+the default and rethrows every exception. `on_error=:resample` requires
+`retain_details=true` and rejects only `DomainError` realizations until the
+requested accepted-trial count is reached or `max_failures` is exhausted.
+Other exception types always propagate immediately.
+
+## Extending the engine
+
+An external package may own a backend identity and a separate formulation
+type. The backend's `compute` method normalizes execution options before doing
+work:
+
+```julia
+import LineCableModels:
+ AbstractFormulation,
+ AbstractCoreResult,
+ ComputationOptions,
+ FormulationOptions,
+ ComputationDetails,
+ computation_details,
+ computation_options,
+ compute,
+ formulation_options
+
+struct ExternalEngine end
+
+struct ExternalFormulation{O <: FormulationOptions} <: AbstractFormulation
+ options::O
+end
+
+function formulation_options(
+ ::Type{ExternalFormulation},
+ options::FormulationOptions,
+)::FormulationOptions
+ isempty(options.data) || throw(ArgumentError("unsupported formulation option"))
+ return FormulationOptions()
+end
+
+function ExternalFormulation(;
+ options::Union{NamedTuple,FormulationOptions} = FormulationOptions(),
+)
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ return ExternalFormulation(formulation_options(ExternalFormulation, options))
+end
+
+function computation_options(
+ ::Type{ExternalEngine},
+ options::ComputationOptions,
+)::ComputationOptions
+ unknown = filter(key -> key != :tolerance, keys(options.data))
+ isempty(unknown) || throw(ArgumentError("unsupported computation option"))
+ normalized = merge((tolerance = 1.0e-8,), options.data)
+ normalized.tolerance > 0 || throw(ArgumentError("tolerance must be positive"))
+ return ComputationOptions(tolerance = Float64(normalized.tolerance))
+end
+
+function compute(
+ ::ExternalEngine,
+ problem,
+ formulation::ExternalFormulation;
+ options::Union{NamedTuple,ComputationOptions} = ComputationOptions(),
+)
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ execution = computation_options(ExternalEngine, options)
+ # Use `problem`, `formulation`, and `execution` here.
+end
+```
+
+External implementations extend these two normalization functions for their
+formulation and backend types. An omitted method raises `MethodError`.
+
+The same backend may expose supplemental output without changing the generic
+higher-order result types:
+
+```julia
+import LineCableModels: ComputationDetails, computation_details
+
+struct ExternalResult <: AbstractCoreResult
+ parameters
+ diagnostics::NamedTuple
+ raw::Dict{String,Any}
+end
+
+function computation_details(
+ ::Type{<:ExternalFormulation},
+ output::ExternalResult,
+)::ComputationDetails
+ return ComputationDetails(;
+ diagnostics=output.diagnostics,
+ raw=output.raw,
+ )
+end
+```
+
+An external backend that cannot modify or wrap its solver's concrete return
+type may store that type directly in a result space. `AbstractCoreResult` marks
+owned direct results. It is not an admission requirement for external result
+payloads.
+
+The outer keys and their types are fixed for `ExternalFormulation`. Dynamic
+vendor channels remain inside the explicit `raw` leaf. ParametricBuilder and
+UQ collect these records when `retain_details=true`, preserving the fields
+provided by the backend.
+
+## Reports and XLSX output
+
+[`report`](@ref) executes `tabulate`, `illustrate`, `encode`, and `write`, then
+constructs a `ReportArtifact`. Tabulation is implemented by the report definition. The
+other stages have optional defaults. In-memory reports have `output === nothing`.
+Raw conveniences construct observations before entering this sequence.
+
+For formulation comparisons, ReportBuilder retains unformatted data in
+`artifact.observed` and exposes `summary`, `maxima`, `formulations`, `computations`,
+`comparisons` and `terms` through `artifact.tables`:
+
+```julia
+using LineCableModels.ReportBuilder: BenchmarkTableDefinition
+
+results = compute(problem, Formulation(earth_properties=Grid((:constant, :longmire1975))))
+reference = compute(problem, Formulation())
+artifact = report(BenchmarkTableDefinition(), (; reference, result=results))
+artifact.tables.summary
+artifact.tables.terms
+# After loading a Makie backend:
+plot(artifact, (Z, Y))
+```
+
+The default request compares all Z/Y/R/L/G/C matrix terms in five bands and
+returns tables in memory. Completed comparisons are joined to their result
+identities before observation construction. `artifact.reference` is a separate
+atomic observation. To change numerical settings, compute the comparison again.
+Arithmetic dimensions, coordinates, and units must be usable. Scientific
+comparability remains the caller's responsibility. One reference is overlaid
+once alongside all selected study points.
+
+[`XLSXReportDefinition`](@ref) defines the human-facing line-parameter workbook:
+
+```julia
+using XLSX
+
+artifact = report(
+ XLSXReportDefinition(file_name="line_parameters.xlsx"),
+ parameters,
+)
+artifact.output
+```
+
+ReportBuilder creates one table and XLSX file per gridpoint and quantity. A full
+matrix table has one frequency row and all n² coefficient columns, including both
+off-diagonals. Each workbook contains numeric `values` and `std` sheets plus
+metadata for coordinates, units, applied cutoffs, and missing-value reasons.
+Loading XLSX activates the writer for these encoded tables. `artifact.output` is
+the list of written paths. `export_data(:xlsx, parameters; ...)` delegates to the
+same workflow. Relative paths resolve from the caller's working directory.
+
+For persistence of uncertainty dependencies and scalar precision, use
+`save(artifact, "observed.jls")` or JSON and `import_data(:observed, path)`.
+The restored observations can be reported or plotted without original sources.
+
+
+
+Scalar computation selections are retained in `details(result).data.formulations`.
+Its `requested` and `methods` fields hold complete requested and resolved records,
+including physical parameters and numerical controls. Formula identifiers are available
+as `record.requested.earth_admittance.identifier` (or through the corresponding
+`air`, `earth`, `mixed` leaf). Resolved identities are under `methods`. A leaf's
+`equivalent_earth` field contains its reduction choice and order. Realized local
+shunt outcomes, including explicit fallback, remain in `details(result).data.shunt_model`.
+Optional `trace` contains detached evaluation data. Reports use the recorded
+formulation selections and voltage references to describe each result.
diff --git a/docs/src/extensions.md b/docs/src/extensions.md
new file mode 100644
index 000000000..1cddd7d9a
--- /dev/null
+++ b/docs/src/extensions.md
@@ -0,0 +1,336 @@
+# Extension API
+
+Extension code adds methods to the owner that defines their meaning. The public
+computation entry points are `compute`, `observe`, `observables`,
+`report`, `plot`, and `preview`.
+
+`PlotBuilder` owns only optional plotting entry points and the live `UIPlot`
+handle. Scientific owners expose observations, quantities, geometry, and
+physical property ranges. The Makie extension owns request normalization,
+palettes, legend grouping, layout helpers, widgets, and native rendering.
+
+## Formula selection and descriptions
+
+`formula_id` identifies a scientific selection. `description` supplies its
+human-readable text. Registered formula types implement
+`description(::Type{<:OwnedFormula{:ID}}; compact=false)`, and their instances
+delegate with the same keyword. The default is the detailed scientific
+description. `compact=true` is the short name used in legends. Override this
+method to customize a name, not `formula_id`.
+
+A family declares independently selectable children through
+`pairs(Family.Formula; quantity=nothing)`, returning ordered `slot => family`
+pairs, or an empty mapping for a scalar leaf. Constructors, retained-record
+readers and formulation projections use that same owner declaration.
+Internal impedance admits `inner`, `outer`, `transfer`. The external earth
+families admit `air`, `earth`, `mixed`. Explicit composites retain every branch,
+including default branches, in quantity-relevant legends.
+
+Formulation owners expose ordered `(owner, route_tuple) => selection` pairs
+through `pairs(source; quantity)` and `pairs(owner, retained; quantity)`.
+Each selected leaf is paired with its declaration controls.
+`formulation_options(selection)` reads the live selection's `FormulationOptions`.
+Consumers obtain identity from `formula_id`, structure and ordering from `pairs`,
+and display text from `description`. The contextual
+`description(owner, selection; compact)` method describes the backend's equations,
+including PSCAD's native defaults.
+
+## User-owned formulas
+
+A formula is a collection of expressions. A user-owned formula is a concrete type below its
+family's formulation supertype. It stores its model `parameters` and its normalized
+`options`, a `FormulationOptions` record, and the user selects it directly in its physical
+slot, such as `Formulation(earth_properties=MySoil(...))`. Its methods dispatch on its own
+type: the family operation for each of its expressions, `formulation_options` for the
+options of each expression, and the metadata methods `formula_id`, `description` and
+`NamedTuple`. A formula that checks its input or shares values adds a `Functor` method, and
+one that needs arrays adds an `initialize_buffers` method. IDs are for inspection only.
+`:default` resolves to an explicit implementation before physical validation or computation.
+
+### Expressions
+
+`Commons.Expression(formula, operation, selectors...)` names one expression of a formula.
+Its `Val` selectors identify the part, such as the kind and the source and target layers of
+an earth interaction, or the inner, outer or transfer kind of an internal impedance. Calling the expression with a Functor and a workspace evaluates
+`operation(formula, selectors..., functor, workspace)`. The formula object comes first, and
+the selectors follow it.
+
+A formula declares the defaults of each expression with
+`formulation_options(::Expression{<:MyFormula, typeof(operation), ...})`. The family projects
+the options supplied to the formula onto the expressions that consume them. Before the
+frequency loop, `validate(expression)` checks that the operation has a method for the
+formula and its selectors.
+
+### The Functor
+
+`Commons.Functor(formula, input, state)` is a formula at one evaluation point. `input`
+stores the values of that point, such as the material and the frequency, with the options
+of the expression evaluated there. `state` stores plain values that the expressions of the
+formula share at that point, such as Schelkunoff's scaled Bessel values or Unified's
+per-frequency system. Arrays come from `workspace.buffers`, never from the state. Every
+family evaluates its expressions through the same path:
+
+```julia
+functor = Functor(formula, input; workspace)
+value = Expression(formula, operation, selectors...)(functor, workspace)
+# This evaluates operation(formula, selectors..., functor, workspace).
+```
+
+Without a method of its own, a formula gets an empty state. A family or a formula that
+checks its input, shares values or reads buffers adds a method
+`Functor(formula::MyFormula, input::NamedTuple; workspace)` that returns
+`Functor(formula, input, state)`. `Functor(functor, extension)` keeps the formula and the
+state and extends the input, such as with one conductor pair of an earth calculation.
+
+| Family | Formulation supertype | Operation | Functor input |
+|---|---|---|---|
+| Internal impedance | `Engine.InternalImpedanceFormulation` | `InternalImpedance.internal_impedance(formula, Val(kind), functor, workspace)`, with `kind` one of `:inner`, `:outer`, `:transfer` | `r_in`, `r_ex`, `rho`, `mu_r`, `jω` and the `options` of that kind |
+| Insulation impedance | `Engine.InsulationImpedanceFormulation` | `InsulationImpedance.insulation_impedance(formula, functor, workspace)` | `r_in`, `r_ex`, `mu_r`, `jω`, `options` |
+| Insulation and semicon admittivity | `Engine.InsulationAdmittanceFormulation`, `Engine.SemiconAdmittanceFormulation` | `insulation_material` or `semicon_material(formula, functor, workspace)` | `material`, `frequency`, `temperature`, `options` |
+| Earth impedance and potential | `Engine.EarthImpedanceFormulation`, `Engine.EarthAdmittanceFormulation` | `earth_impedance` or `earth_potential_coefficient(formula, Val(kind), Val(source), Val(target), functor, workspace)` | `pair`, `physical`, the pair's columns of `rho`, `epsilon` and `mu`, `thickness`, `jω`, `frequency`, `media`, `options` and the `destinations` |
+| Unified earth return | `EarthImpedance.Formula{:unified}`, `EarthAdmittance.Formula{:unified}` | `EarthAdmittance.source_coefficients(formula, Val(kind), Val(source), Val(target), functor, workspace)`, which returns `(axial, potential)` | as for the earth families, with Unified's per-frequency state |
+| Soil frequency dependence | `Earth.FrequencyDependent.FrequencyDependentFormulation` | `earth_material(formula, functor, workspace)` | `material`, `frequency`, `options` |
+| Temperature dependence | `Materials.TemperatureDependent.TemperatureDependentFormulation` | `temperature_resistivity(formula, functor, workspace)` | `material`, `temperature`, `options` |
+| Equivalent earth | `Earth.EquivalentHomogeneous.AbstractRule` | `equivalent_material(formula, Val(kind), Val(source), Val(target), functor, workspace)` | `rho`, `eps_r`, `mu_r`, `model`, the physical `pair`, `frequency`, `options` |
+| Modal decomposition | `AbstractFormulation`, selected by `ModalAnalysisFormulation` | `Commons.initialize_buffers(formula, T, input, plan, common)` and `ModalAnalysis.decompose!(formula, workspace, parameters, options)` | none |
+| Local shunt geometry | `Engine.ShuntModelFormulation` | `Engine.internal_shunt_response(formula, design, geometry, T, material_selections, solutions, design_index)`, during blueprint construction | none |
+| Pipe applicability | `Engine.PipeImpedanceFormulation` | `validate(design, formula, backend)` admits the topology of `design` or throws. No analytical pipe equation is supplied. | none |
+
+An earth expression returns one coefficient per destination of its calculation. It returns a
+number for one destination and a tuple, ordered as the destinations, for several.
+
+### Layered earth
+
+The source and target layers of an earth expression select its method. Layer 1 is air,
+and layer 2 is the first earth layer. Methods written for `Val{1}` and `Val{2}` declare a
+formula for air and one homogeneous earth. On an earth model with more layers, such a
+formula consumes its explicit `equivalent_earth` reduction, or the `:default` reduction
+without one. A layer left as a generic `Val{S}` declares the expression for every layer:
+
+```julia
+function earth_impedance(formula::MyLayeredImpedance, ::Val{:self}, ::Val{S}, ::Val{S},
+ functor, workspace) where {S}
+ # functor.input.thickness holds the thicknesses of the layered earth.
+end
+```
+
+A formula that admits a layer above 2 consumes the whole layered earth and its interfaces.
+Before the frequency loop, the plan checks that the formula has an expression for every
+conductor pair on the earth it consumes. The `ArgumentError` names the number of earth
+layers and the highest layer that the formula admits.
+
+`parameters` are model data and `options` are formulation-owned physical choices
+and numerical controls. Custom constructors validate and normalize their own `FormulationOptions`.
+Execution controls use `ComputationOptions`. Completed supplemental output uses
+`ComputationDetails`. Read their payloads explicitly through `.data`.
+Indexed families declare numerical defaults for the actual selected type and
+case with `formulation_options(::Expression{<:MyType,typeof(operation),...})`.
+Earth field equations consume evaluated material properties and define their
+own wave numbers and field approximations. Material-law families implement
+`constitutive` with a valid material argument.
+Unified's formulation options accept a prescribed longitudinal coefficient Γ
+as a scalar or a frequency-aligned vector. Its precision participates in
+allocation of the computation's numerical storage. The positive-time convention
+and explicit convention conversions are documented with the Unified equations.
+
+Coaxial equations and material laws receive the defining computation workspace,
+or `nothing` for a standalone evaluation that does not require it. Numerical arrays are in
+`workspace.buffers`. The workspace input and plan remain the authority for geometry and
+material mappings.
+
+The workspace provides the buffers, and formulas use them. `initialize_buffers` is the
+one action that builds buffers. Each equation builds its own in its method, usually as a
+named tuple of preallocated arrays, without a storage type of its own. An allocation
+method is unnecessary for an algebraic equation. For an equation that needs buffers, extend
+`Commons.initialize_buffers(selected, T, input, plan, buffers)` to return
+the record extended with owned arrays only. Only selections reached by the
+required indexed calls participate in initialization, before evaluating materials
+or equations. Unused recipe branches remain unallocated.
+The default uses only existing storage. Existing arrays may not be replaced.
+Numerical formulas provision the common quadrature storage through
+`Commons.initialize_buffers(SpectralIntegral, Val(:quad), T, input, plan, buffers)`. An
+integration option is not a capability declaration. Cable constants uses this
+same buffer-initialization method with its local numerical input and an empty plan.
+
+Each formula defines its complete integrand, transformations, Jacobians, branch
+choices and physical subdivision hints. `SpectralIntegral` selects a Sommerfeld-type
+integral over the spatial Fourier variable and contains only that callable.
+`integrate(integral, Val(:quad), controls, buffers)` passes it and the supplied numeric
+subdivision points to QuadGK. Physical expressions and subdivision choices belong to each formula.
+
+`ShuntModel` owns conductor and dielectric geometry extraction, numerical coefficients and the requested fallback
+model. Its `blueprint_dependencies` methods identify the actual local selections
+that affect those coefficients. Engine uses that dependency record for reuse
+within one blueprint construction. Completed coefficient blocks are retained
+after construction. The charge-system factorization and workspace are reused
+only during construction.
+
+Conductor pairs with the same inputs share one computed value when their media agree.
+By default the pairs' destination indices take part, so distinct pairs are computed
+separately. A formula whose arithmetic does not read the indices declares the inputs it
+reads with `same_physical_state(formula, a::EarthPair, b::EarthPair, geometry)`. A formula
+that computes the whole system at once, such as Unified, adds an `Engine.EarthPlan`
+constructor method on its own type and an `Engine.earth!(formula, functor, workspace)`
+method that converts the coefficients of its parts into the physical matrices. Each
+selection uses the Engine frequency sequence and its computation workspace.
+
+An internal-impedance formula builds the values that its inner, outer and transfer impedances
+share in its `Functor` method, once per conductor and frequency. Shunt geometry construction runs once before the
+frequency loop and returns frequency-independent blueprint blocks and diagnostics. A
+consuming earth formula explicitly admits a custom equivalent-earth rule using `validate`.
+
+Results use result-type and unit checks for each formula family, including custom types.
+Impedances are in Ω/m, material admittivities in S/m, earth potential coefficients
+in m/F, and temperature-law resistivities in Ω·m. Scalar material laws return
+`EarthMaterial` or a finite scalar as appropriate. Modal equations write
+`workspace.Tv`, `workspace.Ti`, and `workspace.roots`. The owner constructs
+`ModalOperators` and intrinsic coefficients after decomposition. Supply
+`formula_id`, `description`, `NamedTuple` and
+`formulation_options` methods for metadata. Serialized identities and data do
+not reconstruct executable methods. See the [temperature-law example](engine.md#Cable-material-temperature-dependence).
+
+### A custom modal formula
+
+The following complete one-mode example implements a custom modal formula. Its
+methods take the formula object first, `Formula{:diagonal_example}`. The modal workspace supplies common slices, coordinate conversion buffers, the
+admittance-impedance product, eigenpair history and voltage-vector buffers.
+`initialize_buffers` extends that record only for additional numerical work.
+It assumes a completed one-mode phase scan named `phase`, with nonzero
+diagonal coefficients and known source length. The example is algebraic. It
+does not replace a broadband modal model.
+
+```julia
+using LineCableModels
+import LineCableModels.Engine: description
+import LineCableModels.Commons: formulation_options, FormulationOptions, initialize_buffers
+import LineCableModels.ModalAnalysis: decompose!, Formula
+import LineCableModels: Expression
+
+description(::Type{<:Formula{:diagonal_example}}; compact=false) =
+ compact ? "diagonal example" : "one-mode diagonal example"
+formulation_options(::Expression{<:Formula{:diagonal_example},typeof(decompose!)}) =
+ FormulationOptions()
+
+function initialize_buffers(::Formula{:diagonal_example}, ::Type{T}, input,
+ plan, common) where {T<:Complex}
+ plan.n == 1 || throw(DimensionMismatch("diagonal example requires one mode"))
+ return merge(common, (diagonal_product=Vector{T}(undef,plan.nf),))
+end
+
+function decompose!(::Formula{:diagonal_example}, workspace,
+ parameters::NamedTuple, options::FormulationOptions)
+ product=workspace.buffers.diagonal_product
+ for k in eachindex(product)
+ z=workspace.input.Z[1,1,k]/workspace.input.root_scale
+ y=workspace.input.Y[1,1,k]/workspace.input.root_scale
+ product[k]=z*y
+ root=sqrt(product[k])
+ (real(root)<0 || (iszero(real(root)) && imag(root)<0)) && (root=-root)
+ workspace.roots[1,k]=root*workspace.input.root_scale
+ workspace.Tv[1,1,k]=one(root)
+ workspace.Ti[1,1,k]=one(root)
+ workspace.diagnostics.eigen_residual[1,k]=zero(real(root))
+ workspace.diagnostics.iterations[1,k]=0
+ workspace.diagnostics.converged[1,k]=true
+ end
+ return workspace
+end
+
+selected=ModalAnalysisFormulation(Formula(:diagonal_example))
+modal=compute(ModalAnalysisProblem(phase),selected)
+segment=PropagationParameters(modal)
+size(gamma(modal)) == (1,length(frequencies(phase)))
+size(H(segment)) == size(gamma(modal))
+```
+
+Only the selected formula's `initialize_buffers` method runs. The common
+workspace supplies per-frequency normalization and coordinate buffers. The
+formula owns `diagonal_product`. It writes phase-row by mode-column bases
+and mode-by-frequency roots. The owner checks structural shape and finite arithmetic before computing intrinsic coefficients. It then copies the returned arrays and records diagnostics. Numerical targets are reported as warnings and facts,
+without becoming result-admission rules.
+
+## Input validation
+
+A materialized input defines one direct `validate(::OwnedType)` method. The method
+returns its argument unchanged or throws a native Julia exception identifying
+the rejected field and value:
+
+```julia
+import LineCableModels: validate
+
+struct Annulus{T <: Real}
+ r_in::T
+ r_ex::T
+
+ function Annulus{T}(r_in::T, r_ex::T) where {T <: Real}
+ return validate(new{T}(r_in, r_ex))
+ end
+end
+
+function validate(value::Annulus)
+ value.r_in >= zero(value.r_in) || throw(DomainError(
+ value.r_in,
+ "Annulus.r_in must be nonnegative"
+ ))
+ value.r_ex > value.r_in || throw(DomainError(
+ value.r_ex,
+ "Annulus.r_ex must be greater than r_in"
+ ))
+ return value
+end
+```
+
+Constructors normalize their admitted grammar. The owning `validate` method
+checks the completed value directly and returns it unchanged. [`validate`](@ref)
+documents the rules that every method follows.
+
+## Commons, observations, and units
+
+```@autodocs
+Modules = [
+ LineCableModels.Commons,
+ LineCableModels.Units,
+]
+Order = [:module, :constant, :type, :function, :macro]
+Filter = developer_reference_entry
+Public = true
+Private = false
+```
+
+## Data model and computations
+
+```@autodocs
+Modules = [
+ LineCableModels.DataModel,
+ LineCableModels.Engine,
+ LineCableModels.ParametricBuilder,
+ LineCableModels.ImportExport,
+]
+Order = [:module, :constant, :type, :function, :macro]
+Filter = developer_reference_entry
+Public = true
+Private = false
+```
+
+## Report definitions
+
+```@autodocs
+Modules = [LineCableModels.ReportBuilder]
+Order = [:module, :constant, :type, :function, :macro]
+Filter = developer_reference_entry
+Public = true
+Private = false
+```
+
+## Plotting functions
+
+```@docs
+LineCableModels.PlotBuilder
+```
+
+## Index
+
+```@index
+Pages = ["extensions.md"]
+Order = [:constant, :type, :function, :macro]
+```
diff --git a/docs/src/fem.md b/docs/src/fem.md
new file mode 100644
index 000000000..199421b98
--- /dev/null
+++ b/docs/src/fem.md
@@ -0,0 +1,459 @@
+# Gmsh/GetDP finite-element backend
+
+[`LineCableModelsFEM`](@ref) is the Julia-native finite-element
+backend for `LineParametersProblem`. Gmsh is a weak dependency: the public
+formulation and option normalizers are always available, while the `compute` method
+is activated by loading Gmsh.
+
+```julia
+using LineCableModels
+using Gmsh
+
+fem = Formulation(
+ :fem;
+ insulation_admittance = formula(:default),
+ semicon_admittance = formula(:default),
+ earth_properties = formula(:default),
+ temperature_dependence = formula(:default),
+ options = (
+ physics = :quasi_tem,
+ reduce_bundle = true,
+ kron_reduction = true,
+ ideal_transposition = false,
+ ),
+)
+
+parameters = compute(problem, fem;
+ options = (
+ mesh_mode = :reuse,
+ gmsh_verbosity = 2,
+ getdp_verbosity = 2,
+ frequency_workers = 2,
+ solver_threads = 1,
+ ),
+)
+```
+
+All execution controls pass through `compute(...; options=(...))` and are
+validated by `computation_options(LineCableModelsFEM, ...)`. This includes
+meshing, workers, native verbosity, Julia milestones, logs, and resume:
+
+```julia
+parameters = compute(
+ problem,
+ fem;
+ options = (
+ verbosity = (default = 1,),
+ log_file = "fem-julia.log",
+ ),
+)
+```
+
+GetDP remains an external process, and the bundled executable runs on
+the supported artifact platforms. The first FEM computation there downloads
+the package's lazy, hash-verified GetDP 3.5.0 complex-PETSc artifact. Loading
+LineCableModels or Gmsh alone does not download it. No Python runtime or
+`GetDP.jl` problem generator is used.
+
+The adapter writes one run-local `input/model_data.pro` containing resolved
+region tags, terminal names, material coefficients and domain dimensions.
+Maintained GetDP files own material-domain bindings, the equations and the
+parameterized field-map operation. Material conductivity is the effective
+real part of the selected complex admittivity: dielectric losses are already
+included, so GetDP does not add another loss-tangent contribution.
+
+Each solver job selects its material coefficients by frequency index. When
+`plot_field_maps=true`, basis-specific output operations write the nine common field
+quantities with frequency and source-specific filenames and labels. The maintained GetDP
+files are captured with each run so later edits cannot change an active scan.
+
+Pass execution controls through `compute(...; options=(...))` and select
+`physics` through formulation options. Benchmark calls use `reference_options`
+for FEM execution controls. Supplemental run metadata is a named tuple under
+`details(result).data.fem.run`.
+
+## Physics selection
+
+Set formulation `options=(physics=:quasi_tem,)` for the default independent magnetic and
+electric blocks, or `(physics=:quasi_fw,)` for the coupled first-order Maxwell
+model. Strings `"quasi-tem"` and `"quasi-fw"` and their `Symbol` values are also
+accepted. Julia requires `Symbol("quasi-fw")` for a hyphenated symbol.
+`:quasi-fw` is parsed as subtraction.
+
+```julia
+fem = Formulation(:fem; options=(physics=:quasi_fw,))
+parameters = compute(problem, fem)
+```
+
+`model.pro` exposes the ONELAB number `LineCableModels/FEM/physics`, with
+choices `0 = quasi-tem` and `1 = quasi-fw`. It includes `quasi-tem.pro` or
+`quasi-full.pro` to match that selection. Headless jobs set the same constant with
+`-setnumber Physics 0` or `-setnumber Physics 1`. Both use the resolution
+`LineCableModelsFEMScan`. The Julia-managed GUI displays this choice read-only,
+since the numerical inputs of a run are fixed before meshing. Choose physics
+in formulation `options` for a new computation. Physics is saved with the formulation
+inputs and column checkpoints. A run cannot resume under different physics.
+
+The quasi-full option retains ``A_t/\Gamma`` and ``\phi/\Gamma`` in a first-order
+``\Gamma\to0`` reduction of the vector-potential and continuity equations.
+One axial-current excitation supplies both responses. Its voltage extraction
+includes ``j\omega\int(A_t/\Gamma)\cdot d\ell`` along a physical vertical path
+from earth infinity to each terminal. The backend constructs these paths from
+the first-order triangular mesh, choosing the lowest node of each terminal
+contour (lowest x breaks ties). Transverse fields are zero inside equipotential
+metal, so paths can cross shields or other terminals. The infinite-shell part
+uses the pulled-back edge field and 128 straight segments. Integration within
+each crossed triangle is exact for its lowest-order edge element. This is a
+voltage-path convention without assuming path independence in an inductive field.
+
+The full equations, units, boundary conditions, gauge and extraction are
+documented in [`LineCableModelsFEM`](@ref), with the potential-equation reference
+of [Ciuprina2024](@cite). This backend's 2D longitudinal reduction is distinct
+from that paper's 3D ECE implementation. It retains displacement and does not
+solve a finite-``\Gamma`` eigenproblem. Both public physics options retain the
+specified finite metal conductivity in the axial problem.
+
+Quasi-full also retains the scalar-only `Pscalar.tsv` per column,
+which is gauge dependent and must not be inverted for Y. With field maps
+enabled it also writes `bt_mesh`, `v_local` and `hz_scaled`, for twelve maps
+per excitation. Its `e`, `em` and `jm` maps represent ``E_t/\Gamma`` [V], its
+magnitude [V], and ``|J_t/\Gamma|`` [A/m], respectively.
+
+## Material laws
+
+FEM selects insulation and semicon admittivity, soil frequency dependence, and
+cable-material temperature dependence. Analytical `internal_impedance`,
+`insulation_impedance`, `earth_impedance`, `earth_admittance`, and
+`pipe_impedance` keywords are rejected, including explicit `:default` values.
+Supported enclosing geometry is represented directly in the field domain.
+
+`temperature_dependence=formula(:default)` evaluates each cable material's
+resistivity as ``\rho(T)=\rho_0[1+\alpha(T-T_0)]``. `T` comes from
+`problem.temperature`. Reference resistivity, `T0`, and `alpha` come from the
+material. Select `nothing` to retain reference resistivity. This law is shared
+with analytical computations, cable constants, and PSCAD export.
+
+The default law requires a positive finite correction factor and
+``|T-T_0|<150`` K. These are limits of this approximation, independent of thermal
+rating. Custom laws own their applicability and use the usual contribution-hook
+signature. FEM author registration is unnecessary. Passive materials can retain
+infinite resistivity. Dielectric constituents are evaluated before radial
+aggregation, and polarization loss is not corrected a second time.
+
+`earth_properties` calls the same soil constitutive law as the analytical engine
+at each frequency. Evaluated resistivity, permittivity, and permeability feed
+GetDP's coefficients for the soil and transformed exterior, as well as the skin-depth mesh rule.
+`:default` and `nothing` retain static soil. Air uses its explicitly declared
+static permittivity and permeability, independently of the soil law. The current
+FEM geometry requires one horizontal semi-infinite soil. A non-finite conductive
+skin depth is unsupported. Equivalent homogeneous-earth reductions are rejected.
+An ordinary `EarthModel` supplied after an external reduction includes no history
+from which FEM could detect that prior approximation.
+
+Saved FEM formulation details contain the four consumed `selections`, their
+parameters and numerical options, alongside requested and resolved records.
+Custom selections retain their identities and data. Saved records never
+reconstruct executable methods.
+The selected propagation approximation remains recorded separately.
+
+## Field equations and matrix extraction
+
+The default `:quasi_tem` physics evaluates the series and shunt problems at ``\Gamma=0``. It retains
+diffusion and displacement in the surrounding media, with phasors proportional
+to ``e^{j\omega t}`` and complex admittivity ``\kappa=\sigma+j\omega\epsilon``.
+2 independent blocks share one assembled GetDP system and factorization.
+
+The magnetic block solves for the axial vector potential ``A_z`` and one axial
+electric unknown ``u_i`` per terminal. In each material it solves
+
+```math
+-\nabla\cdot(\mu^{-1}\nabla A_z)+\kappa(j\omega A_z+u_i)=0,
+\qquad
+I_i=-\int_{\Omega_i}\kappa(j\omega A_z+u_i)\,dS.
+```
+
+Here ``u_i`` is supported on its conductor region. Exciting terminal ``s`` with
+1 A and setting the axial current on the others to zero gives ``Z_{is}=-u_i/I_s``.
+Metal conductivity and its internal field remain part of this series problem.
+
+The electric block solves the scalar electrodynamic problem in air, soil and
+passive cable materials, excluding conductor interiors:
+
+```math
+\nabla\cdot(\kappa\nabla v)+\kappa k^2 v=0,
+\qquad k^2=-j\omega\mu\kappa.
+```
+
+Each terminal has one equipotential degree of freedom ``V_i``. Prescribing
+1 A/m of outward transverse terminal current at terminal ``s``, and zero at the
+others, gives the column ``P_{is}=V_i/(1\ \mathrm{A/m})``. GetDP's associated
+quantity has the opposite sign, so this drive is imposed as ``Q_s=-1``.
+Here ``Q`` is a current per unit length. Its units differ from electrostatic charge.
+``P`` has units Ω m and ``Y=P^{-1}`` has units S/m.
+
+Equivalently, prescribe a unit voltage on one source terminal and ground the others. Repeat for each source terminal to extract ``Y_{is}=-Q_i/(1\ \mathrm{V})``.
+For any other set of voltage excitations, terminal currents satisfy ``J=YV``. Extracting
+``Y`` requires the complete voltage matrix, not just a source-voltage rescaling.
+
+The magnetic and electric blocks are separate unit excitations. Electric
+potentials are obtained directly from the scalar operator, without dividing a
+magnetically driven potential by a small ``\Gamma``. This also keeps the shunt
+calculation independent of metal-interior discretization. Bare conductors have
+no passive coating in their electric domain. Finite-conductivity metal remains
+in their magnetic domain.
+
+The earlier coupled `A_z/u/phi` model retained only the axial vector potential
+and used continuity to recover `Phi/Gamma`. At material interfaces that
+reduction omits a transverse Ampère balance at the same order in Γ as the
+electric response being extracted. With
+``\mathbf r=\kappa\nabla_t(\phi/\Gamma)-\mu^{-1}\nabla_t A_z``, continuity
+enforces ``\nabla_t\cdot\mathbf r=0``, whereas transverse Ampère requires
+``\mathbf r=0``. A material interface makes those conditions inequivalent.
+Reducing Γ does not remove the error after normalization by Γ.
+
+## Execution model
+
+One call to `compute` builds one complete two-dimensional Gmsh mesh for each
+distinct physical frequency. The finite air-earth radius is
+``R=\max(R_{\mathrm{layout}},m\delta)`` with the skin depth of conductive soil
+``\delta=\sqrt{\rho/(\pi f\mu)}``. The computation option
+`domain_skin_depths=2.0` sets ``m``. The transformed exterior occupies the annulus
+from ``R`` to ``1.25R``. To compare this domain with a smaller one, use
+`compute(problem, fem; options=(domain_skin_depths=1.5,))`.
+The 1.5–2 range is an empirical operating range for the basic buried-wire checks. Accuracy outside these checks requires separate evaluation. Radius changes preserve the local mesh-size
+targets and enter mesh-cache and resume identity. The final, highest-frequency mesh is the
+displayed mesh. Each frequency-specific mesh is reused by all of that
+frequency's terminal excitations. Cable topology is constructed once: successive
+meshes retain its vertices and geometric faces while updating the exterior circles and
+mesh-size fields. Only one native geometry and mesh is retained in memory. After
+generating all meshes, Julia launches
+up to `frequency_workers` standalone GetDP processes concurrently. Each process
+handles one frequency: it assembles and factors the system for its first
+requested terminal, then updates the right-hand side and reuses those factors
+for the remaining terminals. Frequencies with different meshes have separate
+systems and factorizations. Local mesh sizes also resolve the attenuation and
+phase scales of the evaluated air and soil properties.
+
+The default is two frequency workers with one BLAS/OpenMP thread per solver.
+These are independent OS processes. They do not require multiple Julia threads.
+Set `frequency_workers=1` for serial frequency execution. Increase the worker
+count only within available memory: every active frequency requires its own sparse
+factorization. `solver_threads` sets each child's BLAS/OpenMP environment without
+changing Julia's environment. GetDP must support `GenerateRHSGroup` and
+`SolveAgain`. The package-owned artifact is GetDP 3.5.0 with PETSc complex
+arithmetic.
+
+Each attempt has a separate working directory, solver prefix, raw columns,
+maps and log. Inputs are explicit command arguments and data files. The solvers run independently of ONELAB. One Julia coordinator manages Gmsh and publishes UI progress. It validates columns before writing checksummed checkpoints. Backend calls in the same
+Julia process serialize access to the shared Gmsh session. A filesystem lock
+prevents two coordinators from writing the same run.
+
+Once every column is validated, Julia assembles `raw/Z.tsv` and `raw/P.tsv` in
+frequency and terminal order and publishes the scan completion marker. Validation
+checks headers, row counts, identities, frequencies and finite values.
+
+The primitive matrices have dimensions
+`(nterminal, nterminal, nfrequency)`. The extracted coefficient is
+``P_{\mathrm{FEM}} = Y^{-1}`` in Ω·m. The backend passes the charge-based
+coefficient ``p = j\omega P_{\mathrm{FEM}}`` in m/F to the reduction shared with the
+analytical engine. It applies terminal ordering, bundle merging and Kron reduction
+to ``Z`` and ``p``. With `ideal_transposition=true` (the default is `false`), it
+averages the retained ``Z`` and ``p`` over cyclic transposition. It then obtains
+``Y = j\omega p^{-1}`` by a condition-checked direct solve, with the residual and
+condition number of each frequency in the result details.
+
+The returned value is the package-native `LineParameters` in `PhaseDomain`,
+with ``Z`` in Ω/m, ``Y`` in S/m, and the exact input frequency vector in Hz.
+Pass `options=(trace=true,)` to `compute` to retain primitive ``Z/P`` in result
+details. `output_basis=:total` continues to use the shared computation option
+and scales by line length.
+
+## Schema authority and reconciliation
+
+The existing LineCableModels typed objects are the only physical-model
+authority. The extension creates only derived tags, surface ownership, mesh
+sizes, and GetDP tables. It defines no second cable, material, or project
+schema.
+
+Each FEM computation starts with a numeric preflight before model adaptation,
+runtime-directory creation, Gmsh initialization, or meshing. The preflight
+rebuilds continuous problem data as `Float64`. When a
+`Measurements.Measurement` scalar is present, only its nominal value is
+retained. Discrete topology such as terminal assignments, material tags, and
+pattern counts remains integral. The caller-owned problem is not mutated. Evaluated material-law outputs pass the
+same checked conversion to nominal `Float64` before transport. Finite overflow is
+rejected.
+
+| FEM datum | Authoritative LineCableModels property | Handling |
+|---|---|---|
+| Material class and electrical properties | each resolved `PlacedRegion.source.material`: `kind`, `rho`, `eps_r`, `mu_r`, `tan_delta`, `T0`, `alpha` | Reused. Resistivity follows the selected shared temperature law and constant intrinsic loss tangent contributes ``\omega\epsilon\tan\delta`` to conductivity |
+| Material geometry and topology | `CableDesign.geometry.regions`, each resolved `PlacedRegion.primitive`, and `CableDesign.geometry.outer` | Requires an area-complete material partition, then adapts it to built-in `gmsh.model.geo` loops and cut-hole surfaces |
+| Cable identity and placement | `LineCableSystem.designs`, `CableDesign.cable_id`, `LineCableSystem.positions`, and resolved `LineCableSystem.geometry` | Reused in declared order. Stable IDs form physical names |
+| Terminal ownership and order | `LineCableSystem.terminal_order`, `terminal_map`, and `connection_order` | Reused exactly. Disconnected surfaces of one electrical Group share one terminal physical group |
+| Phase, bundle, and grounded-conductor reduction | `LineCableSystem.connection_order` plus shared formulation options | Delegated to the Engine reduction implementation for both ``Z`` and ``P`` |
+| Frequencies | `LineParametersProblem.frequencies` | Published as indexed, hidden, read-only ONELAB numbers. Each isolated GetDP job also receives its exact physical frequency and matching transformation radii directly |
+| Temperature | `LineParametersProblem.temperature` | Prescribed input to the selected temperature law. The default uses material `T0` and `alpha` |
+| Earth material | `LineParametersProblem.earth_props` | Declared air plus one horizontal soil half-space. The soil law is evaluated per frequency |
+| Optional environment declaration | `LineCableSystem.environment` | `nothing` and `EarthModel` are accepted. Other declarations produce a typed unsupported-feature error |
+| Line length and output basis | `LineCableSystem.line_length` and shared `compute` options | Per-unit-length is the default. Total basis scales Z and Y by line length |
+| Propagation constant | backend-owned ``\Gamma\to0`` limit, with independent or first-order coupled fields selected by formulation `options.physics` | Selected by the formulation. The FEM problem supplies geometry, materials, and frequencies |
+| Mesh resolution | local characteristic lengths derived from each resolved solid, tube, strand, foil, and passive region. Per-frequency earth skin depth controls the exterior domain, and air-soil propagation scales constrain surrounding-medium resolution | Thin internal features remain local and cannot refine unrelated layers or the earth domain |
+
+Disks, ellipses, and cable sectors retain exact Gmsh circle or ellipse arcs.
+Rectangles and schema polygons retain exact line segments. Annuli, conformal
+sector shells, enclosure differences, and
+assembly geometric boundaries use shared oriented loops. Circular geometric boundaries are
+pre-segmented at sector endpoints and circle contacts, so adjacent materials
+reuse the same curve and tangent strands reuse the same point. Compacted strand
+polygons are used unchanged. Touching hole boundaries are partitioned into
+connected filler faces. Metal-metal seams are excluded from filler boundaries.
+Annular wire-ring compartments use that same face tracing: a wire-wire point
+contact separates the inner and outer filler lobes into distinct CAD faces,
+without adding clearance or changing any material boundary. Separated wires
+retain their connecting filler gap. All filler faces retain their declared
+material. Equal evaluated material laws
+share a physical material group, independently of geometric strand identity and
+electrical terminal groups. A shared
+material interface takes the smaller of its two local characteristic lengths.
+Thin internal foils and wire strands do not export their size to the cable-earth
+boundary. One `Distance`/`Threshold` field per actual cable exterior grows
+from that exterior layer's size to ``R_{\mathrm{resolution}}/20``, where
+``R_{\mathrm{resolution}}=\max(R_{\mathrm{layout}},\delta)``, using an adjacent-element
+growth factor of 1.2. This resolution scale is independent of `domain_skin_depths`.
+The transformed shell's maximum size is twice that value.
+Additional fields restrict the surrounding-medium size to
+``h\leq 1/(8|q|)``, where ``q=\sqrt{j\omega\mu\kappa}``, within six attenuation
+lengths of cable exteriors and the air-soil interface, capped at
+``2R_{\mathrm{resolution}}``. The bound resolves both decay and phase.
+It transitions back to the surrounding-medium size beyond that distance.
+Gmsh `Restrict` fields apply each bound to its own air or soil surfaces, and
+`Min` combines overlapping fields. Refinement follows these fields without artificial rings. The adapter
+rejects an incomplete area partition before starting Gmsh and rejects any
+internal material curve without exactly two adjacent geometric faces after
+synchronization. After meshing, boundary-edge incidence and material coverage
+are checked before a mesh can be cached or passed to GetDP. Nonempty but partial
+material meshes are rejected.
+
+Rectangular stranded cores supply their occupied disk boundary directly from
+physical resolution. FEM uses the same geometric boundary as preview, analytical
+flattening and subsequent layers. Complete bounded formations are recognized
+using the same floating-point area tolerance as enclosure resolution, not an
+independent engineering fill-fraction cutoff. Retained filler is not replaced
+by an expanded conductor.
+
+The current FEM domain explicitly rejects vertical earth layers, more than one
+earth half-space layer, a problem-supplied propagation constant, unsupported
+environment types, incomplete material partitions, and any resolved primitive
+without a two-dimensional built-in-`geo` geometric boundary adaptation. These failures use
+`LineCableModelsFEMError`, including the defining object ID and offending field,
+before Gmsh is touched where possible.
+
+## Mesh lifecycle and diagnostics
+
+`mesh_mode=:reuse` first validates an explicit highest-frequency `mesh_path`,
+then checks the fingerprinted repository-local cache for each frequency, and
+otherwise generates the missing frequency-specific mesh.
+Compatibility checks cover mesh dimension, terminal count, material and
+terminal physical groups, physical names, complete boundary incidence, material
+areas (with curved-boundary discretization allowances), and conductor ownership.
+Owned MSH 4.1 files retain all boundary elements, including same-material seams.
+Explicit mesh files must retain these elements too. `mesh_mode=:remesh` always
+regenerates and atomically refreshes the matching cache. The fingerprint
+includes the serialized problem, stable physical metadata, every local and
+exterior mesh size, the physical mesh frequency, transformation radii, growth
+law, and Gmsh version.
+
+Runs live under `.linecablemodels/fem/runs/`. Cached meshes live under
+`.linecablemodels/fem/meshes/`. A successful run directory is deleted after the
+result is constructed unless `keep_run_directory=true`. Failed or incomplete
+runs are retained, and their typed error reports the path. Retained runs contain
+the problem snapshot, immutable GetDP data, mesh snapshot and metadata, raw
+tables, maps, logger output, and atomic `run.json` state transitions. Numerical
+process logs and attempt metadata live in `attempts/fNNNN-*/`. Per-column timing
+records separate constraint updates, assembly, solve and output. At
+`getdp_verbosity>=4`, each attempt also retains PETSc profiling output.
+
+Field maps are off by default. With `plot_field_maps=true`, nine supplied
+quantities are written for every frequency and source pair, with names such as
+`bm_f0002_b0003.pos`. Every expected file must exist before the scan succeeds.
+They remain separate during headless execution. UI execution merges them only after the
+complete numerical scan validates. Map paths are retained in result details
+only when the run directory is retained.
+
+Maps `az`, `b`, `bm`, `ez`, `jz`, and `rhoj2` describe the axial 1 A drive.
+Maps `e`, `em` and `jm` describe the transverse 1 A/m drive: respectively
+``-\nabla v``, its magnitude and ``|\kappa\nabla v|`` in the surrounding media.
+Their view labels identify the drive. These axial and transverse fields belong
+to different excitations and do not form one full-wave field vector.
+
+The executable resolution order is:
+
+1. `compute(...; options=(getdp_executable="/absolute/path/to/getdp",))`.
+2. the `LINECABLEMODELS_GETDP` environment variable.
+3. the package's GetDP 3.5.0 lazy artifact.
+4. `getdp` on `PATH` only when the current platform has no artifact binding.
+
+The artifact currently supports glibc Linux and macOS on x86-64. GetDP 3.5.0
+publishes its Windows build only as a ZIP, which Julia's artifact installer
+cannot consume directly. Windows uses an installed GetDP selected
+explicitly, through the environment variable, or on `PATH`. The same external
+selection applies on every other unsupported platform. An explicitly selected
+or environment-selected invalid path is an error. It is never silently
+replaced by another solver. The backend records the resolved source and path
+for computation records, while
+resume compatibility uses the executable SHA-256 and reported build identity
+instead of its filesystem location. See
+[`THIRD_PARTY_NOTICES.md`](https://github.com/Electa-Git/LineCableModels.jl/blob/main/THIRD_PARTY_NOTICES.md)
+for GetDP's GPL notice and upstream source location.
+
+A nonzero client failure is reported as a typed
+error with its frequency, missing basis indices, retained attempt directory
+and GetDP log tail. Scheduling stops on failure. The coordinator terminates and reaps the other
+active workers. Completed columns remain available for recovery. A zero exit
+code is insufficient without valid completion records and numerical output.
+Result details distinguish actual process launches (`getdp_invocations`),
+`completed_columns`, and `completed_frequencies`. A fresh complete scan normally
+launches one process per frequency. Retries add invocations.
+
+Resume an interrupted compatible run with
+`options=(resume_run_directory="/path/to/run",)` (or `:latest`). Recovery checks mesh identities and column checksums before adopting complete attempt outputs. It requests only missing or invalid terminal columns. The first requested column
+always builds fresh factors, even when its terminal index is not one. Worker
+count may change during recovery. Solver thread settings, physical inputs and
+source and executable identities must match. A surviving solver from an interrupted
+coordinator prevents retry until it exits. Completed runs are reused read-only
+after their aggregate checksums pass. Runs from older solver protocols remain
+preserved comparison artifacts and require a fresh computation. Indexed soil and
+declared-air coefficients use run-input schema 7 and solver protocol 3. Older
+schemas cannot resume. Evaluated cable, soil, and air coefficients participate
+in solve reuse identity. Numerically identical laws can share a solve while
+retaining separate selection computation records and independent result arrays.
+
+## Optional Gmsh UI
+
+The UI displays the model, fields, and solver diagnostics:
+
+```julia
+interactive_fem = Formulation(:fem)
+parameters = compute(problem, interactive_fem;
+ options = (
+ ui = true,
+ plot_field_maps = true,
+ ),
+)
+```
+
+It publishes read-only problem summaries, separate mesh and solve states, and
+status text, completed frequency and column counts, and `Generate mesh` and
+`Run model` buttons. `Run model` refuses
+to proceed before a valid mesh exists.
+Closing the window before solving raises a typed `:not_executed` error that
+distinguishes window closure before mesh generation from window closure after meshing.
+The event loop remains active while solver processes run. Closing the window
+during solving cancels those processes and retains completed checkpoints. After
+a successful scan, validated maps remain visible until the user closes the UI.
+
+The extension finalizes only Gmsh sessions it owns. A caller-owned initialized
+session retains its current model, unrelated models and views, Gmsh verbosity
+options, and pre-existing `LineCableModels/FEM/` ONELAB parameters.
+
+The backend has no Python dependency.
diff --git a/docs/src/gridspace.md b/docs/src/gridspace.md
new file mode 100644
index 000000000..bf4cd7d79
--- /dev/null
+++ b/docs/src/gridspace.md
@@ -0,0 +1,619 @@
+# Gridspace
+
+Gridspace combines finite sets of inputs and constructs one object for each
+selected combination. The cable API uses it to construct designs, systems,
+and problems with varying parameters.
+
+The calculation sequence is:
+
+```text
+Grid declares explicit finite variation
+Gridspace composes finite sources and selects one point
+callable invokes the existing complete construction action
+Engine.compute evaluates one complete core problem
+ParametricBuilder/UQ collect stored computation data
+```
+
+`DataModel` constructors validate physical invariants. Engine calculates
+core results. UQ performs repeated stochastic realization and aggregation.
+
+## Core invariants
+
+A Gridspace has this structure:
+
+```julia
+Gridspace{Target}(build, grids::Tuple; combine=:product)
+Gridspace{Target}(grids::Tuple; combine=:product)
+```
+
+Every member of `grids` must already be a `Grid` or nested `Gridspace`. Wrap
+finite alternatives in `Grid` before passing them to this constructor.
+`Target` is the result family used for dispatch and
+`build` is the callable that constructs it. A nonempty deterministic space
+advertises a concrete iterator element type when Julia can prove that type
+without evaluating a point. Otherwise-including uncertainty-bearing and empty
+spaces-its iterator uses `Base.EltypeUnknown`. `combine` is normalized into the concrete Gridspace
+type. The space is lazy and has an analytic length. `rand(space)` selects and
+realizes one point without collecting the space.
+
+## Grid is the variation marker
+
+`Grid` is the only public marker for finite variation:
+
+```julia
+Grid((1.0, 2.0, 3.0))
+Grid(1.0) # one deterministic point
+Grid((1.0, 2.0), (1.0, 5.0)) # nominal × relative error [%]
+Grid((1.0, 2.0), AbsoluteError(0.1)) # nominal × absolute error
+```
+
+The uncertainty-bearing forms yield `UncertainValue(nominal, sigma)`
+descriptors defined by the core package. A descriptor becomes a Measurements
+value during direct propagation or an ordinary scalar during Monte Carlo realization.
+
+The constructor laws are:
+
+```text
+Grid(existing Grid) = the existing Grid
+Grid(existing Gridspace) = the existing Gridspace
+Grid(tuple or array) = its elements are alternatives
+Grid(other value) = one alternative
+```
+
+Grid instances include no selection identity. Reusing an instance in two source
+positions has the same behavior as placing two equal, separately constructed
+Grids in those positions.
+
+## Collections are atomic until explicitly varied
+
+Public domain builders know which of their inputs are complete domain values.
+An ordinary tuple, vector, or matrix remains atomic:
+
+```julia
+frequencies = [50.0, 100.0, 1000.0] # one frequency scan
+payload = [1.0 2.0; 3.0 4.0] # one matrix
+
+frequency_sets = Grid((
+ [50.0, 100.0],
+ [50.0, 500.0, 5_000.0],
+)) # two complete scans
+```
+
+When one sibling varies, the builder wraps every ordinary sibling in a
+one-point source.
+The matrix or frequency vector remains one complete value at every point.
+Calling `Grid(matrix)` is different and explicit: the matrix elements become
+alternatives because the caller requested that variation.
+
+A tuple or vector containing a `Grid` or `Gridspace` is not atomic: the
+explicit finite sources are composed and the collection is rebuilt from their
+selected, completed values. This lets domain collections such as
+`[design_space, design_space]` reach their scalar builder as completed
+`CableDesign` objects. Ordinary collections containing no explicit finite
+source remain one complete value.
+
+## Product and zip composition
+
+Composition is local to each Gridspace node.
+
+### Product
+
+`:product` is the default and forms the lazy Cartesian product. Julia product
+ordering is preserved, with the first source changing fastest:
+
+```julia
+space = Gridspace{Tuple}(
+ tuple,
+ (Grid((1, 2, 3)), Grid((10, 20))),
+)
+
+collect(space)
+# [(1, 10), (2, 10), (3, 10), (1, 20), (2, 20), (3, 20)]
+```
+
+Its length is computed directly as the product of the source lengths.
+
+### Zip
+
+`:zip` pairs non-singleton sources row by row and broadcasts singleton
+sources:
+
+```julia
+space = Gridspace{Tuple}(
+ tuple,
+ (Grid((1, 2, 3)), Grid((10, 20, 30)), Grid(:fixed));
+ combine=:zip,
+)
+
+collect(space)
+# [(1, 10, :fixed), (2, 20, :fixed), (3, 30, :fixed)]
+```
+
+All non-singleton direct sources must have equal cardinality. A mismatch
+throws `DimensionMismatch` when the Gridspace is constructed, before any point
+is materialized. Zip traversal is linear in the number of rows.
+
+### Nesting
+
+A nested Gridspace is one finite source at its parent. Its selected value remains unresolved until recursive materialization or realization reaches it. Nested
+resolution lets one child zip local parameters while its parent forms a
+Cartesian product:
+
+```julia
+paired = Gridspace{Tuple}(
+ tuple,
+ (Grid((1, 2)), Grid((10, 20)));
+ combine=:zip,
+)
+
+outer = Gridspace{Tuple}(tuple, (paired, Grid((:a, :b))))
+collect(outer)
+# [((1, 10), :a), ((2, 20), :a),
+# ((1, 10), :b), ((2, 20), :b)]
+```
+
+## Public constructors
+
+Gridspace delays finite selection. Each lifted public action applies one rule:
+
+```text
+no admitted Grid or Gridspace -> invoke the scalar action now
+at least one explicit source -> preserve sources, singleton-wrap siblings,
+ and return a Gridspace
+```
+
+For domain arguments that are tuples or vectors, an admitted source may be a
+collection member. The collection itself is reconstructed before the scalar
+action runs.
+
+The current behavior is:
+
+| Entry point | Scalar or complete input | Explicit varying input |
+|---|---|---|
+| `Material` | `Materials.Material` | `Gridspace{Materials.Material}` |
+| `Disk`, `Shell`, and other physical geometry | concrete primitive or contextual shell | `Gridspace{Primitive}` or `Gridspace{Shell}` |
+| `Region`, `Stack`, `Group`, `Assembly`, `Enclosure` | concrete cable part | `Gridspace{TargetPart}` |
+| `CableDesign` | `DataModel.CableDesign` | `Gridspace{DataModel.CableDesign}` |
+| `at`, `trefoil`, `hflat`, `vflat` | `Pose2` or `Vector{Pose2}` | corresponding `Gridspace` |
+| `homogeneous` | `EarthModel` | `Gridspace{EarthModel}` |
+| `LineCableSystem` | `DataModel.LineCableSystem` | `Gridspace{DataModel.LineCableSystem}` |
+| `LineParametersProblem` | `Engine.LineParametersProblem` | `Gridspace{Engine.LineParametersProblem}` |
+| `CableConstantsProblem` | `Engine.CableConstantsProblem` | `Gridspace{Engine.CableConstantsProblem}` |
+| `CableConstants` | `Engine.CableConstants` | `Gridspace{Engine.CableConstants}` |
+| `Formulation` | `Engine.LineParametersFormulation` | `Gridspace{Engine.LineParametersFormulation}` |
+| `CableConstantsFormulation` | `Engine.CableConstantsFormulation` | `Gridspace{Engine.CableConstantsFormulation}` |
+| `ModalAnalysisFormulation` | `ModalAnalysis.ModalAnalysisFormulation` | `Gridspace{ModalAnalysis.ModalAnalysisFormulation}` |
+| backend formulation constructor | completed backend formulation | target-bearing formulation `Gridspace` |
+| `@gridspace` keyword constructor | strict struct | `Gridspace{Target}` |
+
+Scalar-complete calls invoke their domain action immediately. A varying call
+stores only that callable and explicit finite sources. Each selected point
+invokes the same scalar action.
+
+## Gridspace callables
+
+A Gridspace callable should be a concrete immutable functor whose field types
+are concrete. The callable should perform one construction step and delegate physical
+validation to the target constructors:
+
+```julia
+struct PairValue end
+(::PairValue)(left, right) = (left, right)
+
+space = Gridspace{Tuple}(
+ PairValue(),
+ (Grid((1, 2)), Grid((10, 20))),
+)
+```
+
+Avoid `Function`-typed fields in frequently called builders. A function that captures
+local variables is suitable for local experiments. Reusable callables use concrete types.
+Do not introduce a passive record merely to store arguments that another
+function immediately unpacks.
+
+`@gridspace` applies the same rule to a keyword-constructed struct. The macro
+retains the strict positional constructor, returns the struct immediately for
+scalar keyword input and creates a Gridspace only when a field is an explicit
+finite source.
+
+## Materialization and realization
+
+Ordinary iteration selects an internal target-bearing `Gridpoint{Target}` and recursively
+materializes its arguments. Deterministic values pass through unchanged.
+Nested points invoke their own callable before the parent callable is invoked.
+After loading Measurements, an `UncertainValue` materializes as one
+`Measurement`.
+
+Stochastic realization follows the same recursion with a caller-owned random
+number generator. Only `UncertainValue` leaves are redrawn. Deterministic
+selections remain fixed. A zero-sigma descriptor resolves deterministically.
+
+Materialization and realization are internal. Developers extend the public grammar through
+concrete builders and supported uncertainty extensions, not
+by exposing unresolved points as application data.
+
+## Higher-order computation
+
+Passing a formulation `Gridspace` directly to `compute` selects default
+`Combinatorial` traversal:
+
+```julia
+run = compute(problem, formulation_space; options=(;))
+run = compute(problem_space, formulation_space; options=(;))
+run = compute(ParametricProblem(problem_space, ComputationOptions(options)), formulation_space)
+```
+
+The result is a `ParametricResult` in all three cases. A scalar problem forms
+a singleton problem axis. `options` belong to the core computations. An
+existing `ParametricProblem` retains its stored options. For traversal settings
+such as retaining supplemental details, select `Combinatorial` explicitly:
+
+```julia
+run = compute(ParametricProblem(problem_space, ComputationOptions(options)),
+ Combinatorial(formulation_space; options=(retain_details=true,)))
+```
+
+`Combinatorial` accepts one completed formulation, a deterministic
+target-bearing formulation `Gridspace`, or a deterministic `Grid` containing
+completed formulations. Formulation points are resolved once. Traversal then
+materializes each selected problem exactly once and gives the complete
+formulation vector to `compute`:
+
+```text
+resolve every formulation point once
+
+for each `Gridpoint{Problem}`
+ materialize the scalar problem once
+ compute(problem, resolved_formulations)
+end
+```
+
+Each problem-formulation pair is evaluated. Composition inside a formulation
+constructor remains local to that formulation: `combine=:product` or
+`combine=:zip` determines its formulation points, while the outer
+problem-formulation relation is always Cartesian.
+
+The generic vector `compute` method delegates to established scalar dispatch.
+Owners may specialize that vector method to share immutable lowering work. The
+Coaxial line-parameter path validates and flattens every selected design once
+per problem point, constructs its formulation-independent local input once,
+then allocates and solves one independent workspace per formulation. The
+cable-constant path independently follows the same one-flatten rule. Mutable
+matrices, formula-dependent earth data, reduction maps, and diagnostic storage
+belong to each workspace. Modal transformation does not need special lowering and uses the
+generic route on the same phase-domain matrices.
+
+`ParametricResult` retains both axes:
+
+```julia
+run.axes.problems
+run.axes.formulations
+run[problem_index, formulation_index]
+```
+
+Its `values` vector and ordinary linear iteration remain available. Storage is
+column-major in `(problem, formulation)` coordinates: the problem index varies
+fastest. The formulation for column `j` is stored in `run.axes.formulations[j]`.
+Use `formula_id(run.axes.formulations[j].methods.earth_impedance)` to identify
+its earth-impedance method.
+
+Direct linear propagation uses the same traversal. The Measurements extension
+changes only how an uncertain descriptor materializes. `LinearErrorResult`
+stores only its formulation and ordered core results.
+
+ParametricBuilder defines this shared traversal as the qualified `traverse`
+method. It computes the first problem-formulation batch, allocates a vector of
+that exact result type with the analytic Cartesian cardinality and rejects any
+later type change. Optional detail records follow the same rule and are
+resolved through each scalar formulation's computation owner. `Combinatorial`
+constructs its result space from `values`, `axes` and `details`. `LinearError`
+uses one scalar formulation and consumes `values` and `details`.
+
+Monte Carlo selects each outer point once and derives a deterministic seed for that point. It repeatedly realizes the same point:
+
+```text
+for each selected outer point
+ until the requested successful trials are collected
+ redraw uncertain leaves within that point
+ build a fresh complete core problem
+ Engine.compute
+ optionally reject DomainError realizations with a bounded number of resampling attempts
+ aggregate that point's draws
+end
+```
+
+Each nominal and error point produces its own aggregate. `MonteCarloResult` directly owns sample-mean core results, statistics,
+optional retained samples, optional histograms, the root seed, point seeds,
+and trial counts.
+
+The default `on_error=:fail` rethrows every exception. With
+`options=(retain_details=true, on_error=:resample, max_failures=n)`, only
+`DomainError` is treated as an unsupported realization. Rejected draws do not
+enter samples or statistics, and resampling stops when the requested accepted-trial
+count is reached or `n` failures have occurred. This estimates the conditional
+distribution of the output when problem construction and computation
+succeed. The retained failure summary makes the conditioning rate explicit.
+
+For cable-constant Monte Carlo computations, the representative stored in the
+result space remains a `CableConstants` core result. Retained samples,
+statistics, and histograms are concrete named tuples with keys `R`, `L`, `C`,
+and `G`. Cable samples have assembly × trial dimensions. Line-parameter
+products retain conductor × conductor × frequency × trial dimensions.
+`MonteCarloResult` validates these point-aligned arrays' keys and dimensions
+and supplies their public observation methods.
+
+### Observe and compare retained uncertainty
+
+Statistical selectors use the same observation grammar as deterministic values:
+
+```julia
+using Statistics
+using LineCableModels.ReportBuilder: BenchmarkTableDefinition
+request = @observe (statistics, R, mean)[1, :, :, :]
+table = observables(mc_result, (request, (statistics, R, std, 1)); length_unit=:base)
+```
+
+Both MC and LEP expose selected `mean` and `std`. MC means are empirical.
+LEP means are first-order nominal predictions. `std` is physical spread, not
+uncertainty of the estimated mean. A full MC request `(statistics, R, 1)`
+publishes mean, standard deviation, minimum, fifth percentile, median, 95th percentile, maximum and the successful trial count.
+`Base.Fix2(quantile, 0.05)` selects the retained fifth percentile. Unsupported
+percentiles fail rather than interpolate an invented distribution.
+X/B scale the retained L/C summaries by positive `2πf`. Complex Z/Y support
+mean and the nonnegative complex standard deviation. Ordered complex
+percentiles are undefined. Joint samples and histograms remain separate
+products, available only when retained or derivable from retained samples.
+
+```julia
+definition = BenchmarkTableDefinition(((statistics, R, mean), (statistics, R, std));
+ bands=(:all, :dc, :harmonic, :narrow, :wide))
+comparison = report(definition, (reference=mc_result, result=lep_result))
+comparison.table.features # Numeric formulation rows, frequency-band columns
+comparison.table.statistics
+comparison.table.sampling
+```
+
+Results without terminal identities require explicit `(result, metadata)`
+operands. Multiple reference points require explicit `pairing`. Populations
+are never pooled. The shared Engine RMS applies the same two-sided numerical
+resolution rule to each selected statistic and frequency band.
+
+`confidence(mc_result, point)` reports the actual simultaneous DKW bound,
+configured target, marginal count, trial count and conditioning. It counts
+both matrix orientations conservatively and applies per outer point, not
+campaign-wide. Fixed trials need not certify the configured target. Its
+`mean_standard_error` is `s/sqrt(n)` for `n>1`, not an exact confidence interval.
+One trial cannot establish population spread. No LEP distribution is inferred
+from two moments, and no report initiates sampling or physical computation.
+
+Portable scientific records preserve the complete set of retained MC products and shared MC/LEP
+Measurement sources, including signed sensitivities. Built-in `:normal` and
+`:uniform` law choices are retained directly. Distributions.jl `Normal` and
+`Uniform` records retain their actual parameters. Other custom input laws or
+formulation declarations require an owner-provided codec and fail explicitly
+when no codec exists. They are never changed into a supported law on recovery.
+
+All completed result spaces are one-dimensional finite Julia collections.
+Iteration and indexing return one stored core result per computation. A
+`ParametricResult` with several formulations contains the Cartesian
+problem-formulation cardinality in its documented storage order. Monte Carlo
+iteration returns the stored uncertainty-bearing
+core result constructed during aggregation from each point's sample means and
+sample standard deviations. Individual trials
+remain available only through `samples`. Standard `first`, `last`, `only`,
+`collect`, `map`, and `zip` operations apply. `only` asserts singleton
+cardinality and does not perform statistical selection or result transport.
+
+## Transporting completed result spaces
+
+A completed result space enters another scalar computation through the target
+`Gridspace` constructor:
+
+```julia
+modal_problems = Gridspace{ModalAnalysisProblem}(phase_results)
+```
+
+The following example starts with two completed phase scans and a completed
+line formulation. It uses public collection constructors and compute methods
+to calculate modal parameters and then finite-length responses:
+
+```julia
+# phase_a and phase_b are completed phase LineParameters with declared lengths.
+phase_results = ParametricResult(Combinatorial(line_formulation),
+ [phase_a, phase_b])
+modal_formulation = ModalAnalysisFormulation(:default)
+
+# One scalar action and direct target-bearing Gridspace action.
+scalar_modal = compute(ModalAnalysisProblem(phase_a), modal_formulation)
+modal_problems = Gridspace{ModalAnalysisProblem}(phase_results)
+modal_results = compute(modal_problems, modal_formulation)
+length(modal_results) == 2 # N → N, original source order
+
+# Explicit Combinatorial uses the same finite traversal.
+explicit_results = compute(ParametricProblem(modal_problems),
+ Combinatorial(modal_formulation))
+length(explicit_results) == 2
+
+two_formulations = ModalAnalysisFormulation(
+ Grid((modal_formulation, modal_formulation)))
+product_results = compute(modal_problems, two_formulations)
+length(product_results) == 4 # N × M, source index varies fastest
+
+segments = collect(Gridspace{PropagationParameters}(modal_results))
+short_segments = PropagationParameters(scalar_modal;
+ line_length=Grid((150.0, 300.0)))
+roots = gamma.(modal_results) # each element is a mode × frequency array
+voltage_bases = Tv.(modal_results) # each element is a phase × mode × frequency array
+responses = H.(segments) # each element is a mode × frequency array
+```
+
+`modal_results` and `product_results` store concrete scalar `LineParameters`
+elements. `segments` stores concrete scalar `PropagationParameters` elements.
+The first axis of each completed result space follows the source order. For
+the product, the linear index is `source_index + (formulation_index-1)*N`.
+The quantity arrays
+remain single outer elements under ordinary Julia broadcast. A completed
+formulation `Grid` can be passed directly to `compute`.
+
+The dispatched method `Gridspace{Target}(source::SourceResult)` defines how arguments from the source family are supplied to the target problem constructor. The
+transport preserves source cardinality and order unless that source-specific
+method explicitly documents another operation. It produces a `Gridspace` of
+the target problem type.
+
+`ParametricResult` transports its completed combinatorial results directly.
+`LinearErrorResult` and `MonteCarloResult` transport their stored
+uncertainty-bearing results directly. Load `Measurements` before MC computation.
+The extension is required before sampling starts. Aggregation constructs each
+marginal from the accepted raw samples, independently of histogram bins and
+sample-retention options. The uncertainty is the output sample standard
+deviation, not the standard error of its mean. Covariance between observables remains unavailable from this marginal surrogate. Repeated indexing, `uncertain`, and
+transport reuse its existing uncertainty-source identities without rebuilding
+Measurements.
+
+Only result families with defined semantics are admitted. An unsupported
+source-target pair raises an error identifying both types and directs the caller to
+`?Gridspace` and this page. Extension code adds a transport by defining:
+
+```julia
+import LineCableModels: Gridspace
+
+function Gridspace{Target}(source::OwnedResultSpace)
+ # Expose stored source values, then return Gridspace{Target}.
+end
+```
+
+This keeps every scientific computation scalar. Finite composition recurses
+through the same typed result-to-problem conversion:
+
+```text
+ResultSpace{A} → Gridspace{ProblemB} → ResultSpace{B}
+```
+
+## Pairing, exact reuse, and correlation
+
+Zip pairing, exact argument reuse, and stochastic correlation have different
+semantics.
+
+`combine=:zip` is deterministic row pairing between finite sources. Zip pairing says
+nothing about covariance.
+
+Exact reuse is structural. Pass one selected uncertain argument once to a
+callable and use that argument more than once:
+
+```julia
+struct Duplicate end
+(::Duplicate)(value) = (value, value)
+
+space = Gridspace{Tuple}(
+ Duplicate(),
+ (Grid(10.0, AbsoluteError(0.5)),),
+)
+```
+
+Direct propagation constructs one Measurement and both tuple positions retain
+that variable. Monte Carlo draws once and passes the same scalar to both
+positions. But two separately declared uncertain source positions are
+independent, even when they contain the same Grid instance or numerically equal
+descriptors.
+
+General correlation between distinct variables is a UQ concern. Correlation requires a
+joint stochastic source or distribution that returns a tuple or vector sample
+consumed by one builder. Gridspace does not infer or register correlation.
+
+### Feasible geometric dependence
+
+For a fixed-count wire ring, varying the ring radius and wire diameter
+independently can produce overlaps even when the nominal ring fits. Declare the
+intended dependence in an ordinary builder, before constructing physical parts:
+
+```julia
+using LineCableModels, Measurements, Random
+
+joint = Gridspace{NamedTuple{(:inner, :diameter, :x)}}(
+ (scale, x) -> (inner=0.0679scale, diameter=0.006scale, x=x),
+ (Grid(1.0, 10.0), Grid(0.0, AbsoluteError(0.002))),
+)
+geometry = Gridspace{Tuple}(
+ p -> (
+ Ring(68; r=p.inner+p.diameter/2),
+ Disk(p.diameter/2),
+ Annulus(p.inner, p.inner+p.diameter, Pose2(p.x, 0.0)),
+ ),
+ (joint,),
+)
+propagated = only(geometry)
+sampled = rand(Xoshiro(42), geometry; distribution=:uniform)
+```
+
+Lengths are in meters. Here the common scale has mean 1, standard deviation
+0.1 and uniform support `[1-sqrt(3)*0.1, 1+sqrt(3)*0.1]`. The nominal chord
+clearance is positive. Shared positive scaling preserves that sign and the
+68-wire inventory throughout this support. The annulus thickness is derived
+from the same diameter, and its independent coordinate retains its own
+uncertainty. For a complete stack, derive successive layer radii from positive
+thicknesses inside the same builder. Nest the resulting source once in the
+`LineParametersProblem` builder, then use it with `ParametricProblem` and
+either `LinearError` or `MonteCarlo`.
+
+This is a specified correlated model, not independent manufacturing tolerances.
+It preserves each length's nominal mean and 10% standard deviation, but derived
+areas scale quadratically: their mean is `1.01` times nominal area. Linear
+propagation is local and does not include that second-order mean shift.
+Normal sampling remains the default and has unbounded support. Finite reserve
+distances cannot make all independent normal draws feasible. Resampling
+estimates a distribution conditional on success, not the original input law.
+
+Executable native checkpoints retain the joint builder and its sources.
+Marginal Measurement JSON retains values and standard uncertainties only. It is
+not a format for archiving the joint statistical law or covariance.
+
+## Optional package extensions
+
+The core package declares uncertainty without loading Measurements or
+Distributions.
+
+- Loading Measurements adds direct materialization of `UncertainValue` while
+ retaining exact structural reuse.
+- Loading Distributions adds standardized univariate sampling families. The
+ selected distribution must have finite mean and positive finite standard
+ deviation. Samples are transformed to the descriptor's nominal value and
+ standard uncertainty.
+
+## Performance and conformance
+
+The implementation relies on tuple-specialized recursion and Julia's public
+product and zip iterators. The implementation guarantees:
+
+- `length` is computed analytically from source cardinalities.
+- Product and zip traversal are linear in yielded work.
+- Materialization and realization do not use a dictionary or identity lookup.
+- Immutable scalar targets infer through selection, materialization, and
+ realization.
+- After warmup, deterministic iteration and a bare 10,000-realization scalar
+ loop run without heap allocation.
+- Allocations during full cable and line construction are limited to the requirements of existing vectors and domain constructors, the Engine computation and the requested result storage.
+
+The conformance tests in `test/unit/commons/gridspace.jl` and
+`test/unit/parametricbuilder/conformance.jl` check these properties, exact structural
+reuse, explicit variation, and scalar public construction.
+
+## Implementation map
+
+The implementation is split across:
+
+- `src/grid.jl`: finite values and uncertainty descriptors.
+- `src/gridspace.jl`: composition, point selection, recursive
+ materialization, and realization.
+- `src/parametricbuilder/macros.jl`: strict scalar construction and explicit
+ Gridspace lifting for `@gridspace`.
+- material, cable, position, and system files: scalar construction and lifting
+ rules and concrete callable algorithms.
+- `src/parametricbuilder/traversal.jl`: combinatorial traversal and the
+ phase-to-modal composition of a `ParametricProblem`.
+- `src/engine/modalanalysis/composition.jl`: public phase-to-modal composition.
+ `src/engine/modalanalysis/problems.jl`, `compute.jl`, and `propagation.jl` own
+ modal scalar computation and line segment binding.
+- `src/uq/linearerror.jl` and `src/uq/montecarlo/compute.jl`: direct and
+ repeated stochastic traversal.
+- Measurements and Distributions extensions: dependency-specific uncertainty
+ behavior only.
diff --git a/docs/src/index.md b/docs/src/index.md
index 3796e339c..1d3fcb605 100644
--- a/docs/src/index.md
+++ b/docs/src/index.md
@@ -1,54 +1,90 @@
# LineCableModels.jl
-[`LineCableModels.jl`](https://github.com/Electa-Git/LineCableModels.jl) is a specialized Julia package designed to compute the electrical parameters of coaxial arbitrarily-layered underground/overhead cables with uncertainty quantification. It focuses on calculating line and cable impedances and admittances in the frequency-domain, accounting for skin effect, insulation properties, and earth-return impedances with frequency-dependent soil models.
-
-## Documentation outline
-
-```@contents
-Pages = [
- "index.md",
- "tutorials.md",
- "reference.md",
- "bib.md",
-]
-Depth = 1
-```
+[`LineCableModels.jl`](https://github.com/Electa-Git/LineCableModels.jl)
+calculates frequency-domain electrical parameters for underground and overhead
+cable systems. The models include conductor skin effect, dielectric loss,
+earth return, frequency-dependent earth properties, and declared uncertainty
+in geometry and material data.
+
+## Documentation
+
+- [Tutorials](tutorials.md) introduce cable construction and computation.
+- [Modeling and results](usage.md) covers computations, result access,
+ uncertainty, tables, and plots.
+- [Gridspace and uncertainty](gridspace.md) specifies finite variation and
+ uncertainty realization.
+- [API reference](reference.md) lists the line and cable computation API.
+- [Conveniences](conveniences.md) covers estimates, scalar formulas, and VDE
+ designation parsing.
+- [Developers](developers.md) records grammar invariants, CI checks, extension
+ APIs, and project conventions.
## Features
-- Calculates all base DC parameters of a given cable design (R, L, C and G), for solid, tubular or stranded cores, semiconductors, screens, armors, sheaths, tapes, and water-blocking materials, with uncertainty propagation using the [Measurements.jl](https://github.com/JuliaPhysics/Measurements.jl) package.
-- Correction factors to account for temperature, stranding and twisting effects on the DC resistance [app14198982](@cite), GMR [6521501](@cite) and base inductance of stranded cores and wire screens [yang2008gmr](@cite).
-- Explicit computation of dielectric losses and effective resistances for insulators and semiconductors [916943](@cite). Correction of the magnetic constant of insulation layers to account for the solenoid effect introduced by twisted strands [5743045](@cite).
-- Computes phase-domain Z/Y matrices for poliphase systems with any number of conductors per phase, and sequence-domain components for three-phase systems, with uncertainty propagation.
-- Improved equivalent tubular representation for EMT simulations and direct export to ATPDraw and PSCAD formats.
-- Computes internal impedances of solid, tubular or coaxial multi-layered single-core (SC) cables, using rigorous [4113884](@cite) or equivalent approximate formulas available in [industry-standard EMT software](https://www.pscad.com/webhelp/EMTDC/Transmission_Lines/Deriving_System_Y_and_Z_Matrices.htm).
-- Computes earth-return impedances and admittances of underground conductors in homogeneous soil, based on a rigorous solution of Helmholtz equation on the electric Hertzian vector, valid up to 10 MHz [5437464](@cite).
+- Construct deterministic and uncertain designs with the typed
+ `Grid`/`Gridspace` grammar and evaluate them with `compute`.
+- Calculate base cable parameters for solid, tubular, and stranded cores,
+ semiconductors, screens, armors, sheaths, tapes, and water-blocking materials.
+- Apply temperature, wire stranding and twisting corrections to DC resistance
+ [app14198982](@cite), GMR [6521501](@cite) and base inductance
+ [yang2008gmr](@cite).
+- Calculate dielectric loss and equivalent insulation resistance
+ [916943](@cite), including the solenoid contribution of twisted strands to
+ insulation permeability [5743045](@cite).
+- Assemble phase-domain Z/Y matrices for polyphase systems with any number of
+ conductors per phase, with optional Measurements-based direct propagation or
+ conditional Monte Carlo analysis.
+- Replace stranded assemblies with equivalent tubular conductors for EMT
+ computations and export cable data to ATPDraw and PSCAD.
+- Calculate internal impedance for solid, tubular, and multilayer coaxial
+ single-core cables with the formulation in [4113884](@cite) or the
+ approximations documented by
+ [PSCAD](https://www.pscad.com/webhelp/EMTDC/Transmission_Lines/Deriving_System_Y_and_Z_Matrices.htm).
+- Calculate earth-return impedance and admittance for underground conductors in
+ homogeneous soil from the electric-Hertz-vector solution of the Helmholtz
+ equation, up to 10 MHz [5437464](@cite).
## Installation
-Clone the package and add to the Julia environment:
+Install the registered package from Julia's package manager:
```julia-repl
-pkg> add https://github.com/Electa-Git/LineCableModels.jl.git
+pkg> add LineCableModels
```
-If you are using the finite-element solver, it is recommended to run the build script to retrieve the binaries needed by the [GetDP.jl](https://github.com/Electa-Git/GetDP.jl) front-end:
+Then load the core package:
-```julia-repl
-pkg> build LineCableModels
+```julia
+using LineCableModels
```
-Then, in your Julia code, import the package:
+Plotting is optional. Load one backend explicitly before calling `preview` or
+`plot`:
```julia
using LineCableModels
+using CairoMakie
```
+## User statistics
+
+
+
+The map is generated in CI from Julia's public package-server request logs. It
+shows the top server regions by the sum of `request_addrs` for requests marked
+as user traffic. These regional aggregates are not a count of distinct people
+and do not represent country-level telemetry.
+
+
## License
-The source code is provided under the [BSD 3-Clause License](https://github.com/Electa-Git/LineCableModels.jl/LICENSE).
+The source code is licensed under the
+[BSD 3-Clause License](https://github.com/Electa-Git/LineCableModels.jl/LICENSE).
+The optional FEM backend invokes GetDP as an external program. Its notice and
+source link are recorded in
+[`THIRD_PARTY_NOTICES.md`](https://github.com/Electa-Git/LineCableModels.jl/blob/main/THIRD_PARTY_NOTICES.md).
---
```@raw html
""",
- )
- end
-
- # New show for width-aware variant
- function Base.show(io, mime::MIME"text/html", tc::TwoColumnWithWidths)
- l, r = tc.widths
- write(
- io,
- """
-
Accurate modeling of the different conductor materials is crucial for the proper representation of line/cable parameters and propagation characteristics.
-
- Expansion of currently implemented routines to include different earth impedance models, FD soil properties and modal decomposition techniques.
-
-
Construction of additional cable models, detailed investigations on uncertainty quantification.
-
- Development of novel formulations for cables composed of N concentrical layers, allowing for accurate representations of semiconductor materials.
-
-
- Additional tests and validations using the FEM solver.
-
-
-
- """
-
-# ╔═╡ 7d77f069-930b-4451-ab7d-0e77b8fd86a7
-md"""
-# Thank you!
-"""
-
-# ╔═╡ Cell order:
-# ╟─426024d7-23c8-4261-9c20-d0045b1ab077
-# ╠═3183ff8d-a9fd-4035-af8a-664bec3606d4
-# ╠═46cfd6fa-b4d6-44c3-83cf-d2b9b1ff1cf1
-# ╠═4462e48f-0d08-4ad9-8dd9-12f4f5912f38
-# ╠═e90baf94-c8b8-41aa-8728-e129f7f6881e
-# ╠═532cb61b-97b6-43e7-a8f9-3a5f12b8b3f7
-# ╠═b16ff72c-872a-4505-9468-6cefd4a8852c
-# ╠═9fefeafa-63f9-43d0-a2ee-4d4fca170126
-# ╠═fde80e93-1964-4287-acfc-a2da2d4b7d48
-# ╟─77d3c731-bdae-46c2-8b23-eb0b860e7444
-# ╟─9ef6f05f-c384-4fab-bc39-e47edb49f994
-# ╟─f08a32db-05d9-4ddb-9c46-34bc623ce5e7
-# ╟─50384351-fc38-4b29-9bf6-db1556d49dee
-# ╟─23913cc6-a81b-4098-bacf-7a2e09998e53
-# ╟─d482241c-4bd5-4896-bdc1-e82387f69051
-# ╟─14f07acc-5353-4b1d-b94f-9ae43f87289b
-# ╟─6c6e4d21-cc38-46eb-8178-4cc4a99adcba
-# ╟─3e6a9c64-827d-4491-bcac-252ee7b1dc81
-# ╟─877a84cc-979f-48c9-ac41-59be60b4850b
-# ╟─db1944b6-c55f-4091-8128-8d297bdc9a74
-# ╟─5397f442-8dc1-42a6-941d-0b1d58057a6b
-# ╟─a3f5a8c5-4ab9-4a33-abab-7907ffab1347
-# ╟─382252ca-ede1-4043-b921-7834e59810cb
-# ╟─96121e5b-6b5b-4ab1-81d0-6dcbe924cda2
-# ╟─a8ea0da0-36f1-44d4-9415-d3041f34c23f
-# ╟─f5fa7e28-97a7-456b-87a9-5ac4b76be9d4
-# ╟─8c2eaef0-4e01-41b9-b1a6-a20dfa9b2d57
-# ╟─cb8f01ae-26e0-44ce-8347-298ab692ac63
-# ╟─29222f8e-fb07-4bdb-8939-f18e668d2037
-# ╟─c1595a9d-7882-4b66-a1fc-fe6de19f1ef6
-# ╠═c2539b01-ac04-48e4-a973-6a5d8a0e2b58
-# ╟─062439db-1e3f-497e-96c1-e1f65f80399b
-# ╟─4e1dec4b-223f-45f8-9393-523fcc4019f0
-# ╟─5b005f4b-605e-4a3d-ba7f-003908f332b2
-# ╟─e0f87b28-14a3-4630-87db-6f4b51bdb30a
-# ╟─b081c88a-7959-44ea-85ff-33b980ec71b4
-# ╟─0b5142ef-2eb0-4c72-8ba5-da776eadb5a3
-# ╠═1ae76282-4340-4df0-ba29-11712c184a79
-# ╠═9ddcccbe-86c8-4335-8d65-35af4ce755ab
-# ╠═43ff64cb-1226-4d26-9fdf-8aff03505439
-# ╠═ae1749c8-0f6d-4487-8857-12826eb57db3
-# ╠═3d9239df-523e-40be-b6e9-f0d538638bd8
-# ╠═fd1e268a-6520-4dc8-a9ff-32a4854859df
-# ╠═b4169697-e06d-4947-8105-9f42017f5042
-# ╠═0900d10f-8191-4507-af4e-50d7f4a1126f
-# ╟─4ce85966-0386-4525-8cf2-35e9814f8459
-# ╠═44f5823e-4b07-4f2c-8773-e4c3187a6100
-# ╠═987902c5-5983-4815-b62f-4eabc1be2362
-# ╠═6ee6d16d-326c-4436-a750-077ecc2b3b9c
-# ╠═39f7460d-8a1e-483d-94f4-14500d6c9ac2
-# ╟─a2e5d81f-f6a2-4b04-83cc-c95026fd283a
-# ╠═cebe81ec-a183-43d7-be36-6627a46de3bf
-# ╠═ce7d068e-2831-49dc-a459-bb68138c3a00
-# ╠═83d26ac6-24e5-4ca1-817c-921d3c2375c5
-# ╠═cb44ffb8-7e33-4603-a97e-47dbc507f813
-# ╠═e8117400-adf3-45e3-bf56-59933f01e6d0
-# ╠═d20e89c6-b980-4f57-8989-f86d23ea59c6
-# ╠═c6cdfb66-1405-4208-808b-12f3e0949ed1
-# ╟─2f28cc7a-cb14-44e6-908a-f34b8991c1bd
-# ╟─fb9cfe06-1a26-443a-9669-615a4e0463b4
-# ╟─7d77f069-930b-4451-ab7d-0e77b8fd86a7
diff --git a/showcase/start.jl b/showcase/start.jl
deleted file mode 100644
index 2ec261a66..000000000
--- a/showcase/start.jl
+++ /dev/null
@@ -1,26 +0,0 @@
-# This script launches Pluto and opens a specific notebook file.
-using Pluto
-# using PlutoStyles
-
-# Define the path to the notebook file.
-# notebook_path = "/home/amartins/Documents/KUL/LineCableModels/showcase/showcase1.jl"
-
-# println("Starting Pluto and opening notebook: ", notebook_path)
-println("Starting Pluto, check your browser...")
-
-try
- # Run Pluto, telling it which notebook to open
- Pluto.run(launch_browser = true)
-
- # Keep the script alive so the Pluto server doesn't shut down immediately.
- println("\nPluto server is running. Press Ctrl+C in this terminal to stop.")
- wait(Condition()) # Waits indefinitely until interrupted (Ctrl+C)
-
- println("\nAn error occurred while trying to run Pluto:")
- showerror(stdout, e)
- Base.show_backtrace(stdout, catch_backtrace())
-finally
- println("\nPluto server stopped.")
-end
-
-println("Launcher script finished.")
diff --git a/src/LineCableModels.jl b/src/LineCableModels.jl
index 3a3dae8fc..8d9a0fa85 100644
--- a/src/LineCableModels.jl
+++ b/src/LineCableModels.jl
@@ -1,91 +1,231 @@
+"""
+ LineCableModels
+
+Calculate electrical parameters for overhead and underground cable systems.
+
+The public API constructs materialized or finite parametric cable models,
+evaluates cable constants and line-parameter matrices with selected numerical formulations. It propagates declared uncertainty and provides plotting methods when Makie is loaded.
+"""
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, Sector, SectorParams, SectorInsulator
-export ConductorGroup, InsulatorGroup
-export CableComponent, CableDesign, NominalData
-export CablesLibrary
-export CablePosition, LineCableSystem
-export trifoil_formation, flat_formation, preview, equivalent, MaxFill
-
-# Earth properties:
-export EarthModel
+export add!, build, homogenize, validate, description, constitutive
+export formula, formula_id
+export AbstractProblemDefinition, AbstractFormulation, AbstractProblemResult
+export AbstractCoreResult, AbstractResultSpace
+export AbstractParametricResult, AbstractUncertaintyResult
+export FormulationOptions, ComputationOptions, ComputationDetails
+export formulation_options, computation_options, computation_details, details
+export compute, observe, @observe, observables
+export ObservedResult, kron_reduce
+export quantity, native_unit, display_unit, scale_factor, label, symbol
+export basis, line_length, domain, frequencies, nconductors, nfrequencies, ncables, nphases
+export Z, Y, R, X, L, G, B, C
+export series_impedance, shunt_admittance,
+ resistance, reactance, inductance,
+ conductance, susceptance, capacitance
+# High-level modelling grammar:
+export Grid, AbsoluteError, DeterministicGrid, RelativeGrid, AbsoluteGrid
+export AbstractGrid, AbstractUncertainGrid, UncertainValue
+export Gridspace
+export has_uncertainty, nominal, uncertainty
+export @gridspace
+export Combinatorial, LinearError, MonteCarlo, ParametricProblem
+export ParametricResult, LinearErrorResult, MonteCarloResult
+export SampleSummary, HistogramDensity
+export statistics, samples, histograms, uncertain
+export root_seed, point_seed, trial_count
+export confidence, cdf_tolerance, sampling_distribution
+export report, TableReportDefinition, XLSXReportDefinition, ReportArtifact
+export AbstractMaterial, Material, RadialDielectric, MaterialsLibrary
+export AbstractShape, AbstractPrimitive
+export Disk, Rectangle, Ellipse, Sector, Annulus, Polygon, Shell
+export Pose2
+export EmptyBoundary, resolve, boundary, area, perimeter, centroid, support, tessellate
+export r_in, r_ex, thickness, outer_radius
+export AbstractCablePart, Region, Stack
+export Group, Assembly
+export Enclosure
+export Ring, Polar, Fill, Lattice, placements
+export capacity, FillFactor
+export LayRatio, Pitch, LayAngle, Helix, pitch, angle, overlength
+export at, trefoil, hflat, vflat, layer, homogeneous
+export AbstractEarthModel, AbstractEarthLayer, AbstractEarthMaterial, EarthMaterial,
+ EarthLayer, EarthModel
+export terminal, core, stranded, milliken, rope, cores, tape, insulation, screen, sheath
+export armor, bedding, jacket, filler, pipe, duct
+export solid, shell, wires, layers, assembly
+export @cable, @system, @earth, @terminal, @assembly, @pipe, @duct
+export @at, @hflat, @vflat, @trefoil
+export @distribute
+export estimate_stranding, estimate_screen, WireEstimate
+
+public Gridpoint
+
+# Materialized results, reusable designs, and presentation:
+export CableDesign, LineCableSystem, DatasheetInfo, datasheet
+export CableGeometry, PlacedRegion
+export CableConstants, CableConstantsProblem, CableConstantsFormulation,
+ LineParametersProblem, LineParameters, CablesLibrary
+export preview, show_material_scale
# Engine:
-export LineParametersProblem,
- FormulationSet, compute!, SeriesImpedance, ShuntAdmittance, per_km, per_m, kronify,
- LineParameters, PhaseDomain, ModalDomain
-
-# Parametric builder:
-# export make_stranded, make_screened
-# export conductor, insulator
+export Formulation, LineParametersFormulation, CableConstantsFormulation,
+ LineCableModelsCoaxial,
+ LineCableModelsFEM, LineCableModelsFEMError, BoundarySolveError,
+ SeriesImpedance, ShuntAdmittance,
+ LineParameters, PhaseDomain, ModalDomain
+export ModalAnalysisProblem, ModalAnalysisFormulation,
+ LineCableModelsModal, ModalOperators, operators, Tv, Ti, gamma, alpha, beta, velocity, Zc, Yc,
+ PropagationParameters, H, transform
+export ModalAnalysis
# Import/Export:
-export export_data, save, load!
+export export_data, import_data, save, load!
# -------------------------------------------------------------------------
import DocStringExtensions: DocStringExtensions
+using DocStringExtensions: SIGNATURES, TYPEDSIGNATURES, TYPEDEF, TYPEDFIELDS
+using Random
+import Logging
-# Submodule `Commons`
-include("commons/Commons.jl")
-using .Commons: IMPORTS, EXPORTS, add!, PhaseDomain, ModalDomain, domain
-# Submodule `UncertainBessels`
-include("uncertainbessels/UncertainBessels.jl")
-
-# Submodule `Utils`
-include("utils/Utils.jl")
-using .Utils: set_verbosity!
+include("docstrings.jl")
+include("interfaces.jl")
-# Submodule `UnitHandler`
-include("unithandler/UnitHandler.jl")
+public FormulaDefinition, Expression, Functor
-# Submodule `Validation`
-include("validation/Validation.jl")
+# Submodule `Units`
+include("units/Units.jl")
+using .Units: quantity, native_unit, display_unit, scale_factor, label, symbol
-# Submodule `PlotBuilder`
+# Package-local shared computation grammar.
+include("commons/Commons.jl")
+using .Commons:
+ AbstractProblemDefinition, AbstractFormulation, AbstractProblemResult,
+ AbstractCoreResult, AbstractResultSpace,
+ AbstractParametricResult, AbstractUncertaintyResult,
+ FormulationOptions, ComputationOptions, ComputationDetails,
+ formulation_options, computation_options, computation_details, details,
+ observe, @observe, observables, ObservedResult, kron_reduce
+import .Commons: compute, validate
+using .Commons: FormulaDefinition, Expression, Functor
+include("logging.jl")
+include("formulas.jl")
+
+# Bounded text formatting consumes the completed shared declaration grammar.
+include("textdisplay/TextDisplay.jl")
+import .Commons: nominal, uncertainty
+
+# Root-owned finite parameter grammar. These primitives are loaded before the
+# domain modules so every constructor enters the same scalar-or-Gridspace path
+# without a late-loaded bridge through ParametricBuilder.
+include("grid.jl")
+include("gridspace.jl")
+
+public parameterize, materialize, sample_uncertainty
+
+# Thin native plotting handles and optional-extension entry points.
include("plotbuilder/PlotBuilder.jl")
-using .PlotBuilder.BackendHandler: set_backend!
+using .PlotBuilder:
+ UIPlot, plot, preview, show_material_scale, export_svg,
+ figurelegend!, panellegend!, figuretitle!, paneltitle!,
+ figurecolorbars!, axisscale!, resetview!, addwidget!, removewidget!,
+ plotwindow, materialcolors, materialscale!
+export UIPlot, export_svg, figurelegend!, panellegend!, figuretitle!, paneltitle!
+export figurecolorbars!, axisscale!, resetview!, addwidget!, removewidget!
+export materialcolors, materialscale!
+public PlotBuilder, plot, plotwindow
# Submodule `Materials`
include("materials/Materials.jl")
-using .Materials: Material, MaterialsLibrary
+import .Materials
+using .Materials: AbstractMaterial, Material, RadialDielectric, MaterialsLibrary
-# Submodule `EarthProps`
-include("earthprops/EarthProps.jl")
-using .EarthProps: EarthModel
+# Submodule `Earth`
+include("earth/Earth.jl")
+import .Earth
+public Earth
+using .Earth: AbstractEarthModel, AbstractEarthLayer, AbstractEarthMaterial,
+ EarthMaterial, EarthLayer, EarthModel, layer, homogeneous
# Submodule `DataModel`
include("datamodel/DataModel.jl")
-using .DataModel: Thickness, Diameter, CircStrands, RectStrands, Strip, Tubular, Semicon,
- Insulator, ConductorGroup, InsulatorGroup, CableComponent, CableDesign, NominalData,
- CablesLibrary, CablePosition, LineCableSystem, trifoil_formation, flat_formation,
- preview, equivalent, MaxFill, Sector, SectorParams, SectorInsulator
+using .DataModel: CableDesign, CableGeometry, PlacedRegion,
+ LineCableSystem, DatasheetInfo, datasheet,
+ CablesLibrary, ncables, nphases,
+ AbstractShape, AbstractPrimitive,
+ Disk, Rectangle, Ellipse, Sector, Annulus, Polygon, Shell,
+ Pose2,
+ EmptyBoundary, resolve, boundary, area, perimeter, centroid, support,
+ tessellate,
+ r_in, r_ex, thickness, outer_radius,
+ AbstractCablePart, Region, Stack,
+ Group, Assembly, Enclosure
+using .DataModel: Ring, Polar, Fill, Lattice, capacity, placements,
+ FillFactor,
+ LayRatio, Pitch, LayAngle, Helix, pitch, angle, overlength
# Submodule `Engine`
include("engine/Engine.jl")
-using .Engine: LineParametersProblem, compute!, LineParameters, SeriesImpedance,
- ShuntAdmittance, per_km, per_m, kronify, FormulationSet
+using .Engine: LineParameters, LineParametersProblem, CableConstants,
+ CableConstantsProblem, CableConstantsFormulation, SeriesImpedance,
+ ShuntAdmittance, Formulation,
+ LineParametersFormulation, LineCableModelsCoaxial,
+ LineCableModelsFEM,
+ LineCableModelsFEMError, BoundarySolveError,
+ domain, frequencies, nconductors, nfrequencies,
+ Z, Y, X, G, B, series_impedance, shunt_admittance,
+ reactance, conductance, susceptance,
+ LineParamsDomain, PhaseDomain, ModalDomain
+
+public LineParamsDomain
+
+# Submodule `Engine.ModalAnalysis`
+using .Engine: ModalAnalysis
+using .Engine.ModalAnalysis: ModalAnalysisProblem, ModalAnalysisFormulation,
+ LineCableModelsModal, ModalOperators, operators,
+ Tv, Ti, gamma, alpha, beta, velocity, Zc, Yc, PropagationParameters, H, transform
# Submodule `ParametricBuilder`
include("parametricbuilder/ParametricBuilder.jl")
+using .ParametricBuilder:
+ @gridspace,
+ Combinatorial, ParametricProblem, ParametricResult,
+ terminal, core, stranded, milliken, rope, cores, tape,
+ insulation, screen, sheath, armor, bedding, jacket,
+ filler, pipe, duct, solid, shell, wires, layers,
+ assembly,
+ at, trefoil, hflat, vflat,
+ WireEstimate, estimate_stranding, estimate_screen
+using .ParametricBuilder: @cable, @system, @earth, @terminal, @assembly, @pipe,
+ @duct, @at, @hflat, @vflat, @trefoil, @distribute
# Submodule `UQ`
include("uq/UQ.jl")
+using .UQ:
+ LinearError, MonteCarlo, LinearErrorResult, MonteCarloResult,
+ SampleSummary, HistogramDensity,
+ statistics, samples, histograms, uncertain,
+ root_seed, point_seed, trial_count,
+ confidence, cdf_tolerance, sampling_distribution
+
+# Completed-result measurement projections.
+include("performance.jl")
+public benchmark
+
+# Submodule `ReportBuilder`
+include("reportbuilder/ReportBuilder.jl")
+using .ReportBuilder:
+ report, TableReportDefinition, XLSXReportDefinition, ReportArtifact
# Submodule `ImportExport`
include("importexport/ImportExport.jl")
-using .ImportExport: export_data, load!, save
+using .ImportExport: export_data, import_data, load!, save
-# Aliases for backward compatibility
-const WireArray = CircStrands # alias for now
-export WireArray # export aliases
+# External-tool integration. Native execution is deferred until compute.
+include("pscad/PSCAD.jl")
+export PSCAD
-end
\ No newline at end of file
+end
diff --git a/src/cablebuilder/CableBuilder.jl b/src/cablebuilder/CableBuilder.jl
deleted file mode 100644
index b1d0834aa..000000000
--- a/src/cablebuilder/CableBuilder.jl
+++ /dev/null
@@ -1,103 +0,0 @@
-module CableBuilder
-
-include("grid.jl")
-include("gridspace.jl")
-include("macros.jl")
-
-include("materials.jl")
-
-
-include("../commons/Commons.jl")
-include("../uncertainbessels/UncertainBessels.jl"
-)
-include("../utils/Utils.jl")
-
-include("types.jl")
-include("primitives.jl")
-include("partbuilder.jl")
-include("shapes.jl")
-include("cabledesign.jl")
-
-
-export Material, CableDesign, PartGroup
-export Grid, AbsoluteError
-
-# ==========================================
-# THE FRONTEND API (Compilation boundary)
-# ==========================================
-export Conductor, Insulator, Group
-
-@inline function Group(layers::Tuple; origin = (0.0, 0.0), n = 1, m = 1)
- return Builder(PartGroup, origin, n, m, layers)
-end
-
-module Conductor
- import ..CableBuilder: ConductorPart, Builder
- import ..CableBuilder: Circular, Rectangular, Annular
- import ..CableBuilder: SolidCore, TubularLayer
- import ..CableBuilder: Grid
- import ..CableBuilder: Material
-
- @inline function Solid(cmp::Symbol, mat; r)
- # The semicolon triggers the @gridspace kwarg interceptor.
- # If `r` is a Grid, this returns a Gridspace{Circular}.
- # If `r` is a Real, it returns a concrete Circular.
- params = Circular(; r = r)
-
- return Builder(ConductorPart, SolidCore, cmp, mat, params)
- end
-
- @inline function Tubular(cmp::Symbol, mat; t)
- params = Annular(; t = t)
-
- return Builder(ConductorPart, TubularLayer, cmp, mat, params)
- end
-
-
-# @inline function Pipe(cmp::Symbol, mat; t, filler, offset = 0.0)
-# inner = Tubular(cmp, mat; t = t) # tubular wall
-# return EnclosureSpec(ConductorPart, inner, filler; offset = offset)
-# end
-
-# @inline function Stranded(
-# cmp::Symbol,
-# mat;
-# pattern::Symbol = :layer,
-# r_w,
-# n_w,
-# lay_r,
-# lay_d = 1,
-# )
-# mat_spec = convert(AbstractSpec{Material}, mat)
-# return CircStrandedSpec(
-# ConductorPart,
-# Grid(cmp),
-# Grid(r_w),
-# Grid(n_w),
-# Grid(lay_r),
-# Grid(lay_d),
-# mat_spec,
-# )
-# end
-
-end
-
-module Insulator
- import ..CableBuilder: InsulatorPart, Builder
- import ..CableBuilder: Circular, Rectangular, Annular
- import ..CableBuilder: TubularLayer
-
- import ..CableBuilder: Grid
- import ..CableBuilder: Material
-
- # Insulators don't usually have solid cores, but the logic holds!
- @inline function Tubular(cmp::Symbol, mat; t)
- params = Annular(; t = t)
- return Builder(InsulatorPart, TubularLayer, cmp, mat, params)
- end
-
-end
-
-
-
-end # module
diff --git a/src/cablebuilder/cabledesign.jl b/src/cablebuilder/cabledesign.jl
deleted file mode 100644
index 81527814e..000000000
--- a/src/cablebuilder/cabledesign.jl
+++ /dev/null
@@ -1,65 +0,0 @@
-# ---------------------------------------------------------
-# The Concrete Target
-# ---------------------------------------------------------
-struct CableDesign{T <: Tuple}
- payload::T
-end
-
-# ---------------------------------------------------------
-# The Allocation-Free Stacking Engine
-# ---------------------------------------------------------
-@inline build_design(bound::AbstractShapeParams, ::Tuple{}) = ()
-
-@inline function build_design(bound::AbstractShapeParams, builders::Tuple)
- b = first(builders)
-
- # The builder receives the absolute geometric primitive
- target = b(bound)
-
- # Extract the new bounding primitive for the next layer
- next_bound = boundary(target)
-
- return (target, build_design(next_bound, Base.tail(builders))...)
-end
-
-# ---------------------------------------------------------
-# The Constructor (Hit by the Gridspace Generator)
-# ---------------------------------------------------------
-# The generator splats the unrolled PartBuilders here.
-@inline function CableDesign(builders...)
- parts = build_design(Circular(0.0), builders)
- return CableDesign{typeof(parts)}(parts)
-end
-
-
-# ---------------------------------------------------------
-# The DSL Hook (Intent Capture & Auto-Grouping)
-# ---------------------------------------------------------
-# The user provided explicit topological groups. All good.
-@inline CableDesign(layers::Tuple{Vararg{<:Gridspace{GroupBuilder}}}) =
- Gridspace{CableDesign}(layers)
-
-# The user provided naked 1D physics parts. Default-group them.
-@inline function CableDesign(layers::Tuple{Vararg{<:Gridspace{PartBuilder}}})
- grids = (
- Grid(Val{PartGroup}()),
- Grid(((0.0, 0.0),)), # Default origin at center
- Grid(1), # Default n
- Grid(1), # Default m
- layers..., # Splat the naked part spaces
- )
-
- default_group = Gridspace{GroupBuilder}(grids)
- return Gridspace{CableDesign}((default_group,))
-end
-
-# Mixed garbage.
-@inline function CableDesign(layers::Tuple)
- throw(
- ArgumentError(
- "Topological violation: You cannot mix raw parts and explicit Groups " *
- "at the top level of CableDesign. Either wrap everything in Group() " *
- "or pass raw parts exclusively.",
- ),
- )
-end
diff --git a/src/cablebuilder/enclosure.jl b/src/cablebuilder/enclosure.jl
deleted file mode 100644
index 907a661e7..000000000
--- a/src/cablebuilder/enclosure.jl
+++ /dev/null
@@ -1,69 +0,0 @@
-struct Enclosure{T <: Real, S <: AbstractShape{T}} <: AbstractShape{T}
- base_shape::S
- filler_material::Material{T}
-end
-
-function Enclosure(
- base_shape::AbstractShape{T_shape},
- filler::Material{T_mat},
-) where {T_shape <: Real, T_mat <: Real}
-
- T = promote_type(T_shape, T_mat)
-
- s = convert(AbstractShape{T}, base_shape) # must return concrete
- f = convert(Material{T}, filler)
-
- return Enclosure{T, typeof(s)}(s, f)
-end
-
-function Base.convert(::Type{<:AbstractShape{T}}, e::Enclosure) where {T <: Real}
- # Recursively upgrade the payload
- s_converted = convert(AbstractShape{T}, e.base_shape)
- f_converted = convert(Material{T}, e.filler_material)
-
- # Lock them in a new Vault
- return Enclosure{T, typeof(s_converted)}(s_converted, f_converted)
-end
-
-# Override the global accessors because Enclosure is a diva
-r_in(e::Enclosure) = r_in(e.base_shape)
-r_ex(e::Enclosure) = r_ex(e.base_shape)
-
-struct EnclosureBuilder{P, S, O, F}
- inner::S
- offset::O
- filler::Material{F}
-end
-
-@inline function EnclosureBuilder{P}(
- inner::S,
- offset::O,
- filler::Material{F},
-) where {P, S, O, F}
- return EnclosureBuilder{P, S, O, F}(inner, offset, filler)
-end
-
-@inline function (b::EnclosureBuilder{P})(current_r::T) where {P, T <: Real}
- r0 = current_r + b.offset
- part = b.inner(r0)
- newshape = Enclosure(part.shape, b.filler)
- return P(part.cmp, newshape, part.material)
-end
-
-struct EnclosureSpec{P, S, O, F} <: AbstractSpec{EnclosureBuilder{P}}
- inner::S
- offset::O
- filler::F
-end
-
-# # Make this diva explicit about what is iterable, and what is not.
-# @inline grid_args(spec::EnclosureSpec) = (spec.inner, spec.offset, spec.filler)
-
-@inline function EnclosureSpec(::Type{P}, inner::S, offset::O, filler::F) where {P, S, O, F}
- return EnclosureSpec{P, S, O, F}(inner, offset, filler)
-end
-
-@inline function EnclosureSpec(::Type{P}, inner_spec, filler; offset = 0.0) where {P}
- filler_spec = convert(AbstractSpec{Material}, filler)
- return EnclosureSpec(P, inner_spec, Grid(offset), filler_spec)
-end
diff --git a/src/cablebuilder/grid.jl b/src/cablebuilder/grid.jl
deleted file mode 100644
index 81590db06..000000000
--- a/src/cablebuilder/grid.jl
+++ /dev/null
@@ -1,134 +0,0 @@
-import Base: iterate, length, eltype, extrema
-using Measurements
-
-# ---------------------------------------------------------
-# The Vaults (Strictly type-constrained to Tuples)
-# ---------------------------------------------------------
-struct DeterministicGrid{V <: Tuple}
- vals::V
-end
-
-struct RelativeGrid{V <: Tuple, P <: Tuple}
- vals::V
- rel_err::P
-end
-
-struct AbsoluteGrid{V <: Tuple, P <: Tuple}
- vals::V
- abs_err::P
-end
-
-# The explicit tag for absolute standard deviations.
-# If someone asks what this does, fire them.
-struct AbsoluteError{T <: Tuple}
- vals::T
-end
-
-AbsoluteError(x::AbstractArray) = AbsoluteError(Tuple(x))
-AbsoluteError(x::Real) = AbsoluteError((x,))
-
-
-# ---------------------------------------------------------
-# Surface API: The Formal Normalization Grammar
-# Tuples and Arrays are collections. Everything else is a scalar.
-# ---------------------------------------------------------
-
-# --- 1. Deterministic ---
-Grid(v::Tuple) = DeterministicGrid(v)
-Grid(v::AbstractArray) = DeterministicGrid(Tuple(v))
-Grid(v::Any) = DeterministicGrid((v,))
-
-# --- 2. Relative (v, p) ---
-Grid(v::Tuple, p::Tuple) = RelativeGrid(v, p)
-Grid(v::Tuple, p::AbstractArray) = RelativeGrid(v, Tuple(p))
-Grid(v::Tuple, p::Any) = RelativeGrid(v, (p,))
-
-Grid(v::AbstractArray, p::Tuple) = RelativeGrid(Tuple(v), p)
-Grid(v::AbstractArray, p::AbstractArray) = RelativeGrid(Tuple(v), Tuple(p))
-Grid(v::AbstractArray, p::Any) = RelativeGrid(Tuple(v), (p,))
-
-Grid(v::Any, p::Tuple) = RelativeGrid((v,), p)
-Grid(v::Any, p::AbstractArray) = RelativeGrid((v,), Tuple(p))
-Grid(v::Any, p::Any) = RelativeGrid((v,), (p,))
-
-# --- 3. Absolute (v, a) ---
-Grid(v::Tuple, a::AbsoluteError) = AbsoluteGrid(v, a.vals)
-Grid(v::AbstractArray, a::AbsoluteError) = AbsoluteGrid(Tuple(v), a.vals)
-Grid(v::Any, a::AbsoluteError) = AbsoluteGrid((v,), a.vals)
-
-# Pass-through for already built vaults
-Grid(g::Union{DeterministicGrid, RelativeGrid, AbsoluteGrid}) = g
-
-# ---------------------------------------------------------
-# The Iteration Protocol (Measurements Gangbang)
-# ---------------------------------------------------------
-
-# Deterministic
-@inline Base.iterate(g::DeterministicGrid, state...) = iterate(g.vals, state...)
-@inline Base.length(g::DeterministicGrid) = length(g.vals)
-Base.eltype(::Type{<:DeterministicGrid{V}}) where {V} = eltype(V)
-
-# Relative
-@inline function Base.iterate(g::RelativeGrid, state...)
- res = iterate(Iterators.product(g.vals, g.rel_err), state...)
- res === nothing && return nothing
- ((v, p), next_state) = res
- return measurement(v, abs(v) * (p / 100.0)), next_state
-end
-@inline Base.length(g::RelativeGrid) = length(g.vals) * length(g.rel_err)
-Base.eltype(::Type{<:RelativeGrid{V, P}}) where {V, P} =
- Measurement{promote_type(eltype(V), eltype(P))}
-
-# Absolute
-@inline function Base.iterate(g::AbsoluteGrid, state...)
- res = iterate(Iterators.product(g.vals, g.abs_err), state...)
- res === nothing && return nothing
- ((v, err), next_state) = res
- return measurement(v, abs(err)), next_state
-end
-@inline Base.length(g::AbsoluteGrid) = length(g.vals) * length(g.abs_err)
-Base.eltype(::Type{<:AbsoluteGrid{V, P}}) where {V, P} =
- Measurement{promote_type(eltype(V), eltype(P))}
-
-# ---------------------------------------------------------
-# Boundaries (For Lemonparty Solvers)
-# ---------------------------------------------------------
-@inline Base.extrema(g::DeterministicGrid) = (minimum(g.vals), maximum(g.vals))
-
-@inline function Base.extrema(g::RelativeGrid)
- v_min, v_max = minimum(g.vals), maximum(g.vals)
- p_max = maximum(abs, g.rel_err) / 100.0
- return (v_min * (1.0 - p_max), v_max * (1.0 + p_max))
-end
-
-@inline function Base.extrema(g::AbsoluteGrid)
- v_min, v_max = minimum(g.vals), maximum(g.vals)
- err_max = maximum(abs, g.abs_err)
- return (v_min - err_max, v_max + err_max)
-end
-
-# ---------------------------------------------------------
-# The Stochastic Sampler
-# ---------------------------------------------------------
-import Base: rand
-using Distributions
-
-@inline Base.rand(g::DeterministicGrid, ::Type{D}) where {D} = rand(g.vals)
-
-@inline function Base.rand(g::RelativeGrid, ::Type{D}) where {D}
- v, p = rand(g.vals), rand(g.rel_err)
- σ = abs(v) * (p / 100.0)
- σ == 0 && return float(v)
- return D <: Normal ? rand(Normal(v, σ)) : rand(Uniform(v - √3*σ, v + √3*σ))
-end
-
-@inline function Base.rand(g::AbsoluteGrid, ::Type{D}) where {D}
- v, σ = rand(g.vals), rand(g.abs_err)
- σ == 0 && return float(v)
- return D <: Normal ? rand(Normal(v, σ)) : rand(Uniform(v - √3*σ, v + √3*σ))
-end
-
-@inline Base.rand(
- g::Union{DeterministicGrid, RelativeGrid, AbsoluteGrid};
- dist::Type{D} = Normal,
-) where {D} = rand(g, D)
diff --git a/src/cablebuilder/gridspace.jl b/src/cablebuilder/gridspace.jl
deleted file mode 100644
index 26ced1bcc..000000000
--- a/src/cablebuilder/gridspace.jl
+++ /dev/null
@@ -1,67 +0,0 @@
-# ==============================================================================
-# THE GRIDSPACE ENGINE (Replaces AbstractSpec)
-# ==============================================================================
-
-# The Universal Staging Area.
-# Target is the strict `<: Real` physics struct. Args is the tuple of Grids/Scalars.
-struct Gridspace{Target, Args <: Tuple}
- grids::Args
-end
-
-Gridspace{Target}(grids::Args) where {Target, Args <: Tuple} =
- Gridspace{Target, Args}(grids)
-
-Grid(g::Gridspace) = g
-
-# ==============================================================================
-# THE THIN-ALLOCATION ITERATOR PROTOCOL
-# ==============================================================================
-
-# 1. The Initializer
-@inline function Base.iterate(g::Gridspace{Target}) where {Target}
- # Base.Iterators.ProductIterator consumes the tuple directly, preventing the SROA leak.
- iter = Base.Iterators.ProductIterator(g.grids)
- next = iterate(iter)
-
- next === nothing && return nothing
-
- args, state = next
- # Native splat into Target. Completely type-stable.
- return Target(args...), state
-end
-
-# 2. The Advancer
-@inline function Base.iterate(g::Gridspace{Target}, state) where {Target}
- iter = Base.Iterators.ProductIterator(g.grids)
- next = iterate(iter, state)
-
- next === nothing && return nothing
-
- args, new_state = next
- return Target(args...), new_state
-end
-
-# 3. Utilities (So the compiler knows exactly how big the loop is)
-Base.IteratorSize(::Type{<:Gridspace}) = Base.HasShape{1}()
-Base.length(g::Gridspace) = prod(length, g.grids)
-Base.size(g::Gridspace) = (length(g),)
-
-# ---------------------------------------------------------
-# The Stochastic Sampler
-# ---------------------------------------------------------
-import Base: rand
-using Distributions
-
-@inline function Base.rand(
- g::Gridspace{Target},
- ::Type{D},
-) where {Target, D <: ContinuousUnivariateDistribution}
- # Map distributes your existing grid.jl rand() over the tuple
- samples = map(grid -> rand(grid, D), g.grids)
- return Target(samples...)
-end
-
-@inline Base.rand(
- g::Gridspace;
- dist::Type{D} = Normal,
-) where {D <: ContinuousUnivariateDistribution} = rand(g, dist)
\ No newline at end of file
diff --git a/src/cablebuilder/helical.jl b/src/cablebuilder/helical.jl
deleted file mode 100644
index 096767e79..000000000
--- a/src/cablebuilder/helical.jl
+++ /dev/null
@@ -1,40 +0,0 @@
-# ==========================================
-# 1. THE VAULT (Fully resolved analytical geometry)
-# ==========================================
-struct HelicalPath{T <: Real, U <: Integer}
- ratio::T
- pitch::T
- angle::T
- overlength::T
- dir::U
-end
-
-function HelicalPath(ratio, pitch, angle, overlength, dir::U) where {U <: Integer}
- T = promote_type(typeof(ratio), typeof(pitch), typeof(angle), typeof(overlength))
- return HelicalPath{T, U}(
- convert(T, ratio), convert(T, pitch), convert(T, angle), convert(T, overlength), dir,
- )
-end
-
-# ==========================================
-# 2. THE BUILDERS (Materialize the math using mean_diam)
-# ==========================================
-struct LayRatioBuilder{T <: Real, U <: Integer}
- ratio::T
- dir::U
-end
-
-@inline function (b::LayRatioBuilder)(mean_diam::T) where {T <: Real}
- pitch = b.ratio * mean_diam
- angle = atan(pi * mean_diam / pitch)
- overlength = sqrt(1 + (pi * mean_diam / pitch)^2)
- return HelicalPath(b.ratio, pitch, angle, overlength, b.dir)
-end
-
-# ==========================================
-# 3. THE BLUEPRINTS (Specs for Combinatorics)
-# ==========================================
-struct LayRatioSpec{T, U} <: AbstractSpec{LayRatioBuilder}
- ratio::T
- dir::U
-end
\ No newline at end of file
diff --git a/src/cablebuilder/macros.jl b/src/cablebuilder/macros.jl
deleted file mode 100644
index c5ea1debb..000000000
--- a/src/cablebuilder/macros.jl
+++ /dev/null
@@ -1,226 +0,0 @@
-# ==============================================================================
-# THE AST JANITOR
-# Drills through blocks, escapes, and un-evaluated macrocalls to find and replace
-# the struct definition, ensuring multiple macros stack flawlessly.
-# ==============================================================================
-
-# Recursively strips out :escape nodes.
-function _strip_escapes(ex)
- if ex isa Expr && ex.head === :escape
- return _strip_escapes(ex.args[1])
- elseif ex isa Expr
- return Expr(ex.head, map(_strip_escapes, ex.args)...)
- else
- return ex
- end
-end
-
-# Drills into blocks and macrocalls to find the actual :struct node.
-function _get_struct_node(ex)
- ex isa Expr || return nothing
- ex.head === :struct && return ex
- for arg in ex.args
- node = _get_struct_node(arg)
- node !== nothing && return node
- end
- return nothing
-end
-
-# Replaces the old struct node with the new one, leaving macrocalls intact.
-function _replace_struct(ex, new_struct)
- if ex isa Expr && ex.head === :struct
- return new_struct
- elseif ex isa Expr
- return Expr(ex.head, map(arg -> _replace_struct(arg, new_struct), ex.args)...)
- else
- return ex
- end
-end
-
-# Universal field parser. Handles `field::T = default` for BOTH macros.
-function _parse_fields(struct_body)
- fields = Any[]
- clean_body_args = Any[]
-
- for arg in struct_body.args
- if arg isa LineNumberNode || arg isa String
- push!(clean_body_args, arg)
- continue
- end
-
- has_default = false
- local field_name, default_val, clean_field
-
- # Detect `field::T = default`
- if arg isa Expr && arg.head === :(=)
- has_default = true
- clean_field = arg.args[1]
- default_val = arg.args[2]
- else
- clean_field = arg
- end
-
- if clean_field isa Expr && clean_field.head === :(::)
- field_name = clean_field.args[1]
- push!(clean_body_args, clean_field)
- elseif clean_field isa Symbol
- field_name = clean_field
- push!(clean_body_args, clean_field)
- else
- # Inner constructors / garbage passthrough
- push!(clean_body_args, arg)
- continue
- end
-
- push!(
- fields,
- (
- name = field_name,
- has_default = has_default,
- default_val = has_default ? default_val : nothing,
- ),
- )
- end
- return fields, clean_body_args
-end
-
-# Rebuilds the AST block natively around whatever macrocalls are left.
-function _rebuild_ast(ex, new_struct, new_funcs)
- replaced = _replace_struct(ex, new_struct)
- if replaced isa Expr && replaced.head === :block
- return Expr(:block, replaced.args..., new_funcs...)
- else
- return Expr(:block, replaced, new_funcs...)
- end
-end
-
-function _extract_struct_name(struct_node)
- sig = struct_node.args[2]
- if sig isa Symbol
- return sig
- elseif sig.head === :(<:)
- left = sig.args[1]
- return left isa Symbol ? left : left.args[1]
- elseif sig.head === :curly
- return sig.args[1]
- else
- error("Malformed struct signature.")
- end
-end
-
-
-# ==============================================================================
-# MACRO 1: @gridspace (The Combinatorics Hook)
-# ==============================================================================
-macro gridspace(expr)
- raw_ast = _strip_escapes(expr)
- struct_node = _get_struct_node(raw_ast)
- struct_node === nothing && error("@gridspace must be applied to a struct.")
-
- struct_name = _extract_struct_name(struct_node)
- fields, clean_body_args = _parse_fields(struct_node.args[3])
-
- # We strictly enforce the removal of `= default` from the struct definition,
- # otherwise Julia will crash when it tries to compile the dumb struct.
- is_mutable = struct_node.args[1]
- struct_sig = struct_node.args[2]
- clean_struct = Expr(:struct, is_mutable, struct_sig, Expr(:block, clean_body_args...))
-
- kw_params = Any[]
- grid_calls = Any[]
- for f in fields
- if f.has_default
- push!(kw_params, Expr(:kw, f.name, f.default_val))
- else
- push!(kw_params, f.name)
- end
- push!(grid_calls, :(Grid($(f.name))))
- end
-
- params = Expr(:parameters, kw_params...)
- call_sig = Expr(:call, struct_name, params)
- grid_tuple = Expr(:tuple, grid_calls...)
-
- kw_func = Expr(:function, call_sig, quote
- return Gridspace{$struct_name}($grid_tuple)
- end)
-
- final_ast = _rebuild_ast(raw_ast, clean_struct, Any[kw_func])
- return esc(final_ast)
-end
-
-
-# ==============================================================================
-# MACRO 2: @relax (The Type Promoter & Converter)
-# ==============================================================================
-macro relax(expr)
- expr.head == :struct || error("@relax must be applied to a struct definition.")
-
- # 1. Parse signature and supertype
- sig = expr.args[2]
- super_type = nothing
- if sig isa Expr && sig.head == :<:
- super_type = sig.args[2]
- sig = sig.args[1]
- end
- struct_name = sig isa Expr && sig.head == :curly ? sig.args[1] : sig
-
- # 2. Extract fields
- fields = Symbol[]
- for arg in expr.args[3].args
- if arg isa Symbol
- push!(fields, arg)
- elseif arg isa Expr && arg.head == :(::)
- push!(fields, arg.args[1])
- end
- end
-
- # 3. Build AST components
- eltype_calls = [:(Base.eltype(typeof($(f)))) for f in fields]
- recasts = [:(recast(T_promo, $(f))) for f in fields]
- target_recasts = [:(recast(T_target, s.$(f))) for f in fields]
-
- # 4. Abstract Supertype Converter (Optional)
- abstract_convert = :()
- if super_type !== nothing && super_type isa Expr && super_type.head == :curly
- abstract_name = super_type.args[1]
- abstract_convert = quote
- @inline function Base.convert(
- ::Type{<:$(abstract_name){T_target}},
- s::$(struct_name),
- ) where {T_target <: Real}
- return $(struct_name)($(target_recasts...))
- end
- end
- end
-
- # 5. Emit
- return esc(
- quote
- $expr # The original struct
-
- # The Untyped Outer Constructor
- @inline function $struct_name($(fields...))
- T_promo = promote_type($(eltype_calls...))
- return $struct_name($(recasts...))
- end
-
- # The Concrete Converter
- @inline function Base.convert(
- ::Type{<:$struct_name{T_target}},
- s::$struct_name,
- ) where {T_target <: Real}
- return $struct_name($(target_recasts...))
- end
-
- # The eltype Hook
- @inline Base.eltype(::Type{<:$struct_name{T}}) where {T} = T
-
- # THE NEW RECAST HOOK FOR THIS CONCRETE STRUCT
- @inline recast(::Type{T_target}, s::$struct_name) where {T_target <: Real} =
- $struct_name($(target_recasts...))
-
- $abstract_convert
- end,
- )
-end
\ No newline at end of file
diff --git a/src/cablebuilder/materials.jl b/src/cablebuilder/materials.jl
deleted file mode 100644
index fcadf291a..000000000
--- a/src/cablebuilder/materials.jl
+++ /dev/null
@@ -1,20 +0,0 @@
-@gridspace @relax struct Material{T <: Real}
- "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 = 20.0
- "Temperature coefficient of resistivity \\[1/°C\\]."
- alpha::T = 0.0
- "Thermal resistivity \\[K·m/W\\]."
- rho_thermal::T = 0.0
- "Maximum operating temperature \\[°C\\]."
- theta_max::T = 90.0
- "Dielectric loss factor \\[dimensionless\\]."
- tan_delta::T = 0.0
- "Solar absorption coefficient \\[dimensionless\\]."
- sigma_solar::T = 0.0
-end
\ No newline at end of file
diff --git a/src/cablebuilder/partbuilder.jl b/src/cablebuilder/partbuilder.jl
deleted file mode 100644
index 69cb9a516..000000000
--- a/src/cablebuilder/partbuilder.jl
+++ /dev/null
@@ -1,110 +0,0 @@
-# ==========================================
-# THE VALIDATION BOUNDARY
-# ==========================================
-# If a specific part doesn't define topological rules, it passes.
-@inline validate(part::AbstractCablePart) = part
-
-# ==========================================
-# THE ATOMIC BUILDER (Physics & Intrinsic Geometry)
-# ==========================================
-struct PartBuilder{Target, Shape, P <: Tuple}
- cmp::Symbol
- payload::P
-end
-
-# THE CONSTRUCTOR (The Zero-Alloc Val Interceptor)
-@inline function PartBuilder(
- ::Val{Target}, ::Val{Shape}, cmp::Symbol, payload...,
-) where {Target, Shape}
- return PartBuilder{Target, Shape, typeof(payload)}(cmp, payload)
-end
-
-# THE FUNCTOR (The Spatial Collapse - Restored)
-@inline function (b::PartBuilder{Target, Shape})(
- prev_bound::AbstractShapeParams,
-) where {Target, Shape}
- # Pure coaxial materialization. Zero 2D awareness. We receive the
- # absolute geometric boundary and pass it to the shape math.
- part = build_part(Target, Shape, b.cmp, prev_bound, b.payload)
- return validate(part)
-end
-
-# ==========================================
-# THE DSL HOOK
-# ==========================================
-@inline function Builder(
- ::Type{Target}, ::Type{Shape}, cmp::Symbol, args...,
-) where {Target, Shape}
- grids = (
- Grid(Val{Target}()),
- Grid(Val{Shape}()),
- Grid(cmp),
- map(Grid, args)...,
- )
- return Gridspace{PartBuilder}(grids)
-end
-
-# ==========================================
-# THE GROUP BUILDER (Topology & Layout)
-# ==========================================
-struct GroupBuilder{P <: Tuple}
- payload::P
-end
-
-@inline function GroupBuilder(::Val{PartGroup}, payload...)
- return GroupBuilder{typeof(payload)}(payload)
-end
-
-@inline function (b::GroupBuilder)(prev_bound::Circular)
- origin = b.payload[1]
- n = b.payload[2]
- m = b.payload[3]
-
- inner_builders = Base.tail(Base.tail(Base.tail(b.payload)))
-
- # 1. Local coaxial stacking
- T_local = typeof(prev_bound.r)
- local_parts = build_design(Circular(zero(T_local)), inner_builders)
- local_r_ex = r_ex(local_parts[end])
-
- # 2. Global topological translation
- ox, oy = origin
- layout_r = sqrt(ox^2 + oy^2)
- r_prev = prev_bound.r
- bound_r_in = r_prev
-
- # Outer envelope calculation:
- # `layout_r` is the center of the first layer of cores.
- # Each additional `m` layer adds roughly `2 * local_r_ex` to the bounding center.
- # The outer boundary adds one final `local_r_ex` to clear the outermost core.
- envelope_r = layout_r + ((2 * m) - 1) * local_r_ex
-
- bound_r_ex = max(r_prev, envelope_r)
-
- T = promote_type(typeof(bound_r_in), typeof(bound_r_ex), typeof(ox), typeof(oy))
-
- # 3. Emit the anonymous topological folder
- part = PartGroup(
- convert(T, bound_r_in),
- convert(T, bound_r_ex),
- (convert(T, ox), convert(T, oy)),
- n, m,
- local_parts,
- )
-
- return validate(part)
-end
-
-@inline function Builder(
- ::Type{PartGroup}, origin, n, m, layers::Tuple,
-)
- grids = (
- Grid(Val{PartGroup}()),
- Grid((origin,)),
- Grid(n),
- Grid(m),
- layers...,
- )
-
- return Gridspace{GroupBuilder}(grids)
-end
diff --git a/src/cablebuilder/primitives.jl b/src/cablebuilder/primitives.jl
deleted file mode 100644
index f841bf887..000000000
--- a/src/cablebuilder/primitives.jl
+++ /dev/null
@@ -1,38 +0,0 @@
-# Shape params define the shape of a primitive, but not its material, group or location/layout.
-abstract type AbstractShapeParams{T <: Real} end
-
-# If a specific payload vault doesn't define intrinsic rules, it passes.
-@inline validate(params::AbstractShapeParams) = params
-
-@gridspace @relax struct Circular{T <: Real} <: AbstractShapeParams{T}
- r::T
-end
-
-@inline function validate(p::Circular)
- p.r > zero(p.r) || throw(DomainError(p.r, "Circular radius must be strictly positive."))
- return p
-end
-
-@gridspace @relax struct Rectangular{T <: Real} <: AbstractShapeParams{T}
- w::T
- h::T
-end
-
-@inline function validate(p::Rectangular)
- p.w > zero(p.w) ||
- throw(DomainError(p.w, "Rectangular width must be strictly positive."))
- p.h > zero(p.h) ||
- throw(DomainError(p.h, "Rectangular height must be strictly positive."))
- return p
-end
-
-@gridspace @relax struct Annular{T <: Real} <: AbstractShapeParams{T}
- t::T
-end
-
-@inline function validate(p::Annular)
- p.t > zero(p.t) ||
- throw(DomainError(p.t, "Annular thickness must be strictly positive."))
- return p
-end
-
diff --git a/src/cablebuilder/runme.jl b/src/cablebuilder/runme.jl
deleted file mode 100644
index 580742370..000000000
--- a/src/cablebuilder/runme.jl
+++ /dev/null
@@ -1,157 +0,0 @@
-using Revise
-include("CableBuilder.jl")
-using .CableBuilder
-import .CableBuilder: CableDesign
-
-using BenchmarkTools
-
-# 1. Define a dummy stochastic Material
-# 2 variations of resistivity
-mat = Material(
- rho = Grid((1.6e-8, 1.7e-8)),
- eps_r = 1.0, mu_r = 1.0, T0 = 20.0, alpha = 0.0,
- rho_thermal = 0.0, theta_max = 90.0, tan_delta = 0.0, sigma_solar = 0.0,
-)
-
-# 2. Define the Solid Core wrapping the Circle primitive
-# 5 variations of radius
-core = (
- Conductor.Solid(:core, mat; r = Grid((0.01, 0.02, 0.03, 0.04, 0.05))),
- Conductor.Tubular(:sheath, mat; t = Grid((0.01, 0.02, 0.03, 0.04, 0.05))),
- Insulator.Tubular(:sheath, mat; t = Grid((0.05))),
-)
-
-# 3. Wrap it in the Design Blueprint
-spec = CableDesign(core)
-
-# 4. The strict function barrier
-# This forces the compiler to optimize the loop exactly as it would in production
-function exhaust_generator(s)
- count = 0
- for design in s
- # 'design' is fully materialized right here!
- # If the primitives, builders, or tuple recursion leak memory,
- # it will pile up on the heap inside this loop.
- count += 1
- end
- return count
-end
-
-# Warmup to compile
-exhaust_generator(spec)
-
-# The moment of truth
-println("--- Combinatorial Allocation Test ---")
-@btime exhaust_generator($spec)
-
-des=first(spec, length(spec))
-# ms_cu = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393, 0.0, 0.0, 0.0, 0.0)
-# ms_vac = Material(Inf, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
-
-# parts = (
-# Conductor.Solid(:core, ms_cu; r = 0.02315),
-# Conductor.Tubular(:sheath, ms_cu; t = 0.005),
-# Conductor.Pipe(:pipe, ms_cu; t = 0.005, filler = ms_vac, offset = 0.01),
-# # Conductor.Stranded(:sheath, ms_cu; r_w = 0.005, n_w = 3, lay_r = 0.01),
-# )
-
-# # Flawless compilation
-# cds = CableDesignSpec(parts)
-
-# ast = first(cds)
-# println(ast.payload)
-
-
-# 1. Define the Blueprint (The User DSL)
-# Notice we mix deterministic scalars, a relative sweep, and an absolute sweep
-# copper_spec = Material(;
-# rho = Grid(1.68e-8:0.01e-8:1.72e-8, 2.0), # 2% manufacturing tolerance
-# eps_r = 1.0, # Auto-promoted to DeterministicGrid
-# mu_r = 1.0,
-# T0 = Grid(20.0:10.0:90.0, AbsoluteError(2.0)), # ± 2.0°C absolute sensor error
-# )
-
-# @show typeof(copper_spec)
-# @code_warntype rand(copper_spec)
-
-# @show @allocated rand(copper_spec)
-# @btime @allocated rand($copper_spec)
-
-
-# # ---------------------------------------------------------
-# # Benchmark 1: The Array Comprehension (User Snippet)
-# # ---------------------------------------------------------
-# println("--- Benchmarking 100000 Monte Carlo Realizations (Array Allocation) ---")
-# @btime mc_materials = [rand($copper_spec) for _ in 1:100000]
-
-# # @allocated(rand(copper_spec)) = 80
-# # 27.698 ns (0 allocations: 0 bytes)
-# # --- Benchmarking 100000 Monte Carlo Realizations (Array Allocation) ---
-# # 2.084 ms (3 allocations: 6.87 MiB)
-# #
-
-# # ---------------------------------------------------------
-# # Benchmark 2: The Bare-Metal Loop (Zero-Allocation Target)
-# # ---------------------------------------------------------
-# # This tests the pure speed of your ntuple/Grid/rand architecture
-# # without the overhead of Julia allocating a 1000-element Vector
-# function pure_monte_carlo_loop(spec, N)
-# # We just draw the sample and discard it to test the engine's raw speed
-# for _ in 1:N
-# m = rand(spec)
-# end
-# return nothing
-# end
-
-# println("\n--- Benchmarking 1000 Monte Carlo Realizations (Engine Only) ---")
-# @btime pure_monte_carlo_loop($copper_spec, 1000)
-
-
-# # ---------------------------------------------------------
-# # Test 1: The Deterministic Single Build
-# # ---------------------------------------------------------
-# println("--- Benchmarking Single Deterministic Build ---")
-# # We use $ to interpolate the variable into the macro so it doesn't benchmark global scope lookup
-# @btime first($cds)
-
-# # ---------------------------------------------------------
-# # Test 2: The Combinatorial Iterator (The Real Test)
-# # ---------------------------------------------------------
-# grid_parts = (
-# Conductor.Solid(:core, ms_cu; r = Grid([0.02, 0.025, 0.03])), # 3 variations
-# Conductor.Tubular(:sheath, ms_cu; t = Grid([0.004, 0.005, 0.006, 0.007])), # 4 variations
-# Conductor.Pipe(
-# :pipe,
-# ms_cu;
-# t = Grid([0.004, 0.005, 0.006, 0.007]),
-# filler = ms_vac,
-# offset = Grid([0.004, 0.005, 0.006, 0.007]),
-# ), # 4 variations
-# )
-# grid_cds = CableDesignSpec(grid_parts)
-# println("\n--- Benchmarking 1-Design Materialization from Grids ---")
-# @btime first($grid_cds)
-
-# # A function barrier to test the loop exactly how your solver will use it
-# function exhaust_generator(spec)
-# count = 0
-# for design in spec
-# # The design is materialized here.
-# # If the compiler is happy, this loop will allocate ZERO memory.
-# count += 1
-# end
-# return count
-# end
-
-# println("\n--- Benchmarking Combinatorial Sweep ---")
-# @btime exhaust_generator($grid_cds)
-
-# function alloc_per_design(spec, n)
-# s = Iterators.take(spec, n)
-# a = @allocated for x in s
-# nothing
-# end
-# return a / n
-# end
-# println("\n--- Alloc per design ---")
-# @show alloc_per_design(grid_cds, length(grid_cds))
diff --git a/src/cablebuilder/shapes.jl b/src/cablebuilder/shapes.jl
deleted file mode 100644
index e499451cf..000000000
--- a/src/cablebuilder/shapes.jl
+++ /dev/null
@@ -1,38 +0,0 @@
-# Global accessors
-@inline r_in(s::AbstractShape) = s.r_in
-@inline r_ex(s::AbstractShape) = s.r_ex
-
-
-
-include("solidcore.jl")
-include("tubular.jl")
-# include("enclosure.jl")
-# include("wires.jl")
-# include("helical.jl")
-# include("stranded.jl")
-
-# ---------------------------------------------------------
-# The Fuzzy Characteristic Length Trait
-# ---------------------------------------------------------
-# Returns the characteristic dimension of the primitive.
-# If someone writes a Rectangle primitive and defines char_len as the diagonal,
-# the stacking engine will blindly build overlapping garbage.
-@inline char_len(s::SolidCore) = 2 * r_ex(s)
-# @inline char_len(s::TubularLayer) = r_ex(s) - r_in(s)
-# @inline char_len(s::CircularWire) = 2 * s.r
-# @inline char_len(s::RectangularWire) = s.h
-
-# ==========================================
-# THE TOPOLOGICAL FOOTPRINT
-# Extracts the absolute boundary as a concrete primitive.
-# ==========================================
-@inline boundary(p::AbstractCablePart) = boundary(p.shape)
-
-# A SolidCore's footprint is a circle at r_ex.
-@inline boundary(s::SolidCore) = Circular(r_ex(s))
-
-# A Tubular's footprint is a circle at r_ex.
-@inline boundary(s::TubularLayer) = Circular(r_ex(s))
-
-# A PartGroup's bounding footprint is currently a circumscribed circle.
-@inline boundary(g::PartGroup) = Circular(r_ex(g))
\ No newline at end of file
diff --git a/src/cablebuilder/solidcore.jl b/src/cablebuilder/solidcore.jl
deleted file mode 100644
index 81dc4da5f..000000000
--- a/src/cablebuilder/solidcore.jl
+++ /dev/null
@@ -1,58 +0,0 @@
-# ==========================================
-# 1. THE VAULT
-# ==========================================
-# Just strictly holds the universal boundaries.
-@relax struct SolidCore{T <: Real, P <: AbstractShapeParams{T}} <: AbstractShape{T}
- r_in::T
- r_ex::T
- params::P
-end
-
-@inline function validate(part::ConductorPart{T, <:SolidCore}) where {T}
- shape = part.shape
-
- # Cascade to intrinsic validation
- validate(shape.params)
-
- # Topological bounds checks
- shape.r_in == zero(T) || throw(
- DomainError(
- shape.r_in, "Topological violation: SolidCore MUST start exactly at r=0.",
- ),
- )
-
- shape.r_ex > shape.r_in || throw(
- DomainError(
- shape.r_ex,
- "Physics violation: Outer radius ($(shape.r_ex)) must be > inner radius ($(shape.r_in)).",
- ),
- )
-
- return part
-end
-
-# ==========================================
-# 2. THE FUNCTOR SPECIALIZATION (Spatial Collapse)
-# ==========================================
-@inline function build_part(
- ::Type{Target},
- ::Type{SolidCore},
- grp::Symbol,
- prev_bound::Circular{T}, # <-- Dispatches on the primitive
- payload::Tuple{M, C},
-) where {Target, T <: Real, M <: Material, C <: Circular}
-
- mat, params = payload
-
- prev_bound.r <= eps(T) ||
- throw(
- DomainError(
- prev_bound.r,
- "Topological violation: SolidCore must start at r=0.",
- ),
- )
-
- shape = SolidCore(prev_bound.r, params.r, params)
- return Target(grp, shape, mat)
-end
-
diff --git a/src/cablebuilder/stranded.jl b/src/cablebuilder/stranded.jl
deleted file mode 100644
index 15a30921d..000000000
--- a/src/cablebuilder/stranded.jl
+++ /dev/null
@@ -1,93 +0,0 @@
-# ==========================================
-# 1. THE UNIVERSAL VAULT
-# ==========================================
-struct StrandedLayer{
- L,
- T <: Real,
- U <: Integer,
- P <: AbstractWire{T},
- H <: HelicalPath,
-} <: AbstractShape{T}
- r_in::T
- r_ex::T
- n_w::U
- wire::P
- pitch::H
-end
-
-function StrandedLayer(
- r_in,
- r_ex,
- n_w::Integer,
- wire::AbstractWire,
- pitch::HelicalPath,
-)
- T = promote_type(typeof(r_in), typeof(r_ex))
- p = convert(AbstractWire{T}, wire)
- return StrandedLayer{T, typeof(n_w), typeof(p), typeof(pitch)}(
- convert(T, r_in), convert(T, r_ex), n_w, p, pitch,
- )
-end
-
-function Base.convert(
- ::Type{<:AbstractShape{T}},
- s::StrandedLayer,
-) where {T <: Real}
- p_converted = convert(AbstractWire{T}, s.wire)
- return StrandedLayer{T, typeof(s.n_w), typeof(p_converted), typeof(s.pitch)}(
- convert(T, s.r_in), convert(T, s.r_ex), s.n_w, p_converted, s.pitch,
- )
-end
-
-# ==========================================
-# 2. THE UNIVERSAL BUILDER
-# ==========================================
-struct StrandedBuilder{P, U <: Integer, W, H, T <: Real}
- cmp::Symbol
- n_w::U
- wire_builder::W
- pitch_builder::H
- mat::Material{T}
-end
-
-@inline function (b::StrandedBuilder{P})(current_r::T) where {P, T <: Real}
- # If someone tries to put a stranded armor at the exact center of the universe, mock them.
- current_r <= zero(T) && error(
- "Topological violation: Stranded layers cannot exist at r=0. Use a SolidCore.",
- )
-
- # 1. Materialize the physical entity
- wire = b.wire_builder()
-
- # 2. Extract its radial footprint via dispatch
- thick = char_len(wire)
-
- r_ex = current_r + thick
- mean_diam = current_r + (thick / 2)
-
- # 3. Pass context to the nested helical builder
- pitch_profile = b.pitch_builder(mean_diam)
-
- # 4. Lock it into the unified layer
- shape = StrandedLayer(current_r, r_ex, b.n_w, wire, pitch_profile)
-
- return P(b.cmp, shape, b.mat)
-end
-
-# ==========================================
-# 3. THE UNIVERSAL BLUEPRINT
-# ==========================================
-struct StrandedSpec{
- P,
- G,
- U,
- W <: AbstractSpec,
- H <: AbstractSpec,
- M <: AbstractSpec{Material},
-} <: AbstractSpec{StrandedBuilder{P}}
- cmp::G
- n_w::U
- wire_spec::W
- pitch_spec::H
- mat::M
-end
\ No newline at end of file
diff --git a/src/cablebuilder/tubular.jl b/src/cablebuilder/tubular.jl
deleted file mode 100644
index 18dbd6a42..000000000
--- a/src/cablebuilder/tubular.jl
+++ /dev/null
@@ -1,56 +0,0 @@
-# ==========================================
-# THE VAULT
-# ==========================================
-@relax struct TubularLayer{T <: Real, P <: AbstractShapeParams{T}} <: AbstractShape{T}
- r_in::T
- r_ex::T
- params::P
-end
-
-# We use a Union to safely catch both Conductors and Insulators
-# without introducing dynamic `isa` checks or type-pirating the base AbstractCablePart.
-@inline function validate(
- part::Union{ConductorPart{T, <:TubularLayer}, InsulatorPart{T, <:TubularLayer}},
-) where {T}
- shape = part.shape
-
- # Cascade to intrinsic validation (checks t > 0)
- validate(shape.params)
-
- # Topological bounds check
- shape.r_ex > shape.r_in || throw(
- DomainError(
- shape.r_ex,
- "Physics violation: TubularLayer outer radius ($(shape.r_ex)) must be strictly greater than inner radius ($(shape.r_in)).",
- ),
- )
-
- return part
-end
-
-
-# ==========================================
-# THE FUNCTOR SPECIALIZATION (Spatial Collapse)
-# ==========================================
-@inline function build_part(
- ::Type{Target},
- ::Type{TubularLayer},
- cmp::Symbol,
- prev_bound::Circular{T},
- payload::Tuple{M, A},
-) where {Target, T <: Real, M <: Material, A <: Annular}
-
- mat, params = payload
-
- # Conformal anchor: The tube strictly wraps the inner circular boundary.
- r_in = prev_bound.r
-
- # Extrusion: Expand by the intrinsic payload thickness.
- r_ex = r_in + params.t
-
- # Collapse the shape geometry
- shape = TubularLayer(r_in, r_ex, params)
-
- # Emit the atomic physics part (Zero 2D awareness here)
- return Target(cmp, shape, mat)
-end
\ No newline at end of file
diff --git a/src/cablebuilder/types.jl b/src/cablebuilder/types.jl
deleted file mode 100644
index 93b560c3a..000000000
--- a/src/cablebuilder/types.jl
+++ /dev/null
@@ -1,65 +0,0 @@
-abstract type AbstractShape{T <: Real} end
-abstract type AbstractCablePart end
-
-@inline r_ex(p::AbstractCablePart) = r_ex(p.shape)
-@inline r_in(p::AbstractCablePart) = r_in(p.shape)
-
-struct ConductorPart{T, S <: AbstractShape{T}} <: AbstractCablePart
- cmp::Symbol
- shape::S
- material::Material{T}
-end
-
-@inline function ConductorPart(
- cmp::Symbol,
- shape::AbstractShape{S},
- mat::Material{M},
-) where {S <: Real, M <: Real}
- T = promote_type(S, M)
- s = convert(AbstractShape{T}, shape)
- m = convert(Material{T}, mat)
-
- return ConductorPart{T, typeof(s)}(cmp, s, m)
-end
-
-struct InsulatorPart{T, S <: AbstractShape{T}} <: AbstractCablePart
- cmp::Symbol
- shape::S
- material::Material{T}
-end
-
-@inline function InsulatorPart(
- cmp::Symbol,
- shape::AbstractShape{S},
- mat::Material{M},
-) where {S <: Real, M <: Real}
- T = promote_type(S, M)
- s = convert(AbstractShape{T}, shape)
- m = convert(Material{T}, mat)
-
- return InsulatorPart{T, typeof(s)}(cmp, s, m)
-end
-
-# ==========================================
-# THE TOPOLOGICAL VAULT
-# ==========================================
-struct PartGroup{T <: Real, P <: Tuple} <: AbstractCablePart
- r_in::T
- r_ex::T
- origin::Tuple{T, T}
- n::Int
- m::Int
- parts::P
-end
-
-@inline r_ex(g::PartGroup) = g.r_ex
-@inline r_in(g::PartGroup) = g.r_in
-
-# ---------------------------------------------------------
-# THE GLOBAL RECAST FALLBACKS
-# ---------------------------------------------------------
-# 1. Reals get standard numeric conversion
-@inline recast(::Type{T}, x::Real) where {T} = convert(T, x)
-
-# 2. Everything else (Symbols, Bools, Strings) is ignored and passed through safely
-@inline recast(::Type{T}, x) where {T} = x
\ No newline at end of file
diff --git a/src/commons/Commons.jl b/src/commons/Commons.jl
index 780fb2e30..205c1cfcd 100644
--- a/src/commons/Commons.jl
+++ b/src/commons/Commons.jl
@@ -1,25 +1,82 @@
-module Commons
+"""
+ LineCableModels.Commons
-include("docstringextension.jl")
-include("consts.jl")
+Define computation supertypes and functions shared by Engine,
+ParametricBuilder, UQ, and external implementations.
+# Physical constants
-export get_description, add!, domain, LineParamsDomain, PhaseDomain, ModalDomain
+- `vacuum_permittivity` and `vacuum_permeability` return the vacuum constants in
+ a requested scalar type.
-function get_description end
+# Matrix reductions
-function add! end
+- `ReductionPlan` fixes the terminal reorder, bundle merge, Kron elimination and
+ ideal transposition of primitive line matrices.
+- `reduce_line_matrices!` applies a plan to one frequency and inverts the potential
+ coefficients to the shunt admittance, in `ReductionBuffers`.
-abstract type LineParamsDomain end
-struct PhaseDomain <: LineParamsDomain end
-struct ModalDomain <: LineParamsDomain end
+# Public actions
+- `formulation_options` and `computation_options` normalize owner-specific options.
+- `formulas` lists the identifiers that a formula family registers.
+- `initialize_buffers` builds the reusable arrays of a computation into its buffer
+ record, one method per formula or shared component.
+- `computation_details` normalizes supplemental output from a registered
+ computation owner, and `details` reads retained supplemental output.
+- `validate` checks a materialized input before its consumer uses it and returns it
+ unchanged.
+- `compute` evaluates a problem through a selected formulation.
+- `observe` and `@observe` read native numerical values from completed results.
+- `observables` publishes explicitly requested scientific values.
+- `validate_observables` and `unit_targets` align publication requests and
+ display units for presentation consumers.
"""
-Return the domain tag type for objects that carry one.
+module Commons
-Fallback returns `nothing` for domainless objects.
-"""
-@inline domain(::Type) = nothing
-@inline domain(x) = domain(typeof(x))
+export AbstractProblemDefinition, AbstractFormulation, AbstractProblemResult
+export AbstractCoreResult, AbstractResultSpace
+export AbstractParametricResult, AbstractUncertaintyResult
+export FormulationOptions, ComputationOptions, ComputationDetails
+export formulation_options, computation_options, computation_details, details
+export compute, observe, @observe, observables, validate
+export nominal, uncertainty
+
+using DocStringExtensions: SIGNATURES, TYPEDSIGNATURES, TYPEDEF, TYPEDFIELDS
+using RequiredInterfaces: @required
+import ..LineCableModels: basis
+import UUIDs
+import Random
+import ..Units
+using LinearAlgebra: I, axpy!, checksquare, cond, diag, ldiv!, lu!, mul!, norm
+using ..Units: UnitExpr, quantity, native_unit, display_unit, scale_factor
+
+include("consts.jl")
+include("matrixops.jl")
+include("types.jl")
+include("base.jl")
+include("results.jl")
+include("interfaces.jl")
+include("formulas.jl")
+include("observables.jl")
+include("uncertainty.jl")
+include("gridpoint.jl")
+include("observed_validation.jl")
+include("observedresult.jl")
+include("retained_products.jl")
-end
\ No newline at end of file
+public vacuum_permittivity, vacuum_permeability
+public ideal_transposition!, reorder_indices, kron_reduce, kron_reduce!,
+ bundle_operations, merge_bundles!
+public ReductionPlan, ReductionBuffers, reduce_line_matrices!, initialize_buffers
+public FormulaDefinition, Expression, Functor, formulas
+public validate_observables, unit_targets, detach
+public observation_request, observation_indices, materialize_observation
+public observation_resolution
+public input_fields, observation_gridpoint, observation_requests, observation_quantity
+export ObservedResult
+public observation_groups, observation_labels, observation_product, gridpoint_id
+public observation_selection
+public request_identity, request_quantity, request_indices
+public normalize_observation_selector
+end # module Commons
diff --git a/src/commons/base.jl b/src/commons/base.jl
new file mode 100644
index 000000000..2303d1c38
--- /dev/null
+++ b/src/commons/base.jl
@@ -0,0 +1,30 @@
+# Records have structural value semantics, not a NamedTuple collection interface.
+Base.:(==)(a::FormulationOptions, b::FormulationOptions) = a.data == b.data
+Base.:(==)(a::ComputationOptions, b::ComputationOptions) = a.data == b.data
+Base.:(==)(a::ComputationDetails, b::ComputationDetails) = a.data == b.data
+
+Base.isequal(a::FormulationOptions, b::FormulationOptions) = isequal(a.data, b.data)
+Base.isequal(a::ComputationOptions, b::ComputationOptions) = isequal(a.data, b.data)
+Base.isequal(a::ComputationDetails, b::ComputationDetails) = isequal(a.data, b.data)
+
+Base.hash(value::FormulationOptions, seed::UInt) = hash(value.data, hash(:FormulationOptions, seed))
+Base.hash(value::ComputationOptions, seed::UInt) = hash(value.data, hash(:ComputationOptions, seed))
+Base.hash(value::ComputationDetails, seed::UInt) = hash(value.data, hash(:ComputationDetails, seed))
+
+function Base.show(io::IO, value::FormulationOptions)
+ print(io, "FormulationOptions(")
+ show(io, value.data)
+ print(io, ')')
+end
+
+function Base.show(io::IO, value::ComputationOptions)
+ print(io, "ComputationOptions(")
+ show(io, value.data)
+ print(io, ')')
+end
+
+function Base.show(io::IO, value::ComputationDetails)
+ print(io, "ComputationDetails(")
+ show(io, value.data)
+ print(io, ')')
+end
diff --git a/src/commons/consts.jl b/src/commons/consts.jl
index c58b7b1df..25347eb57 100644
--- a/src/commons/consts.jl
+++ b/src/commons/consts.jl
@@ -1,26 +1,20 @@
-# Export public API
-export f₀, μ₀, ε₀, ρ₀, T₀, TOL, ΔTmax
-export BASE_FLOAT, REALSCALAR, COMPLEXSCALAR
+"""
+$(TYPEDSIGNATURES)
-# 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
+Return the vacuum permittivity ``\\varepsilon_0 = 8.8541878128 \\times 10^{-12}``
+\\[F/m\\] in the scalar type `T`.
-# 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}}}
+The integer mantissa and the power of ten are evaluated in `T`. The value is exact
+for rational `T` and has the precision of `T` otherwise.
+"""
+vacuum_permittivity(::Type{T}) where {T <: Real} = one(T) * 88541878128 * (one(T) * 10)^(-22)
+"""
+$(TYPEDSIGNATURES)
+
+Return the vacuum permeability ``\\mu_0 = 4\\pi \\times 10^{-7}`` \\[H/m\\] in the scalar
+type `T`.
+
+The factors are evaluated in `T`. The value has the precision of `T`.
+"""
+vacuum_permeability(::Type{T}) where {T <: Real} = one(T) * 4 * (one(T) * π) * (one(T) * 10)^(-7)
diff --git a/src/commons/docstringextension.jl b/src/commons/docstringextension.jl
deleted file mode 100644
index 82b7ba687..000000000
--- a/src/commons/docstringextension.jl
+++ /dev/null
@@ -1,54 +0,0 @@
-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/commons/formulas.jl b/src/commons/formulas.jl
new file mode 100644
index 000000000..6555c6969
--- /dev/null
+++ b/src/commons/formulas.jl
@@ -0,0 +1,145 @@
+"""
+Declare formulation-option defaults for one expression.
+"""
+function formulation_options(expression::Expression)
+ throw(ArgumentError("missing formulation-option defaults for $expression"))
+end
+
+function formulation_options(expression::Expression, supplied::FormulationOptions)
+ return formulation_options(expression, formulation_options(expression), supplied)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Normalize supplied formulation options against defaults declared by the actual
+selected expression. A family with multiple cases projects supplied options to
+each consuming expression before calling this constructor. Empty defaults exclude options. Each option's dispatched
+normalizer defines its value type, including scalar physical choices and structured
+numerical controls.
+"""
+function formulation_options(expression::Expression, defaults::FormulationOptions, supplied::FormulationOptions)
+ default_data, supplied_data = defaults.data, supplied.data
+ unknown = filter(name -> !haskey(default_data, name), keys(supplied_data))
+ isempty(unknown) || throw(ArgumentError(
+ "unused formulation options $(Tuple(unknown)) for $expression"))
+ sections = map(keys(default_data)) do name
+ default = getproperty(default_data, name)
+ explicit = get(supplied_data, name, default isa NamedTuple ? (;) : default)
+ formulation_options(expression, Val(name), default, explicit)
+ end
+ return FormulationOptions(NamedTuple{keys(default_data)}(sections))
+end
+
+function formulation_options(expression::Expression, ::Val{Section}, defaults,
+ supplied) where {Section}
+ throw(ArgumentError("no formulation-option constructor for :$Section of $expression with $(typeof(supplied))"))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Project the options supplied to `formula` onto the expressions it declares. Each supplied
+section must be consumed by at least one expression. Each distinct expression receives the
+sections its defaults declare, normalized by its own constructor. Return the distinct
+expressions in order of first appearance and their `FormulationOptions`, as
+`(expressions, options)`.
+"""
+function formulation_options(formula::AbstractFormulation,
+ expressions::Union{Tuple, AbstractVector{<:Expression}})
+ supplied = formula.options.data
+ identities = unique(expressions)
+ defaults = map(formulation_options, identities)
+ admitted = union((keys(value.data) for value in defaults)...)
+ unknown = setdiff(keys(supplied), admitted)
+ isempty(unknown) || throw(ArgumentError(
+ "unused formulation options $(Tuple(unknown)) for :$(formula_id(formula))"))
+ normalized = map(eachindex(identities)) do index
+ declared = defaults[index]
+ names = Tuple(intersect(keys(supplied), keys(declared.data)))
+ formulation_options(identities[index], declared, FormulationOptions(supplied[names]))
+ end
+ return (expressions = identities, options = normalized)
+end
+
+"""
+ formulas(family)
+
+Return the identifiers that a formula family registers, in registration order. `family` is
+the family's `Formula` type, such as `EarthImpedance.Formula`. Each family adds one
+method on its own `Formula` type.
+"""
+function formulas end
+
+import ..LineCableModels: description, formula_id
+
+"""
+$(TYPEDSIGNATURES)
+
+Check that the operation of `expression` has a method for its formula and selectors, before
+any evaluation. The operation takes a [`Functor`](@ref) and a workspace after the selectors,
+as the evaluation passes them. A narrower method for either argument counts. A backend
+variant, which takes the backend in their place, does not. Return `expression`.
+
+# Errors
+
+- Throws `ArgumentError` naming the formula and the selectors that have no expression.
+"""
+function validate(expression::Expression)
+ signature = Tuple{typeof(expression.selection), map(typeof, expression.arguments)...,
+ Functor, Any}
+ hasmethod(expression.method, signature) && return expression
+ isempty(methods(expression.method, signature)) || return expression
+ value(::Val{X}) where {X} = X
+ throw(ArgumentError("formula :$(formula_id(expression.selection)) has no expression for " *
+ join(map(selector -> repr(value(selector)), expression.arguments), ", ")))
+end
+
+"""Read a declaration's actual formulation-owned inputs without resolving them."""
+formulation_options(value::FormulaDefinition) = value.options
+formula_id(source::Pair{<:AbstractFormulation,<:NamedTuple}) = formula_id(first(source))
+description(source::Pair{<:AbstractFormulation,<:NamedTuple}; compact::Bool=false) = description(first(source);compact)
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the ordered, owner-scoped formula selections and controls relevant to
+`quantity`. Pass `nothing` to retain the complete formulation. The formulation's owning
+`pairs` methods list them. Descriptions are not identities.
+Return `missing` if any selected identity is unavailable. No formula is evaluated.
+"""
+function formula_id(source::AbstractFormulation, quantity)
+ selections = Tuple((scope, formula_id(value), value isa Pair ? last(value) : (;))
+ for (scope, value) in pairs(source; quantity))
+ return any(selection -> ismissing(selection[2]), selections) ? missing : selections
+end
+formula_id(::Missing, quantity) = missing
+
+"""Describe a formulation's composition for one existing physical request."""
+description(source::AbstractFormulation,request;compact::Bool=true) =
+ only(description([source];roles=[:none],quantity=request,compact))
+
+"""Describe a selection in its consuming owner's scientific context."""
+description(owner::Type,selected;compact::Bool=false,quantity=nothing) = description(selected;compact)
+
+
+"""Describe an owner-scoped route using the selected leaf's own description."""
+description(scope::Tuple{Type,Tuple},selected;kwargs...) = description(first(scope),last(scope),selected;kwargs...)
+function description(owner::Type,route::Tuple, selected; compact::Bool=true, settings::Bool=false)
+ name=isempty(route) ? description(owner;compact) : description(owner,Val(first(route)))
+ length(route)>1 && (name *= "("*join(string.(Base.tail(route)),",")*")")
+ controls=selected isa Pair ? last(selected) : (;)
+ value=settings ? "" : isempty(route) ? description(selected;compact) : description(owner,selected;compact)
+ isempty(controls) || (value *= (isempty(value) ? "" : " ") *
+ (isempty(route) ? sprint(show,controls;context=:compact=>compact) :
+ description(FormulaDefinition,controls;compact)))
+ return name*"="*value
+end
+
+"""Render only the explicit controls admitted by a formula declaration."""
+function description(::Type{FormulaDefinition},controls::NamedTuple;compact::Bool=true)
+ return "("*join([string(key)*"="*description(FormulaDefinition,Val(key),value;compact)
+ for (key,value) in pairs(controls)],", ")*")"
+end
+description(::Type{FormulaDefinition},::Union{Val{:parameters},Val{:options}},value::NamedTuple;
+ compact::Bool=true) = sprint(show,value;context=:compact=>compact)
diff --git a/src/commons/gridpoint.jl b/src/commons/gridpoint.jl
new file mode 100644
index 000000000..ac0e8bb65
--- /dev/null
+++ b/src/commons/gridpoint.jl
@@ -0,0 +1,53 @@
+"""
+$(TYPEDSIGNATURES)
+
+Copy retained records and arrays, preserving uncertainty-source identities.
+Numerical leaves keep their scalar representation. Structured scientific owners
+supply their own conversion.
+"""
+detach(value::Union{Number,Symbol,Nothing,Missing,Type,Val,UUIDs.UUID}) = value
+detach(value::AbstractString) = String(value)
+detach(value::NamedTuple) = map(detach, value)
+detach(value::Tuple) = map(detach, value)
+detach(value::AbstractArray) = map(detach, value)
+detach(value::Pair) = detach(first(value)) => detach(last(value))
+detach(value::AbstractDict) = Dict(detach(key) => detach(item) for (key, item) in value)
+detach(value::Union{Units.UnitExpr,Units.Unit,Units.Quantity}) = value
+function detach(value::Function)
+ Base.issingletontype(typeof(value)) || throw(ArgumentError("observation records require a singleton function type or a type-specific detach method"))
+ return value
+end
+detach(value::Base.Fix2) = Base.Fix2(detach(value.f),detach(value.x))
+
+"""
+$(TYPEDSIGNATURES)
+
+Read the retained physical description and original identity of a completed
+gridpoint. Result owners supply these descriptions through methods of `observation_gridpoint`.
+
+The fallback identifies an externally supplied result with unspecified inputs.
+Such a record cannot establish physical equivalence with another observation.
+"""
+observation_gridpoint(source) = (id=nothing, inputs=nothing, formulations=nothing,
+ coordinates=nothing, uncertainty=nothing, missing_reason=:physical_inputs_not_supplied)
+
+"""
+$(TYPEDSIGNATURES)
+
+Identify a completed physical point and formulation within one computation.
+The default source UUID uses system randomness independently of scientific RNGs.
+Collection owners share `source_id` and supply the original one-based indices.
+"""
+gridpoint_id(; source_id=UUIDs.uuid4(Random.RandomDevice()),
+ problem_index::Integer=1, formulation_index::Integer=1) =
+ (; source_id, problem_index=Int(problem_index), formulation_index=Int(formulation_index))
+
+"""
+$(TYPEDSIGNATURES)
+
+Return owner-defined physical field names and units for captured inputs.
+Each entry is `(name, unit)`. Unregistered fields retain their explicit field
+names without an inferred physical unit. Completion stores this passive metadata
+beside the original values for source-free description formatting.
+"""
+input_fields(::Type) = (;)
diff --git a/src/commons/interfaces.jl b/src/commons/interfaces.jl
new file mode 100644
index 000000000..35f5402ab
--- /dev/null
+++ b/src/commons/interfaces.jl
@@ -0,0 +1,215 @@
+"""
+ validate(subject, context...)
+
+Reject an invalid input before its consumer uses it. Return `subject`, the first
+positional argument, unchanged.
+
+`subject` is user input or an input derived from it. The optional context says what the
+subject is checked for: its consumer, such as the problem, formulation, physical model or
+result being built, or, for the result of a law or record, the function that produced it.
+Data that the check needs follows the context. A method returns the subject without
+converting or resolving it or building a new record, and without a check when the
+consumer does not restrict it. `validate` is the only verb for input checks.
+
+# Errors
+
+- Throws a native exception identifying the invalid field, value, and required
+ condition.
+- Throws `RequiredInterfaces.NotImplementedError` when a concrete problem type
+ does not implement input validation.
+"""
+function validate end
+
+@required AbstractProblemDefinition begin
+ validate(::AbstractProblemDefinition)
+end
+
+"""
+$(SIGNATURES)
+
+Validate and normalize the options owned by a formulation type.
+
+The implementation that owns `FormulationType` defines a method for the type
+itself. Dispatch requires an explicitly supported type. An unregistered formulation
+raises `MethodError`.
+
+# Arguments
+
+- `owner`: formulation-type dispatch token.
+- `options`: `FormulationOptions` supplied to the defining normalizer. Public
+ constructors also accept `options=(...)` shorthand and wrap it at entry.
+
+# Returns
+
+- A formulation-owned [`FormulationOptions`](@ref) record with a fixed set
+ of keys for the selected owner.
+"""
+function formulation_options end
+
+"""
+$(TYPEDSIGNATURES)
+
+Allocate a selected formula's reusable arrays during computation initialization.
+The arguments are the resolved selection, scalar type, completed numerical input,
+plan of fixed indices and geometry, and existing buffer record. Return the extended
+record without replacing another owner's storage. Array blocks contain no copied
+geometry, material model, selection or validity state. The default uses the existing storage. No material law or integrand is evaluated here.
+"""
+function initialize_buffers end
+
+function initialize_buffers(
+ ::Union{AbstractFormulation, Nothing}, ::Type, input, plan, buffers)
+ buffers
+end
+
+function initialize_buffers(
+ selections::Union{NamedTuple, Tuple}, ::Type{T}, input, plan, buffers) where {T}
+ return foldl(values(selections); init = buffers) do accumulated, selected
+ initialized = initialize_buffers(selected, T, input, plan, accumulated)
+ # Extension methods may append arrays but cannot replace another owner's
+ # storage.
+ retained = map(values(accumulated),
+ values(initialized[keys(accumulated)])) do before, after
+ before === after
+ end
+ all(retained) || throw(ArgumentError(
+ "buffer initialization replaced existing storage :$(keys(accumulated)[findfirst(!, retained)])"))
+ initialized
+ end
+end
+
+"""
+$(SIGNATURES)
+
+Validate and normalize the options owned by one computation.
+
+The implementation that owns `OwnerType` defines a method for the type itself.
+`OwnerType` may identify a core solver or another composite computation owner.
+Dispatch requires an explicitly supported type. An unregistered owner raises `MethodError`.
+
+# Arguments
+
+- `owner`: computation-owner dispatch token.
+- `options`: `ComputationOptions` supplied to the defining normalizer. Public
+ compute calls also accept `options=(...)` shorthand and wrap it at entry.
+
+# Returns
+
+- A computation-owned [`ComputationOptions`](@ref) record with a fixed set
+ of outer keys for the selected owner.
+"""
+function computation_options end
+
+"""
+$(SIGNATURES)
+
+Return supplemental output owned by one core or composite computation.
+
+The formulation type defines a method for itself and returns a
+fixed-key [`ComputationDetails`](@ref) record. Dispatch requires an explicitly supported type.
+An unregistered formulation raises `MethodError`.
+"""
+function computation_details end
+
+"""
+$(SIGNATURES)
+
+Return the typed supplemental output retained by a completed
+result. Result owners define narrow methods beside their result containers.
+"""
+function details end
+
+"""
+$(SIGNATURES)
+
+Calculate a completed result from an explicit problem and formulation.
+
+Concrete solver methods validate and normalize execution `options` through
+[`computation_options`](@ref) for their owner. Composite computations may
+forward caller options to that solver for validation. Scientific choices use [`formulation_options`](@ref), and supplemental
+results use [`computation_details`](@ref). Unsupported problem-formulation
+pairs fail through ordinary Julia dispatch.
+"""
+function compute end
+
+"""
+$(SIGNATURES)
+
+Return native numerical values selected from a completed scientific result.
+
+The selector and optional transform are function objects. Result owners define
+the supported combinations beside their result representations.
+"""
+function observe end
+
+function _observe_macro_parts(request)
+ valid_request = request isa Expr &&
+ request.head === :ref &&
+ length(request.args) >= 2
+ valid_request || throw(ArgumentError(
+ "@observe expects indexed `accessor[...]` or `(accessor, transform)[...]`; " *
+ "got `$(request)`.",
+ ))
+
+ selector = first(request.args)
+ indices = request.args[2:end]
+ if selector isa Expr && selector.head === :tuple
+ length(selector.args) in (2, 3) || throw(ArgumentError(
+ "@observe selector tuples require two or three functions; " *
+ "got `$(selector)`.",
+ ))
+ return Tuple(selector.args), indices
+ end
+ return (selector,), indices
+end
+
+"""
+ @observe accessor[indices...]
+ @observe (accessor, transform)[indices...]
+ @observe (statistics, quantity, statistic)[indices...]
+
+Construct a plain observable-request tuple without reading a result.
+"""
+macro observe(request)
+ selectors, indices = _observe_macro_parts(request)
+ parts = (selectors..., indices...)
+ return Expr(:tuple, map(esc, parts)...)
+end
+
+"""
+ @observe source accessor[indices...]
+ @observe source (accessor, transform)[indices...]
+ @observe source (statistics, quantity, statistic)[indices...]
+
+Expand indexed observable syntax into an immediate [`observe`](@ref) call. The
+indices follow the selected observable. Ordinary line quantities use row,
+column, and sample indices. Diagonal transforms use mode and sample indices.
+"""
+macro observe(source, request)
+ selectors, indices = _observe_macro_parts(request)
+ parts = (source, selectors..., indices...)
+ escaped = map(esc, parts)
+ return :(observe($(escaped...)))
+end
+
+"""
+$(SIGNATURES)
+
+Publish explicitly requested scientific values for presentation or reporting.
+
+`observables(::Type{T})` declares the selectors supported by `T`.
+`observables(source, requests; units, length_unit, frequency_unit,
+quantity_units, clip, atol, frequencies)` returns one [`ObservedResult`](@ref),
+or an ordinary vector for a result collection. Its four sections retain gridpoint
+descriptions, quantities, completed errors, and recorded timings. Quantity-wise
+tables are materialized by `ReportBuilder.tabulate` from these detached records. `units` is empty or
+positionally aligned with `requests`. With `clip=true` (default), the result
+owner's declared native-unit reporting resolution is applied before conversion.
+`atol` optionally overrides that resolution. Multiple quantities require keyed
+cutoffs. `frequencies` supplies standalone tensor context \\[Hz\\]. These cutoffs
+are not certified floating-point error bounds. Each clipped value becomes exact
+zero, including its uncertainty. Quantities lacking both a declared resolution and an explicit cutoff remain unchanged. `clip=false` retains raw values in
+the requested display units. Absolute/relative error products are never clipped
+using their operands' physical cutoffs.
+"""
+function observables end
diff --git a/src/commons/matrixops.jl b/src/commons/matrixops.jl
new file mode 100644
index 000000000..0739e0f03
--- /dev/null
+++ b/src/commons/matrixops.jl
@@ -0,0 +1,468 @@
+# Matrix reductions shared by the line-parameter backends. Primitive matrices are
+# ordered by terminal. A `ReductionPlan` fixes the index operations once.
+
+"""
+$(TYPEDSIGNATURES)
+
+Replace each cyclic diagonal of the square `matrix` by its mean, in place, and
+return `matrix`. The result is the ideally transposed matrix: every phase occupies
+every position for an equal share of the line length.
+"""
+function ideal_transposition!(matrix::AbstractMatrix)
+ n = checksquare(matrix)
+ coefficients = similar(diag(matrix))
+ @inbounds for offset in 0:(n - 1)
+ total = zero(eltype(matrix))
+ for row in 1:n
+ total += matrix[row, 1 + mod(row - 1 + offset, n)]
+ end
+ coefficients[offset + 1] = total / n
+ end
+ @inbounds for row in 1:n, column in 1:n
+
+ matrix[row, column] = coefficients[mod1(column - row + 1, n)]
+ end
+ return matrix
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the terminal permutation that places the first conductor of each active
+phase in encounter order, then the remaining conductors of each phase in the same
+phase order, then the conductors whose `map` entry is zero.
+"""
+function reorder_indices(map::AbstractVector{<:Integer})
+ n = length(map)
+ phases = Int[] # encounter order of active phase IDs
+ firsts = Int[]
+ sizehint!(firsts, n)
+ eliminated = Int[] # phase-zero conductors
+ sizehint!(eliminated, 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!(eliminated, 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 eliminated
+ perm[k] = i
+ k += 1
+ end
+ end
+ return perm
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Eliminate matrix rows and columns whose `phase_map` entry is zero.
+
+For retained indices `1` and eliminated indices `2`, calculate the Schur
+complement
+
+```math
+M_{\\mathrm{red}} = M_{11} - M_{12}M_{22}^{-1}M_{21}.
+```
+
+# Arguments
+
+- `M`: square complex matrix.
+- `phase_map`: active-phase assignment for each row and column. Nonzero IDs
+ identify active phases. Zero marks a grounded or eliminated conductor.
+
+# Returns
+
+- The reduced matrix.
+"""
+function kron_reduce(
+ M::Matrix{Complex{T}},
+ phase_map::Vector{Int}
+) where {T <: Real}
+ retained = count(!=(0), phase_map)
+ reduced = similar(M, retained, retained)
+ kron_reduce!(M, phase_map, reduced)
+ return reduced
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Write the Kron-reduced matrix from [`kron_reduce`](@ref) into `Mred`.
+
+# Arguments
+
+- `M`: square complex matrix.
+- `phase_map`: active-phase assignment for each row and column. Nonzero IDs
+ identify active phases. Zero marks a grounded or eliminated conductor.
+- `Mred`: destination matrix.
+
+# Returns
+
+- `nothing`.
+"""
+function kron_reduce!(
+ M::Matrix{Complex{T}},
+ phase_map::Vector{Int},
+ Mred::Matrix{Complex{T}}
+) where {T <: Real}
+ checksquare(M) == length(phase_map) || throw(DimensionMismatch(
+ "phase map must contain one entry per matrix row"))
+ keep = findall(!=(0), phase_map)
+ eliminate = findall(==(0), phase_map)
+ # This entry point also permits Mred to alias M. The reusable-buffer entry
+ # point below requires independent scratch, as in the computation workspace.
+ source = Base.unalias(Mred, M)
+ kron_reduce!(source, keep, eliminate, Mred,
+ similar(M, length(eliminate), length(eliminate)),
+ similar(M, length(keep), length(eliminate)),
+ similar(M, length(eliminate), length(keep)))
+ return nothing
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Write the Schur complement of `matrix` that retains the indices `keep` and
+eliminates the indices `eliminate` into `reduced`, and return `reduced`.
+
+`factor`, `coupling` and `right_hand_side` are scratch blocks of sizes
+`length(eliminate)²`, `length(keep)×length(eliminate)` and
+`length(eliminate)×length(keep)`. The destination and the scratch blocks must not
+alias `matrix` or one another. Without eliminated indices, `reduced` receives
+`matrix[keep, keep]`.
+"""
+function kron_reduce!(
+ matrix::AbstractMatrix{Complex{T}},
+ keep::AbstractVector{Int},
+ eliminate::AbstractVector{Int},
+ reduced::AbstractMatrix{Complex{T}},
+ factor::AbstractMatrix{Complex{T}},
+ coupling::AbstractMatrix{Complex{T}},
+ right_hand_side::AbstractMatrix{Complex{T}}
+) where {T <: Real}
+ retained = length(keep)
+ removed = length(eliminate)
+ size(reduced) == (retained, retained) || throw(DimensionMismatch(
+ "reduced matrix storage must be $retained×$retained"
+ ))
+ size(factor) == (removed, removed) || throw(DimensionMismatch(
+ "Kron factor storage must be $removed×$removed"
+ ))
+ size(coupling) == (retained, removed) || throw(DimensionMismatch(
+ "Kron coupling storage must be $retained×$removed"
+ ))
+ size(right_hand_side) == (removed, retained) || throw(DimensionMismatch(
+ "Kron right-hand-side storage must be $removed×$retained"
+ ))
+ @inbounds for column in 1:retained, row in 1:retained
+
+ reduced[row, column] = matrix[keep[row], keep[column]]
+ end
+ iszero(removed) && return reduced
+ @inbounds for column in 1:removed, row in 1:removed
+
+ factor[row, column] = matrix[eliminate[row], eliminate[column]]
+ end
+ @inbounds for column in 1:removed, row in 1:retained
+
+ coupling[row, column] = matrix[keep[row], eliminate[column]]
+ end
+ @inbounds for column in 1:retained, row in 1:removed
+
+ right_hand_side[row, column] = matrix[eliminate[row], keep[column]]
+ end
+ factorization = lu!(factor)
+ ldiv!(factorization, right_hand_side)
+ mul!(
+ reduced,
+ coupling,
+ right_hand_side,
+ -one(Complex{T}),
+ one(Complex{T})
+ )
+ return reduced
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the pairs `(first, duplicate)` that merge each further conductor of an
+active phase into the first conductor of that phase, in the order of `phases`.
+Zero entries take part in no pair.
+"""
+function bundle_operations(phases::AbstractVector{<:Integer})
+ first_index = Dict{Int, Int}()
+ operations = Tuple{Int, Int}[]
+ @inbounds for (index, phase) in pairs(phases)
+ phase > 0 || continue
+ first = get(first_index, phase, 0)
+ if iszero(first)
+ first_index[phase] = index
+ else
+ push!(operations, (first, index))
+ end
+ end
+ return operations
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Apply the bundle change of basis of the pairs from [`bundle_operations`](@ref) to
+`matrix` in place, and return `matrix`. Each duplicate column loses the column of
+its first conductor. Each duplicate row then loses the row of its first conductor.
+"""
+function merge_bundles!(
+ matrix::AbstractMatrix{T},
+ operations::AbstractVector{<:Tuple{Int, Int}}
+) where {T}
+ @inbounds for (first, duplicate) in operations
+ base_column = @view matrix[:, first]
+ column = @view matrix[:, duplicate]
+ if matrix isa StridedMatrix{T} &&
+ T <: Union{Float32, Float64, ComplexF32, ComplexF64}
+ axpy!(-one(T), base_column, column)
+ else
+ column .-= base_column
+ end
+ end
+ @inbounds for (first, duplicate) in operations
+ base_row = @view matrix[first, :]
+ row = @view matrix[duplicate, :]
+ if matrix isa StridedMatrix{T} &&
+ T <: Union{Float32, Float64, ComplexF32, ComplexF64}
+ axpy!(-one(T), base_row, row)
+ else
+ row .-= base_row
+ end
+ end
+ return matrix
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Apply the bundle change of basis to conductors assigned to the same active
+phase. A zero assignment denotes an independent conductor selected for
+grounded or eliminated-conductor reduction and is never interpreted as a bundle
+identity.
+"""
+function merge_bundles!(
+ matrix::AbstractMatrix{T},
+ phases::AbstractVector{<:Integer}
+) where {T}
+ n = size(matrix, 1)
+ size(matrix, 2) == n == length(phases) ||
+ throw(ArgumentError("shape mismatch"))
+ operations = bundle_operations(phases)
+ merge_bundles!(matrix, operations)
+ reduced = copy(phases)
+ @inbounds for (_, duplicate) in operations
+ reduced[duplicate] = 0
+ end
+ return matrix, reduced
+end
+
+"""
+$(TYPEDEF)
+
+Fix the index operations that reduce primitive matrices, ordered by terminal, to the
+matrices of the retained phases. The fields below apply in their listed order.
+
+$(TYPEDFIELDS)
+"""
+struct ReductionPlan
+ "Reordering of the primitive terminals, from [`reorder_indices`](@ref)."
+ permutation::Vector{Int}
+ "Bundle change-of-basis pairs in reordered indices, empty without bundle reduction."
+ bundles::Vector{Tuple{Int, Int}}
+ "Reordered indices retained by the Kron elimination."
+ keep::Vector{Int}
+ "Reordered indices eliminated by the Kron elimination."
+ eliminate::Vector{Int}
+ "Whether the retained matrices are ideally transposed."
+ transposition::Bool
+ "Phase assignment of each retained row."
+ phase_map::Vector{Int}
+ "Primitive terminal of each retained row."
+ indices::Vector{Int}
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the reduction plan of `phase_map`, the active-phase assignment of each
+primitive terminal. Nonzero IDs identify active phases, and zero marks a grounded
+or eliminated conductor.
+
+# Keywords
+
+- `reduce_bundle`: merge the conductors of each active phase into one.
+- `kron_reduction`: eliminate the conductors with phase zero.
+- `ideal_transposition`: average the retained matrices over cyclic transposition.
+"""
+function ReductionPlan(phase_map::AbstractVector{<:Integer}; reduce_bundle::Bool,
+ kron_reduction::Bool, ideal_transposition::Bool)
+ permutation = reorder_indices(phase_map)
+ ordered = phase_map[permutation]
+ reduced = copy(ordered)
+ seen = Set{Int}()
+ @inbounds for (index, phase) in pairs(ordered)
+ if phase > 0 && phase in seen
+ reduced[index] = 0
+ elseif phase > 0
+ push!(seen, phase)
+ end
+ end
+ # Without Kron reduction, bundle reduction still eliminates the merged
+ # duplicates and keeps the conductors with phase zero, marked -1.
+ retained = if reduce_bundle
+ kron_reduction ? reduced :
+ [ordered[index] == 0 ? -1 : reduced[index] for index in eachindex(reduced)]
+ else
+ kron_reduction ? ordered : nothing
+ end
+ bundles = reduce_bundle ? bundle_operations(ordered) : Tuple{Int, Int}[]
+ retained === nothing && return ReductionPlan(permutation, bundles,
+ collect(eachindex(ordered)), Int[], ideal_transposition, ordered, copy(permutation))
+ keep = findall(!=(0), retained)
+ return ReductionPlan(permutation, bundles, keep, findall(==(0), retained),
+ ideal_transposition, retained[keep], permutation[keep])
+end
+
+"""
+$(TYPEDEF)
+
+Hold the storage of [`reduce_line_matrices!`](@ref) for one [`ReductionPlan`](@ref)
+and one element type.
+
+$(TYPEDFIELDS)
+"""
+struct ReductionBuffers{T <: Number}
+ "Reordered primitive matrix, reused for `Z` and `P`."
+ ordered::Matrix{T}
+ "Retained potential coefficients, factorized in place."
+ potential::Matrix{T}
+ "Kron elimination block of the eliminated indices."
+ factor::Matrix{T}
+ "Kron elimination block coupling retained to eliminated indices."
+ coupling::Matrix{T}
+ "Kron elimination right-hand side."
+ right_hand_side::Matrix{T}
+ "Identity of the retained size."
+ identity::Matrix{T}
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Allocate the storage of [`reduce_line_matrices!`](@ref) for `plan` with elements of
+type `T`.
+"""
+function ReductionBuffers{T}(plan::ReductionPlan) where {T <: Number}
+ n, retained, removed = length(plan.permutation), length(plan.keep), length(plan.eliminate)
+ return ReductionBuffers{T}(Matrix{T}(undef, n, n), Matrix{T}(undef, retained, retained),
+ Matrix{T}(undef, removed, removed), Matrix{T}(undef, retained, removed),
+ Matrix{T}(undef, removed, retained), Matrix{T}(I, retained, retained))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Extend `buffers` with `reduction`, the storage of [`reduce_line_matrices!`](@ref) for
+`reduction` with elements of type `T`.
+"""
+function initialize_buffers(reduction::ReductionPlan, ::Type{T}, input, plan,
+ buffers) where {T <: Number}
+ return merge(buffers, (reduction = ReductionBuffers{T}(reduction),))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Reduce one frequency of primitive line matrices to the retained phases of `plan`.
+
+`Zprimitive` \\[Ω/m\\] and `Pprimitive` \\[m/F\\] are ordered by terminal. Both are
+reordered, merged by bundle and Kron-reduced. With ideal transposition, the
+retained `Z` and `P` are averaged. `Z` receives the retained series impedance and
+`Y` receives the shunt admittance ``Y = s P^{-1}`` \\[S/m\\] of the retained `P`,
+with `s = jω`. `buffers` comes from [`ReductionBuffers`](@ref) for `plan`.
+
+With `diagnostics = Val(true)`, nonfinite `P` or `Y` throws `ArgumentError`, and the
+return value is the infinity-norm residual of ``P P^{-1} - I`` and the 2-norm
+condition number of `P`. A nonfinite condition estimate or a residual above
+``\\max(\\sqrt{ε}, 32nε\\max(1, κ))`` produces a warning. Otherwise the return value
+is `nothing`.
+"""
+function reduce_line_matrices!(
+ Z::AbstractMatrix{T},
+ Y::AbstractMatrix{T},
+ Zprimitive::AbstractMatrix,
+ Pprimitive::AbstractMatrix,
+ s::Number,
+ plan::ReductionPlan,
+ buffers::ReductionBuffers{T},
+ diagnostics::Union{Val{true}, Val{false}} = Val(false)
+) where {T <: Number}
+ function retain!(retained, primitive)
+ ordered = buffers.ordered
+ @inbounds for column in eachindex(plan.permutation), row in eachindex(plan.permutation)
+ ordered[row, column] = primitive[plan.permutation[row], plan.permutation[column]]
+ end
+ merge_bundles!(ordered, plan.bundles)
+ kron_reduce!(ordered, plan.keep, plan.eliminate, retained,
+ buffers.factor, buffers.coupling, buffers.right_hand_side)
+ plan.transposition && ideal_transposition!(retained)
+ return retained
+ end
+ retain!(Z, Zprimitive)
+ P = retain!(buffers.potential, Pprimitive)
+ if diagnostics === Val(false)
+ ldiv!(Y, lu!(P), buffers.identity)
+ Y .*= s
+ return nothing
+ end
+ all(isfinite, P) || throw(ArgumentError(
+ "retained potential coefficients contain nonfinite values"))
+ R = real(T)
+ condition_number = convert(R, cond(P))
+ isfinite(condition_number) ||
+ @warn "Potential-coefficient condition estimate is not finite" condition_number
+ # The reordered storage is free after the Kron elimination of P.
+ coefficients = copyto!(view(buffers.ordered, axes(P)...), P)
+ ldiv!(Y, lu!(P), buffers.identity)
+ all(isfinite, Y) || throw(ArgumentError("computed admittance contains nonfinite values"))
+ residual = convert(R, norm(coefficients * Y - buffers.identity, Inf))
+ tolerance = max(sqrt(eps(R)), convert(R, 32size(Y, 1) * eps(R) * max(one(R), condition_number)))
+ isfinite(residual) && residual <= tolerance ||
+ @warn "Potential-coefficient inversion residual target was not met" residual tolerance condition_number
+ Y .*= s
+ return (; residual, condition_number)
+end
diff --git a/src/commons/observables.jl b/src/commons/observables.jl
new file mode 100644
index 000000000..c9799ab4c
--- /dev/null
+++ b/src/commons/observables.jl
@@ -0,0 +1,294 @@
+"""
+$(TYPEDSIGNATURES)
+
+Return the function-valued selector prefix encoded by one observable
+request. Positional indices are omitted from the result.
+"""
+function request_identity(request)
+ request isa Function && return request
+ request isa Tuple && !isempty(request) || throw(ArgumentError(
+ "observable requests must be selector functions or nonempty tuples",
+ ))
+ count = findfirst(value -> !(value isa Function) || value isa Colon, request)
+ count = count === nothing ? length(request) : count - 1
+ count > 0 || throw(ArgumentError("an observable request must begin with a selector function"))
+ return count == 1 ? first(request) : request[1:count]
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Normalize a selector prefix to the retained component selector used for lookup.
+The input excludes positional indices. [`request_identity`](@ref) extracts that
+prefix from a complete request. Scientific owners may extend this method for
+accessor aliases. The default leaves selectors unchanged. `Val` selector names
+also support keyed display-unit overrides such as `:alpha` and `:beta`.
+"""
+normalize_observation_selector(selector) = selector
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the [`LineCableModels.Units.Quantity`](@ref) encoded by a scientific
+request.
+"""
+function request_quantity(request)
+ identity=request_identity(request)
+ return identity isa Tuple ? quantity(identity...) : quantity(identity)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the positional indices encoded by a scientific request.
+"""
+function request_indices(request)
+ request isa Function && return ()
+ identity = request_identity(request)
+ selector_count = identity isa Tuple ? length(identity) : 1
+ return request[(selector_count + 1):end]
+end
+
+function _observable_declaration(source)
+ supported = observables(typeof(source))
+ supported isa Tuple || throw(
+ ArgumentError("observables($(typeof(source))) must return a tuple of selectors"),
+ )
+ return supported
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Resolve one scientific request into its declared selector identity, physical
+quantity, and positional indices.
+
+# Arguments
+
+- `source`: value whose type declares the supported observable identities.
+- `request`: selector function or plain observable request tuple.
+
+# Returns
+
+- A named tuple containing `identity`, `quantity`, and `indices`.
+
+# Errors
+
+- `ArgumentError`: the declaration is malformed or the request is unsupported.
+"""
+function observation_request(source, request)
+ supported = _observable_declaration(source)
+ identity = request_identity(request)
+ identity in supported || throw(ArgumentError(
+ "$(typeof(source)) does not publish selector $(repr(identity))",
+ ))
+ selector_count = identity isa Tuple ? length(identity) : 1
+ indices = request isa Function ? () : request[(selector_count + 1):end]
+ return (; identity, quantity = request_quantity(request), indices)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Resolve an integer, range, vector, or colon selector against a dimension of
+length `count`.
+
+# Returns
+
+- Concrete integer indices in selection order.
+
+# Errors
+
+- `ArgumentError`: the selector form is unsupported.
+- `BoundsError`: at least one resolved index is outside `1:count`.
+"""
+function observation_indices(selector, count::Integer)
+ indices = if selector isa Colon
+ collect(1:Int(count))
+ elseif selector isa Integer
+ [Int(selector)]
+ elseif selector isa AbstractRange || selector isa AbstractVector
+ collect(Int, selector)
+ else
+ throw(ArgumentError(
+ "observable indices must be integers, ranges, vectors, or `:`",
+ ))
+ end
+ all(index -> index in 1:count, indices) || throw(BoundsError(1:count, indices))
+ return indices
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return a plain observable request with `indices` and the selector identity from
+`resolved`.
+"""
+function materialize_observation(resolved::NamedTuple, indices::Tuple)
+ identity = resolved.identity
+ prefix = identity isa Tuple ? identity : (identity,)
+ return (prefix..., indices...)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Validate positional scientific requests and aligned display-unit overrides
+against the observable declaration for `source`.
+
+# Arguments
+
+- `source`: result or retained product that owns observable selectors.
+- `requests`: tuple of selector functions or selector tuples.
+- `unit_overrides`: empty tuple or one display unit for each request.
+
+# Returns
+
+- A tuple containing the normalized selector identity for each request.
+
+# Errors
+
+- Throws `ArgumentError` when the declaration is malformed or a request is
+ unsupported.
+- Throws `DimensionMismatch` when the display-unit tuple is not aligned with
+ the requests.
+- A source that does not implement the declaration fails through ordinary
+ method dispatch.
+"""
+function validate_observables(
+ source,
+ requests::Tuple,
+ unit_overrides::Tuple = ()
+)
+ isempty(unit_overrides) || length(unit_overrides) == length(requests) ||
+ throw(
+ DimensionMismatch("display units must align with observable requests"),
+ )
+ allunique(requests) || throw(ArgumentError("observable requests must be distinct"))
+ return map(request -> observation_request(source, request).identity, requests)
+end
+
+function _unit_override_keys(request, overrides)
+ identity = request_identity(request)
+ indices=request_indices(request)
+ selector=identity isa Tuple ? first(identity) : identity
+ names=selector isa Base.Fix2 ? () : selector isa Function ? (nameof(selector),) : ()
+ prefix = identity isa Tuple && length(identity) > 2 ? (identity[1:2],) : ()
+ component_selector=normalize_observation_selector(identity)
+ indexed_aliases=isempty(indices) ? () : Tuple(key for key in keys(overrides) if
+ key isa Tuple && !isempty(key) && first(key) isa Function &&
+ isequal(normalize_observation_selector(request_identity(key)),component_selector) &&
+ isequal(request_indices(key),indices) && !isequal(key,request))
+ function_aliases=Tuple(key for key in keys(overrides) if key isa Function &&
+ isequal(normalize_observation_selector(key),component_selector) && !isequal(key,identity))
+ name_aliases=Tuple(key for key in keys(overrides) if key isa Symbol &&
+ isequal(normalize_observation_selector(Val(key)),component_selector))
+ bound=selector isa Base.Fix2 ? (selector,selector.f,nameof(selector.f)) : ()
+ physical=identity isa Tuple && length(identity)>1 && identity[2] isa Function &&
+ applicable(quantity,identity[2]) ? (identity[2],nameof(identity[2])) : ()
+ return (request,indexed_aliases...,identity, prefix..., bound...,function_aliases...,name_aliases...,
+ names...,physical...)
+end
+
+function _unit_override(overrides, request)
+ overrides === nothing && return nothing
+ overrides isa Union{Symbol, UnitExpr} && return overrides
+ overrides isa Union{NamedTuple, AbstractDict} || throw(ArgumentError(
+ "unit overrides must be a prefix, UnitExpr, keyed collection, or nothing",
+ ))
+ for override_key in _unit_override_keys(request,overrides)
+ if overrides isa NamedTuple
+ override_key isa Symbol && haskey(overrides, override_key) &&
+ return overrides[override_key]
+ elseif haskey(overrides, override_key)
+ return overrides[override_key]
+ end
+ end
+ return nothing
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Resolve display-unit targets aligned with a tuple of scientific requests.
+
+# Arguments
+
+- `requests`: selector functions or selector tuples.
+- `result_basis`: `:pul` or `:total`.
+
+# Keywords
+
+- `length_prefix`: metric prefix applied to per-length denominators.
+- `overrides`: a global prefix or `UnitExpr`, a keyed collection of those values or `nothing`.
+
+# Returns
+
+- A tuple of concrete `UnitExpr` values aligned with `requests`.
+"""
+function unit_targets(
+ requests::Tuple,
+ result_basis::Symbol;
+ length_prefix::Symbol = :kilo,
+ overrides = nothing
+)
+ return map(requests) do request
+ display_unit(
+ request_quantity(request),
+ result_basis,
+ _unit_override(overrides, request);
+ length_prefix
+ )
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Detach an observed value from its result storage and apply a display-unit scale
+factor. Structured result owners extend this method for their published value
+types.
+"""
+detach(value::Number, factor) = value * factor
+detach(value::AbstractFloat, factor::Real) = value * oftype(value, factor)
+detach(value::Complex{T}, factor::Real) where {T<:AbstractFloat} = value * T(factor)
+detach(value::Missing, factor) = missing
+detach(value::Nothing, factor) = nothing
+detach(values::NamedTuple, factor) = map(value -> detach(value, factor), values)
+detach(values::AbstractArray, factor) = map(value -> detach(value, factor), values)
+
+"""
+$(TYPEDSIGNATURES)
+
+Resolve the declared physical reporting resolution for one scientific request.
+Result owners extend this operation. The fallback leaves precision unspecified.
+
+# Arguments
+
+- `source`: result defining the requested values and native physical basis.
+- `request`: an observable selector or indexed request.
+
+# Keywords
+
+- `atol`: optional absolute reporting cutoff in the requested native units.
+- `frequencies`: optional frequency context \\[Hz\\] for standalone tensors.
+
+# Returns
+
+- A record containing `kind`, native `atol` and `unit`, and detached
+ `unresolved` and `available` masks aligned with the requested values.
+ An unassessed request returns `nothing` for its cutoff and masks. Reporting cutoffs
+ are not certified numerical forward-error bounds.
+"""
+function observation_resolution(source, request; atol=nothing, frequencies=nothing)
+ return (kind=:unassessed, atol=nothing, unit=nothing,
+ unresolved=nothing, available=nothing)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct detached atomic observations. For an ordinary collection, apply the
+atomic constructor to each element.
+"""
+observables(source,requests::Tuple=();kwargs...) = ObservedResult(source,requests;kwargs...)
diff --git a/src/commons/observed_validation.jl b/src/commons/observed_validation.jl
new file mode 100644
index 000000000..e5d98725d
--- /dev/null
+++ b/src/commons/observed_validation.jl
@@ -0,0 +1,188 @@
+# Validate retained quantity records consumed by tables, plots, and archives.
+function _observed_fields(record,fields,what)
+ record isa NamedTuple && all(key -> haskey(record,key),fields) ||
+ throw(ArgumentError("$what requires fields $(fields)"))
+end
+
+function _observed_indices(indices,extent,name)
+ indices isa AbstractVector && allunique(indices) && all(i -> i isa Integer && !(i isa Bool) && 1<=i<=extent,indices) ||
+ throw(ArgumentError("$name must contain distinct original indices within its extent"))
+end
+
+function _validate_observed_quantity(product)
+ _observed_fields(product,(:request,:quantity,:family,:statistic,:values,:unit,:basis,
+ :coordinates,:thresholds,:available,:engineering_zero,:clipped,:missing_reason),"observed quantity")
+ request_quantity(product.request)==product.quantity || throw(ArgumentError("request and quantity disagree"))
+ product.family isa Symbol && product.statistic isa Symbol && product.clipped isa Bool ||
+ throw(ArgumentError("family/statistic must be symbols and clipped must be Boolean"))
+ scale_factor(native_unit(product.quantity,product.basis),product.unit)
+ c=product.coordinates
+ _observed_fields(c,(:kind,:indices,:extent),"quantity coordinates")
+ c.extent isa Tuple && all(n -> n isa Integer && n>=0,c.extent) || throw(ArgumentError("invalid coordinate extent"))
+ c.indices isa Tuple || throw(ArgumentError("coordinate indices must be a tuple"))
+ declared=request_indices(product.request)
+ c.kind===:samples && length(declared)==length(c.indices)-1 &&
+ (declared=(declared...,Colon()))
+ if !isempty(declared) && c.kind!==:array
+ length(declared)>=length(c.indices) && declared[1:length(c.indices)]==c.indices ||
+ throw(ArgumentError("request selectors disagree with retained coordinate selectors"))
+ end
+ dims=if c.kind===:vector
+ _observed_fields(c,(:axis,:axis_label,:positions,:samples,:frequencies,:frequency_unit,:labels,:domain),"vector coordinates")
+ length(c.extent)==2 || throw(DimensionMismatch("vector extent requires coordinate and frequency axes"))
+ c.axis isa Symbol && c.axis_label isa AbstractString && c.domain isa Symbol ||
+ throw(ArgumentError("vector axis requires physical labels and a domain"))
+ _observed_indices(c.positions,c.extent[1],"positions")
+ _observed_indices(c.samples,c.extent[2],"samples")
+ c.labels isa AbstractVector && length(c.labels)==c.extent[1] ||
+ throw(DimensionMismatch("vector labels do not cover the original extent"))
+ c.frequencies===nothing || length(c.frequencies)==length(c.samples) ||
+ throw(DimensionMismatch("frequency and sample counts differ"))
+ (length(c.positions),length(c.samples))
+ elseif c.kind in (:matrix,:diagonal) || c.kind in (:samples,:histogram) && haskey(c,:rows)
+ _observed_fields(c,(:rows,:columns,:samples,:frequencies,:frequency_unit,:labels,:domain),"matrix coordinates")
+ length(c.extent)==3 || throw(DimensionMismatch("matrix extent requires three axes"))
+ _observed_indices(c.rows,c.extent[1],"rows")
+ _observed_indices(c.columns,c.extent[2],"columns")
+ _observed_indices(c.samples,c.extent[3],"samples")
+ length(c.labels)>=max(c.extent[1],c.extent[2]) || throw(DimensionMismatch("matrix labels do not cover the extent"))
+ if haskey(c,:column_labels) || haskey(c,:column_domain)
+ _observed_fields(c,(:column_labels,:column_domain),"mixed matrix coordinates")
+ c.kind===:matrix || throw(ArgumentError("mixed labels require matrix coordinates"))
+ c.column_labels isa AbstractVector && length(c.column_labels)>=c.extent[2] ||
+ throw(DimensionMismatch("column labels do not cover the extent"))
+ c.column_domain isa Symbol || throw(ArgumentError("column domain must be a symbol"))
+ end
+ c.kind===:diagonal && c.rows!=c.columns && throw(ArgumentError("diagonal coordinates must agree"))
+ c.frequencies===nothing || length(c.frequencies)==length(c.samples) || throw(DimensionMismatch("frequency and sample counts differ"))
+ c.kind===:diagonal ? (length(c.rows),length(c.samples)) : (length(c.rows),length(c.columns),length(c.samples))
+ elseif c.kind===:assemblies || c.kind in (:samples,:histogram) && haskey(c,:assemblies)
+ _observed_fields(c,(:assemblies,:labels,:frequencies,:frequency_unit),"assembly coordinates")
+ length(c.extent)==2 && c.extent[2]==1 || throw(DimensionMismatch("assembly extent requires one operating frequency"))
+ _observed_indices(c.assemblies,c.extent[1],"assemblies")
+ length(c.labels)==c.extent[1] && allunique(c.labels) || throw(ArgumentError("assembly labels must identify every assembly uniquely"))
+ length(c.frequencies)==1 || throw(DimensionMismatch("assemblies require one operating frequency"))
+ (length(c.assemblies),)
+ elseif c.kind===:array
+ c.extent
+ else
+ throw(ArgumentError("unsupported observed coordinate kind $(c.kind)"))
+ end
+ if haskey(c,:frequency_unit)
+ scale_factor(c.frequency_unit,Units.units(:base,:hertz))
+ c.frequencies===nothing || all(f -> f isa Real && isfinite(nominal(f)) && nominal(f)>=0,c.frequencies) ||
+ throw(ArgumentError("frequency coordinates must be finite and nonnegative"))
+ end
+ if c.kind===:samples
+ _observed_fields(c,(:trials,),"sample coordinates")
+ allunique(c.trials) && all(t -> t isa Integer && !(t isa Bool) && t>0,c.trials) ||
+ throw(ArgumentError("trial coordinates must be distinct positive integers"))
+ dims=(dims...,length(c.trials))
+ end
+ if c.kind===:histogram
+ _observed_fields(product.values,(:lower,:upper,:density,:probability,:count),"histogram values")
+ n=length(product.values.lower)
+ all(v -> v isa AbstractVector && length(v)==n,Base.values(product.values)) || throw(DimensionMismatch("histogram column lengths differ"))
+ _observed_fields(product,(:distribution,:ordinate_units),"histogram product")
+ _observed_fields(product.distribution,(:edges,:empirical_cdf,:model_cdf,:qq),"histogram distribution")
+ length(product.distribution.edges)==n+1 || throw(DimensionMismatch("histogram edges and bins differ"))
+ for cdf in (product.distribution.empirical_cdf,product.distribution.model_cdf)
+ cdf===nothing && continue
+ _observed_fields(cdf,(:x,:y),"CDF coordinates")
+ cdf.x isa AbstractVector && cdf.y isa AbstractVector && length(cdf.x)==length(cdf.y) ||
+ throw(DimensionMismatch("CDF coordinate lengths differ"))
+ end
+ qq=product.distribution.qq
+ if qq!==nothing
+ _observed_fields(qq,(:model,:sample,:reference),"Q–Q coordinates")
+ qq.model isa AbstractVector && qq.sample isa AbstractVector && length(qq.model)==length(qq.sample) ||
+ throw(DimensionMismatch("Q–Q model and sample coordinate lengths differ"))
+ qq.reference isa Tuple && length(qq.reference)==2 || throw(ArgumentError("Q–Q identity line requires two endpoints"))
+ end
+ _observed_fields(product.ordinate_units,(:density,:probability,:count),"histogram ordinate units")
+ scale_factor(inv(product.unit),product.ordinate_units.density)
+ for unit in (product.ordinate_units.probability,product.ordinate_units.count)
+ scale_factor(Units.units(:base,:dimensionless),unit)
+ end
+ product.available isa Bool && product.engineering_zero isa Bool && product.missing_reason===nothing ||
+ throw(ArgumentError("histogram availability must be scalar"))
+ product.thresholds===nothing || throw(ArgumentError("histograms cannot carry primary clipping thresholds"))
+ return nothing
+ end
+ values=product.values
+ values isa Union{Number,Missing,AbstractArray} || throw(ArgumentError("numerical products require scalar or array values"))
+ (values isa AbstractArray ? length(values) : 1)==prod(dims) || throw(DimensionMismatch("value count differs from retained coordinates"))
+ actual=values isa AbstractArray ? size(values) : ()
+ reduced=length(c.indices)==length(dims) ? Tuple(n for (n,i) in zip(dims,c.indices) if !(i isa Integer)) : dims
+ actual in (dims,reduced) || throw(DimensionMismatch("value shape differs from retained coordinate axes"))
+ for (field,allowed) in ((:available,x -> x isa Bool),(:engineering_zero,x -> x isa Bool),
+ (:missing_reason,x -> x===nothing || x isa Symbol))
+ value=getproperty(product,field)
+ value===nothing && continue
+ if value isa AbstractArray
+ size(value)==actual || (length(value)==1 && isempty(actual)) || throw(DimensionMismatch("$field shape differs from values"))
+ all(allowed,value) || throw(ArgumentError("invalid $field entries"))
+ else
+ allowed(value) || throw(ArgumentError("invalid $field"))
+ end
+ end
+ if product.thresholds!==nothing
+ _observed_fields(product.thresholds,(:kind,:values,:unit),"quantity thresholds")
+ # Phase eligibility retains physical Cartesian thresholds, not angles.
+ threshold_unit=product.thresholds.unit
+ phase=request_identity(product.request) isa Tuple && angle in request_identity(product.request)
+ threshold_quantity=phase ? quantity(first(request_identity(product.request))) : product.quantity
+ scale_factor(native_unit(threshold_quantity,product.basis),threshold_unit)
+ samples=haskey(c,:samples) ? length(c.samples) : nothing
+ _validate_observed_cutoff(product.thresholds.values,samples)
+ end
+ return nothing
+end
+
+function _validate_observed_cutoff(value,samples)
+ value===nothing && return nothing
+ if value isa NamedTuple
+ foreach(v -> _validate_observed_cutoff(v,samples),values(value))
+ elseif value isa AbstractVector
+ samples===nothing || length(value)==samples || throw(DimensionMismatch("cutoff count differs from samples"))
+ foreach(v -> _validate_observed_cutoff(v,nothing),value)
+ else
+ value isa Real && isfinite(nominal(value)) && nominal(value)>=0 || throw(ArgumentError("cutoffs must be finite and nonnegative"))
+ end
+ return nothing
+end
+
+function _validate_observed_comparison(row)
+ _observed_fields(row,(:result_id,:reference_id,:request,:quantity,:statistic,:band,
+ :normalization,:absolute,:relative,:absolute_unit,:relative_unit,:coordinates,:settings,:maxima),"completed comparison")
+ for id in (row.result_id,row.reference_id)
+ _validate_observed_id(id)
+ end
+ _observed_fields(row.settings,(:basis,:indices,:sample_count,:status,:normalization_reason),"comparison settings")
+ _observed_fields(row.maxima,(:absolute,:relative),"comparison maxima")
+ request_quantity(row.request)==row.quantity || throw(ArgumentError("comparison request and quantity disagree"))
+ row.absolute isa AbstractMatrix && row.relative isa AbstractMatrix && size(row.absolute)==size(row.relative) ||
+ throw(DimensionMismatch("comparison matrices must have matching extents"))
+ length(row.coordinates)==size(row.absolute,1)==size(row.absolute,2) ||
+ throw(DimensionMismatch("comparison coordinates and matrices disagree"))
+ size(row.settings.status)==size(row.absolute)==size(row.settings.normalization_reason) ||
+ throw(DimensionMismatch("comparison status and reason matrices must match the error matrices"))
+ row.settings.sample_count==length(row.settings.indices) || throw(DimensionMismatch("comparison sample count disagrees with indices"))
+ allunique(row.settings.indices) && all(i -> i isa Integer && !(i isa Bool) && i>0,row.settings.indices) ||
+ throw(ArgumentError("comparison sample indices must be distinct positive integers"))
+ for maximum in (row.maxima.absolute,row.maxima.relative)
+ _observed_fields(maximum,(:value,:index),"comparison maximum")
+ end
+ all(ismissing.(row.absolute).==ismissing.(row.relative)) || throw(ArgumentError("absolute and relative errors must share eligibility"))
+ scale_factor(native_unit(row.quantity,row.settings.basis),row.absolute_unit)
+ scale_factor(Units.units(:base,:dimensionless),row.relative_unit)
+ return nothing
+end
+
+function _validate_observed_id(id)
+ _observed_fields(id,(:source_id,:problem_index,:formulation_index),"gridpoint identity")
+ id.source_id===nothing && throw(ArgumentError("identified gridpoints require a source identity"))
+ all(i -> i isa Integer && !(i isa Bool) && i>0,(id.problem_index,id.formulation_index)) ||
+ throw(ArgumentError("point and formulation indices must be positive integers"))
+ return nothing
+end
diff --git a/src/commons/observedresult.jl b/src/commons/observedresult.jl
new file mode 100644
index 000000000..16d20222f
--- /dev/null
+++ b/src/commons/observedresult.jl
@@ -0,0 +1,499 @@
+"""
+$(TYPEDEF)
+
+Retain detached scientific products for one completed gridpoint. Collections
+are ordinary vectors of these objects.
+
+$(TYPEDFIELDS)
+"""
+struct ObservedResult
+ "Original identity, physical inputs, selections, and uncertainty interpretation."
+ gridpoint::NamedTuple
+ "Requested numerical products, coordinates, units, and availability records."
+ quantities::Vector{NamedTuple}
+ "Completed comparisons with result and separate reference identities."
+ errors::Vector{NamedTuple}
+ "Completed execution and performance measurements in their original scopes."
+ timings::NamedTuple
+
+ function ObservedResult(gridpoint::NamedTuple,quantities::AbstractVector,
+ errors::AbstractVector,timings::NamedTuple)
+ foreach(_validate_observed_quantity,quantities)
+ allunique(q.request for q in quantities) || throw(ArgumentError("retained requests must be distinct"))
+ _observed_fields(gridpoint,(:id,),"gridpoint")
+ id=gridpoint.id
+ id===nothing || _validate_observed_id(id)
+ haskey(timings,:result_id) && timings.result_id!=id &&
+ throw(ArgumentError("recorded timings must identify this result"))
+ all(row -> row isa NamedTuple && haskey(row,:result_id) &&
+ haskey(row,:reference_id) && id!==nothing && row.result_id==id,errors) ||
+ throw(ArgumentError("completed comparisons must identify this result and a separate reference"))
+ foreach(_validate_observed_comparison,errors)
+ allunique((row.reference_id,row.request,row.band,row.normalization) for row in errors) ||
+ throw(ArgumentError("completed comparisons must be distinct"))
+ return new(detach(gridpoint),NamedTuple[detach(q) for q in quantities],
+ NamedTuple[detach(row) for row in errors],detach(timings))
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Distinguish a bare selector or one composed or indexed request from a tuple of
+requests using the source's declared observable identities. This operation
+only resolves syntax. [`observation_requests`](@ref) resolves scientific meaning.
+"""
+function observation_selection(source,selection)
+ selection===nothing && return ()
+ selection isa Function && return (selection,)
+ selection isa Tuple || throw(ArgumentError("selection must be a selector or tuple of requests"))
+ isempty(selection) && return ()
+ if first(selection) isa Function
+ any(item -> item isa Tuple && !isempty(item) && first(item) isa Function,
+ selection[2:end]) && return selection
+ identity=request_identity(selection)
+ declared=source isa ObservedResult ? Tuple(request_identity(q.request) for q in source.quantities) : observables(typeof(source))
+ normalize_observation_selector(identity) in normalize_observation_selector.(declared) &&
+ (identity isa Tuple || !isempty(request_indices(selection))) && return (selection,)
+ end
+ return selection
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Normalize requests for retained observations. Concrete scientific
+owners enforce their complete representations. `complete_pairs=true` is used
+by raw display conveniences. An observed-input consumer only selects retained
+products. The return record separates `retained` from `displayed` requests.
+"""
+function observation_requests(source,requests::Tuple;complete_pairs::Bool=false)
+ retained=isempty(requests) ? observables(typeof(source)) : requests
+ validate_observables(source,retained,())
+ return (;retained,displayed=retained)
+end
+
+function observation_requests(source::ObservedResult,requests::Tuple;complete_pairs::Bool=false)
+ identities=request_identity.(getproperty.(source.quantities,:request))
+ selected=isempty(requests) ? Tuple(count(==(identity),identities)==1 ? identity : product.request
+ for (identity,product) in zip(identities,source.quantities)) : requests
+ displayed=Any[]
+ for request in selected
+ identity=request_identity(request)
+ component_selector=normalize_observation_selector(identity)
+ if !isequal(component_selector,identity)
+ prefix=component_selector isa Tuple ? component_selector : (component_selector,)
+ selection=(prefix...,request_indices(request)...)
+ observation_product(source,selection)
+ push!(displayed,selection)
+ continue
+ end
+ selector=identity isa Base.Fix2 ? identity.f : identity
+ family=identity isa Function ? nameof(selector) : nothing
+ products=filter(source.quantities) do product
+ prefix=request_identity(product.request)
+ retained_selector=prefix isa Tuple ? first(prefix) : prefix
+ get(product,:family,nothing)===family &&
+ get(product,:statistic,nothing)===:value &&
+ (identity isa Base.Fix2 ? isequal(retained_selector,identity) :
+ !(retained_selector isa Base.Fix2))
+ end
+ if isempty(products)
+ observation_product(source,request)
+ push!(displayed,request)
+ else
+ for product in products
+ indices=request_indices(request)
+ prefix=request_identity(product.request)
+ selector=prefix isa Tuple ? prefix : (prefix,)
+ selection=isempty(indices) ? prefix : (selector...,indices...)
+ observation_product(source,selection)
+ push!(displayed,selection)
+ end
+ end
+ end
+ result=Tuple(displayed)
+ allunique(result) || throw(ArgumentError("observation requests must be distinct"))
+ return (retained=result,displayed=result)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Extract one owned numerical product with coordinates and units. Scientific
+owners extend this operation to describe their tensor coordinates and apply
+their numerical resolution. The fallback provides ordinary array coordinates.
+"""
+function observation_quantity(source,request;unit=nothing,clip=true,atol=nothing,frequencies=nothing)
+ values=if request isa Function
+ observe(source,request)
+ elseif request isa Tuple
+ observe(source,request...)
+ else
+ throw(ArgumentError("observable requests must be selector functions or nonempty tuples"))
+ end
+ q=request_quantity(request)
+ native=native_unit(q,basis(source))
+ displayed=unit===nothing ? display_unit(q,basis(source)) : unit
+ coordinate=(kind=:array,indices=request_indices(request),
+ extent=values isa AbstractArray ? size(values) : (),)
+ return (request,quantity=q,family=:other,statistic=:value,
+ values=detach(values,scale_factor(native,displayed)),unit=displayed,
+ basis=basis(source),coordinates=coordinate,thresholds=nothing,
+ available=nothing,engineering_zero=nothing,clipped=false,missing_reason=nothing)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct one detached observation from a completed primary result and already
+completed comparison and timing records.
+
+# Keywords
+
+- `comparisons=()`: completed records identifying this result and its reference.
+- `timings=(;)`: recorded measurements. Absent evidence stays absent.
+- `gridpoint=nothing`: explicit description for an external numerical source.
+- `clip=true`, `atol=nothing`: replace assessed values at or below the native-unit
+ cutoffs with exact zero, including zero uncertainty. Unresolved phase is
+ unavailable. Source results and values outside the cutoffs are unchanged.
+- `length_unit=:kilo`, `frequency_unit=:base`: display-unit prefixes.
+- `units=()`, `quantity_units=nothing`: aligned or quantity-keyed unit overrides.
+- `frequencies=nothing`: frequency context \\[Hz\\] for standalone numerical tensors.
+- `complete_pairs=false`: complete a raw display selection through the shared normalizer.
+"""
+function ObservedResult(source,requests::Tuple=();comparisons=(),timings=(;),gridpoint=nothing,
+ clip::Bool=true,atol=nothing,units::Tuple=(),length_unit::Symbol=:kilo,
+ frequency_unit::Symbol=:base,quantity_units=nothing,frequencies=nothing,
+ complete_pairs::Bool=false)
+ selected=observation_requests(source,requests;complete_pairs).retained
+ isempty(units) || length(units)==length(selected) || throw(DimensionMismatch(
+ "one display unit is required for each retained quantity"))
+ isempty(units) || quantity_units===nothing || throw(ArgumentError("use units or quantity_units, not both"))
+ atol isa Real && length(unique(request_quantity.(selected)))>1 && throw(ArgumentError(
+ "multiple quantities require component-keyed native-unit cutoffs"))
+ description=gridpoint===nothing ? observation_gridpoint(source) : gridpoint
+ quantities=map(eachindex(selected)) do index
+ request=selected[index]
+ q=request_quantity(request)
+ unit=isempty(units) ? q isa Units.Quantity{:frequency} ? Units.units(frequency_unit,:hertz) :
+ display_unit(q,basis(source),_unit_override(quantity_units,request);length_prefix=length_unit) :
+ display_unit(q,basis(source),units[index];length_prefix=length_unit)
+ record=observation_quantity(source,request;unit,clip,atol,frequencies)
+ if haskey(record.coordinates,:frequencies) && record.coordinates.frequencies!==nothing
+ frequency_target=Units.units(frequency_unit,:hertz)
+ f=record.coordinates.frequencies
+ factor=isempty(f) ? 1 : scale_factor(Units.units(:base,:hertz),frequency_target,typeof(float(nominal(first(f)))))
+ record=merge(record,(coordinates=merge(record.coordinates,
+ (frequencies=detach(f,factor),frequency_unit=frequency_target)),))
+ end
+ record
+ end
+ return ObservedResult(description,quantities,collect(comparisons),timings)
+end
+
+function observation_request(observed::ObservedResult,request)
+ identity=request_identity(request)
+ any(q -> isequal(normalize_observation_selector(request_identity(q.request)),
+ normalize_observation_selector(identity)),observed.quantities) ||
+ throw(ArgumentError("the requested quantity was not retained"))
+ return (;identity,quantity=request_quantity(request),indices=request_indices(request))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Read a retained quantity in its recorded unit. Selection uses original
+coordinates. No source extraction, clipping, or absent-quantity derivation occurs.
+"""
+function observe(observed::ObservedResult,selectors...)
+ request=length(selectors)==1 ? only(selectors) : selectors
+ return detach(observation_product(observed,request).values)
+end
+
+function basis(observed::ObservedResult)
+ bases=unique(q.basis for q in observed.quantities)
+ length(bases)==1 || throw(ArgumentError("observation does not have one quantity basis"))
+ return only(bases)
+end
+
+observation_gridpoint(observed::ObservedResult) = detach(observed.gridpoint)
+function Base.show(io::IO,observed::ObservedResult)
+ print(io,"ObservedResult(",length(observed.quantities)," quantities, ",length(observed.errors)," comparisons)")
+end
+Base.show(io::IO,::MIME"text/plain",observed::ObservedResult) = show(io,observed)
+Base.summary(io::IO,observed::ObservedResult) = show(io,observed)
+
+"""
+$(TYPEDSIGNATURES)
+
+Lift atomic observation over an ordinary collection. Completed comparisons and
+point timings are joined by original identities before detachment, so filtering
+and reordering cannot associate a result with another point's evidence.
+"""
+function observables(sources::Union{AbstractVector,Tuple,AbstractResultSpace},requests::Tuple=();
+ comparisons=nothing,timings=nothing,kwargs...)
+ return map(collect(sources)) do source
+ id=get(observation_gridpoint(source),:id,nothing)
+ errors=comparisons===nothing ? (source isa ObservedResult ? source.errors : ()) :
+ filter(record -> record.result_id==id,comparisons)
+ recorded=timings===nothing ? (source isa ObservedResult ? source.timings : (;)) : timings isa NamedTuple ? timings : begin
+ matches=filter(record -> record.result_id==id,timings)
+ length(matches)<=1 || throw(ArgumentError("multiple timing records for one result identity"))
+ isempty(matches) ? (;) : only(matches)
+ end
+ ObservedResult(source,requests;comparisons=errors,timings=recorded,kwargs...)
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Select a retained quantity record and optional original coordinates. Missing or
+ambiguous requests fail.
+"""
+function observation_product(observed::ObservedResult,request;unit=nothing,frequency_unit=nothing)
+ identity=normalize_observation_selector(request_identity(request))
+ matches=filter(q -> isequal(normalize_observation_selector(request_identity(q.request)),identity),
+ observed.quantities)
+ length(matches)>1 && (matches=filter(q -> q.request==request,matches))
+ length(matches)==1 || throw(ArgumentError("requested retained product is absent or ambiguous"))
+ product=only(matches)
+ indices=request_indices(request)
+ isempty(indices) || request==product.request || (product=_selected_product(product,request,indices))
+ return _reexpress_product(product;unit,frequency_unit)
+end
+
+function _selected_product(product,request,indices)
+ c=product.coordinates
+ c.kind in (:matrix,:diagonal,:vector,:assemblies,:samples) || throw(ArgumentError("select this retained product by its complete request"))
+ matrix=haskey(c,:rows)
+ dimensions=c.kind===:vector ? (c.positions,c.samples) :
+ matrix ? c.kind===:diagonal ? (c.rows,c.samples) : (c.rows,c.columns,c.samples) : (c.assemblies,)
+ if c.kind===:samples
+ dimensions=(dimensions...,c.trials)
+ length(indices)==length(dimensions)-1 && (indices=(indices...,Colon()))
+ end
+ length(indices)==length(dimensions) || throw(DimensionMismatch("request rank differs from retained coordinates"))
+ positions=map(indices,dimensions) do requested,retained
+ wanted=requested isa Colon ? retained : requested isa Integer ? [requested] : collect(requested)
+ selected=map(wanted) do index
+ position=findfirst(==(index),retained)
+ position===nothing && throw(ArgumentError("coordinate $index was not retained"))
+ position
+ end
+ requested isa Integer ? only(selected) : selected
+ end
+ selected_dimensions=map(dimensions,positions) do dimension,position
+ position isa Integer ? [dimension[position]] : dimension[position]
+ end
+ select_values(value)=value isa AbstractArray ? reshape(value,length.(dimensions)...)[positions...] : value
+ sample_axis=c.kind===:vector || c.kind===:diagonal ? 2 : matrix ? 3 : nothing
+ f=if c.frequencies===nothing || sample_axis===nothing
+ c.frequencies
+ else
+ selected_samples=positions[sample_axis]
+ c.frequencies[selected_samples isa Integer ? [selected_samples] : selected_samples]
+ end
+ coordinate=c.kind===:vector ? merge(c,(indices,positions=first(selected_dimensions),
+ samples=last(selected_dimensions),frequencies=f)) :
+ matrix ? merge(c,(indices,rows=first(selected_dimensions),
+ columns=c.kind===:diagonal ? first(selected_dimensions) : selected_dimensions[2],
+ samples=selected_dimensions[sample_axis],frequencies=f)) :
+ merge(c,(indices,assemblies=first(selected_dimensions)))
+ c.kind===:samples && (coordinate=merge(coordinate,(trials=last(selected_dimensions),)))
+ thresholds=product.thresholds
+ if thresholds!==nothing && sample_axis!==nothing
+ select_cutoff(cutoff::AbstractVector)=cutoff[positions[sample_axis]]
+ select_cutoff(cutoff::NamedTuple)=map(select_cutoff,cutoff)
+ select_cutoff(cutoff)=cutoff
+ thresholds=merge(thresholds,(values=select_cutoff(thresholds.values),))
+ end
+ components=get(product,:unavailable_components,nothing)
+ components===nothing || (components=merge(components,(nominal_magnitude=select_values(components.nominal_magnitude),
+ real=select_values(components.real),imaginary=select_values(components.imaginary))))
+ result=merge(product,(request,values=select_values(product.values),coordinates=coordinate,
+ available=select_values(product.available),engineering_zero=select_values(product.engineering_zero),
+ missing_reason=select_values(product.missing_reason),thresholds))
+ return haskey(product,:unavailable_components) ? merge(result,(unavailable_components=components,)) : result
+end
+
+# Numeric equality also checks the uncertainty graph. For a dependency-aware
+# number, equal marginal deviations alone do not make the difference certain.
+_same_observed_values(a::Number,b::Number) = isequal(nominal(a),nominal(b)) &&
+ isequal(uncertainty(a),uncertainty(b)) && iszero(uncertainty(a-b))
+_same_observed_values(a::AbstractArray,b::AbstractArray) = size(a)==size(b) && all(_same_observed_values.(a,b))
+_same_observed_values(a::Tuple,b::Tuple) = length(a)==length(b) && all(_same_observed_values(x,y) for (x,y) in zip(a,b))
+_same_observed_values(a::NamedTuple,b::NamedTuple) = keys(a)==keys(b) && all(_same_observed_values(x,y) for (x,y) in zip(values(a),values(b)))
+_same_observed_values(a,b) = isequal(a,b)
+
+_same_observed_dependencies(a::Number,b::Number) = iszero(uncertainty(a-b))
+_same_observed_dependencies(a::AbstractArray,b::AbstractArray) = size(a)==size(b) && all(_same_observed_dependencies.(a,b))
+_same_observed_dependencies(a::NamedTuple,b::NamedTuple) = keys(a)==keys(b) && all(_same_observed_dependencies(x,y) for (x,y) in zip(values(a),values(b)))
+_same_observed_dependencies(a,b) = true
+
+"""
+$(TYPEDSIGNATURES)
+
+Return display groups and every original member identity for one retained
+request. Eligibility is established by physical-point identity, owner-selected
+formulations and controls, statistical interpretation, coordinates, and applied
+cutoffs. Exact numerical and uncertainty-dependency agreement only verifies an
+already established equivalence. Unidentified assumptions remain separate.
+"""
+function observation_groups(observed;request,band=nothing,normalization=nothing,reference=nothing)
+ points=observed isa ObservedResult ? [observed] : observed
+ groups=NamedTuple[]
+ keys=Any[]
+ products=Any[]
+ for (index,point) in enumerate(points)
+ product=if band===nothing
+ observation_product(point,request)
+ else
+ matches=filter(row -> row.request==request && isequal(row.band,band) &&
+ row.normalization==normalization && (reference===nothing || row.reference_id==reference),point.errors)
+ length(matches)==1 || throw(ArgumentError("requested completed comparison is absent or ambiguous"))
+ row=only(matches)
+ (quantity=row.quantity,statistic=row.statistic,coordinates=row.coordinates,
+ basis=get(row.settings,:basis,nothing),unit=row.absolute_unit,
+ thresholds=(reference=get(row.settings,:atol,nothing),result=get(row.settings,:result_atol,nothing)),
+ available=get(row.settings,:status,nothing),engineering_zero=nothing,
+ assumptions=get(row,:assumptions,nothing),values=(absolute=row.absolute,relative=row.relative),
+ interpretation=get(row.settings,:estimators,nothing))
+ end
+ id=get(point.gridpoint,:id,nothing)
+ assumptions=get(product,:assumptions,nothing)
+ physical=id===nothing ? nothing : (id.source_id,id.problem_index)
+ identity=band===nothing ? request_identity(product.request) : request_identity(request)
+ selector=identity isa Tuple ? first(identity) : identity
+ key=(physical,assumptions,selector isa Base.Fix2 ? identity : nothing,
+ product.quantity,product.statistic,
+ get(point.gridpoint,:uncertainty,nothing),product.coordinates,product.basis,
+ product.unit,product.thresholds,
+ band,normalization,reference,get(product,:interpretation,nothing))
+ semantic=physical===nothing || assumptions===nothing || ismissing(assumptions) ? Int[] :
+ findall(i -> _same_observed_values(keys[i],key),eachindex(keys))
+ # The uncertainty interpretation includes the actual dependency graph.
+ # Equal scalar means alone do not establish that interpretation.
+ same_interpretation=filter(i -> _same_observed_dependencies(products[i].values,product.values),semantic)
+ for i in same_interpretation
+ _same_observed_values(products[i].values,product.values) || throw(ArgumentError(
+ "semantically equivalent observations have conflicting numerical values"))
+ end
+ matched=isempty(same_interpretation) ? nothing : first(same_interpretation)
+ if matched===nothing
+ push!(keys,key);push!(products,product)
+ push!(groups,(representative=index,members=[index],identities=[id]))
+ else
+ push!(groups[matched].members,index)
+ push!(groups[matched].identities,id)
+ end
+ end
+ return groups
+end
+
+function _description_values!(output,path,value;name=path,unit="",indices=(),text=nothing)
+ if value isa NamedTuple
+ descriptions=get(value,:field_descriptions,(;))
+ for (key,child) in pairs(value)
+ key in (:frequencies,:field_descriptions,:kind,:system_id,:cable_id) && continue
+ child_path=isempty(path) ? string(key) : path*"."*string(key)
+ field=get(descriptions,key,nothing)
+ child_name=(field===nothing ? string(key) : field.name)*join("[$i]" for i in indices)
+ _description_values!(output,child_path,child;name=child_name,
+ unit=field===nothing ? unit : field.unit,indices,
+ text=field===nothing ? nothing : get(field,:text,nothing))
+ end
+ elseif value isa Union{AbstractArray,Tuple}
+ for (index,child) in enumerate(value)
+ suffix="["*string(index)*"]"
+ _description_values!(output,path*suffix,child;name=name*suffix,unit,indices=(indices...,index),text)
+ end
+ elseif value isa Union{Number,AbstractString,Symbol}
+ output[path]=(;value,name,unit,text)
+ end
+ return output
+end
+
+"""
+Describe differences in captured physical inputs, active methods, and individual
+controls. Labels use the scientific descriptions captured when the computation
+completed. Gridpoint IDs are available in the observation metadata.
+`fallback` supplies text when no captured description or varying field is
+available. `nothing` retains the default positional result label.
+"""
+function observation_labels(observed;request=nothing,fallback=nothing)
+ points=observed isa ObservedResult ? [observed] : observed
+ isempty(points) && return String[]
+ descriptions=[begin
+ values=_description_values!(Dict{String,Any}(),"",get(point.gridpoint,:inputs,nothing))
+ uncertainty=get(point.gridpoint,:uncertainty,nothing)
+ annotations=get(point.gridpoint,:uncertainty_descriptions,nothing)
+ if uncertainty isa NamedTuple && !isempty(uncertainty)
+ annotations!==nothing && all(key -> haskey(annotations,key),keys(uncertainty)) ||
+ throw(ArgumentError("retained uncertainty descriptions are absent; construct observations through the current UQ owner before plotting"))
+ uncertainty=merge(uncertainty,(field_descriptions=annotations,))
+ end
+ _description_values!(values,"uncertainty",uncertainty)
+ values
+ end for point in points]
+ paths=sort(unique(collect(Iterators.flatten(keys(record) for record in descriptions))))
+ varying=filter(paths) do path
+ haskey(first(descriptions),path) || return true
+ original=first(descriptions)[path]
+ any(record -> !haskey(record,path) ||
+ !isequal(record[path].value,original.value) || record[path].unit!=original.unit,descriptions)
+ end
+ fields=map(points) do point
+ retained=get(point.gridpoint,:formulation_fields,(;))
+ quantity=request===nothing ? nothing : request_quantity(request)
+ family=quantity===nothing || isempty(retained) || !applicable(Units.family,quantity) ?
+ :all : Units.family(quantity)===Val(:series) ? :Z : :Y
+ entries=get(retained,family,())
+ all(field -> haskey(field,:meaning) && haskey(field,:control_fields),entries) ||
+ throw(ArgumentError("retained formulation descriptions lack individual control meanings; capture descriptions with the current completion owner before plotting"))
+ entries
+ end
+ # Only active peers establish a varying selection. A missing backend slot
+ # is not an equal selection: the backend summary identifies that difference.
+ peers(field)=[other for entries in fields for other in entries if other.meaning==field.meaning]
+ varies(field)=any(other -> !isequal(other.selection.identifier,field.selection.identifier),peers(field))
+ return map(eachindex(points)) do index
+ parts=String[]
+ methods=filter(varies,fields[index])
+ for field in methods
+ # When differing child methods already identify the computation, use
+ # the backend itself instead of repeating its method summary.
+ text=isempty(field.meaning) && all(other -> isempty(other.meaning),methods) ? field.summary : field.value
+ if !isempty(field.name) && count(other -> !isempty(other.meaning),methods)>1
+ text=field.name*"="*text
+ end
+ push!(parts,text)
+ end
+ for field in fields[index], control in field.control_fields
+ active=[other for peer in peers(field) for other in peer.control_fields
+ if other.scope==control.scope]
+ any(other -> !isequal(other.value,control.value),active) || continue
+ text=control.text
+ isempty(field.name) || (text=field.name*": "*text)
+ push!(parts,text)
+ end
+ for path in varying
+ haskey(descriptions[index],path) || continue
+ field=descriptions[index][path]
+ push!(parts,field.text===nothing ?
+ field.name*"="*string(field.value)*(isempty(field.unit) ? "" : " "*field.unit) : field.text)
+ end
+ if isempty(parts)
+ roots=filter(field -> isempty(field.meaning),fields[index])
+ if !isempty(roots)
+ append!(parts,(field.summary for field in roots))
+ else
+ source_name=get(points[index].gridpoint,:name,nothing)
+ push!(parts,source_name===nothing ?
+ (fallback===nothing ? "Result $index" : string(fallback)) : string(source_name))
+ end
+ end
+ join(parts,", ")
+ end
+end
diff --git a/src/commons/results.jl b/src/commons/results.jl
new file mode 100644
index 000000000..5ff11ece8
--- /dev/null
+++ b/src/commons/results.jl
@@ -0,0 +1,40 @@
+"""
+$(TYPEDSIGNATURES)
+
+Validate `element` as the element type of the result space being built.
+
+The element type must be concrete and cannot itself be a result-space
+envelope. Concrete external result types are accepted without requiring them
+to subtype [`AbstractCoreResult`](@ref).
+
+# Arguments
+
+- `element`: proposed result-space element type.
+- The result-space type being built, or `AbstractResultSpace` for a collection of
+ results.
+
+# Returns
+
+- `element` when it satisfies the result-space invariant.
+
+# Errors
+
+- `ArgumentError`: `element` is abstract, is `Any` or subtypes
+ [`AbstractResultSpace`](@ref).
+"""
+function validate(element::Type{T}, ::Type{<:AbstractResultSpace}) where {T}
+ isconcretetype(element) || throw(ArgumentError(
+ "result-space element type must be concrete; got $element",
+ ))
+ element <: AbstractResultSpace && throw(ArgumentError(
+ "a result space cannot contain another result-space envelope",
+ ))
+ return element
+end
+
+#! explicit-imports: off
+# Base's iterator trait protocol exposes these values without public bindings.
+Base.IteratorSize(::Type{<:AbstractResultSpace}) = Base.HasShape{1}()
+Base.IteratorEltype(::Type{<:AbstractResultSpace}) = Base.HasEltype()
+#! explicit-imports: on
+Base.eltype(::Type{<:AbstractResultSpace{T}}) where {T} = T
diff --git a/src/commons/retained_products.jl b/src/commons/retained_products.jl
new file mode 100644
index 000000000..66e36ec75
--- /dev/null
+++ b/src/commons/retained_products.jl
@@ -0,0 +1,197 @@
+# Re-expression uses recorded units. It never enters source acquisition or
+# changes the retained numerical eligibility decision.
+_observed_scale(value::Missing,from,to) = value
+_observed_scale(value::Nothing,from,to) = value
+_observed_scale(value::AbstractArray,from,to) = map(x -> _observed_scale(x,from,to),value)
+_observed_scale(value::NamedTuple,from,to) = map(x -> _observed_scale(x,from,to),value)
+_observed_scale(value::Tuple,from,to) = map(x -> _observed_scale(x,from,to),value)
+function _observed_scale(value::Number,from,to)
+ from==to && return value
+ T=typeof(float(real(nominal(value))))
+ convert_value()=value*scale_factor(from,to,T)
+ return T===BigFloat ? setprecision(convert_value,BigFloat,precision(real(nominal(value)))) : convert_value()
+end
+
+function _reexpress_product(product;unit=nothing,frequency_unit=nothing)
+ target=unit===nothing ? product.unit : unit
+ scale_factor(product.unit,target) # Check dimensions even if all values are missing.
+ result=product
+ if target!=product.unit
+ values=if product.coordinates.kind===:histogram
+ merge(product.values,(
+ lower=_observed_scale(product.values.lower,product.unit,target),
+ upper=_observed_scale(product.values.upper,product.unit,target),
+ density=_observed_scale(product.values.density,inv(product.unit),inv(target))))
+ else
+ _observed_scale(product.values,product.unit,target)
+ end
+ thresholds=product.thresholds
+ if thresholds!==nothing && thresholds.unit==product.unit
+ thresholds=merge(thresholds,(values=_observed_scale(thresholds.values,product.unit,target),unit=target))
+ end
+ result=merge(product,(values,unit=target,thresholds))
+ if product.coordinates.kind===:histogram
+ d=product.distribution
+ cdf(c)=c===nothing ? nothing : merge(c,(x=_observed_scale(c.x,product.unit,target),))
+ distribution=merge(d,(edges=_observed_scale(d.edges,product.unit,target),
+ empirical_cdf=cdf(d.empirical_cdf),model_cdf=cdf(d.model_cdf),
+ qq=d.qq===nothing ? nothing : _observed_scale(d.qq,product.unit,target)))
+ result=merge(result,(distribution,ordinate_units=merge(product.ordinate_units,(density=inv(target),))))
+ end
+ end
+ c=result.coordinates
+ if frequency_unit!==nothing && haskey(c,:frequencies) && c.frequencies!==nothing
+ target_frequency=frequency_unit isa Symbol ? Units.units(frequency_unit,:hertz) : frequency_unit
+ scale_factor(c.frequency_unit,target_frequency)
+ result=merge(result,(coordinates=merge(c,(
+ frequencies=_observed_scale(c.frequencies,c.frequency_unit,target_frequency),
+ frequency_unit=target_frequency)),))
+ end
+ return result
+end
+
+function _retained_unit(product,override,length_unit)
+ override isa UnitExpr && return override
+ override===nothing || override isa Symbol || throw(ArgumentError("unit override must be a unit expression or metric prefix"))
+ numerator=product.unit.numerator
+ if override!==nothing && !isempty(numerator)
+ numerator=(Units.Unit(first(numerator).name,override),Base.tail(numerator)...)
+ end
+ denominator=map(product.unit.denominator) do unit
+ length_unit!==nothing && unit.name===:meter ? Units.Unit(:meter,length_unit) : unit
+ end
+ return UnitExpr(numerator,denominator)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Select or re-express an existing observation without acquiring a source.
+Omitted unit options preserve recorded units. Explicit units convert from each
+product's recorded unit. Frequency conversion preserves sample identities.
+Coordinates, availability, uncertainty dependencies, comparisons, and timing
+associations remain retained. New clipping, thresholds, or frequency samples
+require a new observation of the primary result.
+"""
+function ObservedResult(source::ObservedResult,requests::Tuple=();
+ comparisons=nothing,timings=nothing,gridpoint=nothing,clip=nothing,atol=nothing,
+ units::Tuple=(),length_unit::Union{Nothing,Symbol}=nothing,
+ frequency_unit=nothing,quantity_units=nothing,frequencies=nothing,
+ complete_pairs::Bool=false)
+ atol===nothing && frequencies===nothing || throw(ArgumentError(
+ "retained observations cannot apply new cutoffs or recover frequency samples"))
+ gridpoint===nothing || isequal(gridpoint,source.gridpoint) || throw(ArgumentError("retained gridpoint identity cannot be replaced"))
+ comparisons===nothing || isequal(collect(comparisons),source.errors) || throw(ArgumentError("retained comparisons cannot be replaced"))
+ timings===nothing || isequal(timings,source.timings) || throw(ArgumentError("retained timing associations cannot be replaced"))
+ selected=observation_requests(source,requests;complete_pairs).retained
+ isempty(units) || length(units)==length(selected) || throw(DimensionMismatch("units must align with retained requests"))
+ isempty(units) || quantity_units===nothing || throw(ArgumentError("use units or quantity_units, not both"))
+ products=map(eachindex(selected)) do index
+ request=selected[index]
+ product=observation_product(source,request)
+ clip===nothing || clip===product.clipped || throw(ArgumentError("retained clipping cannot be changed"))
+ override=isempty(units) ? _unit_override(quantity_units,request) : units[index]
+ unit=_retained_unit(product,override,length_unit)
+ _reexpress_product(product;unit,frequency_unit)
+ end
+ return ObservedResult(source.gridpoint,products,source.errors,source.timings)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Interpret a retained request across observations. Convert compatible
+quantity and frequency units to the first product's units (or explicit targets),
+and order coefficients by the first product's original coordinates. Every trace
+retains its samples. Missing coefficients and incompatible units fail without
+interpolation, numerical acquisition, or changes to scientific eligibility.
+With `band`, select original sample identities from completed comparison
+records. `reference_id` disambiguates the recorded reference. A separately
+included reference uses the same unambiguous saved selection. Missing or
+conflicting records fail before returning any products.
+"""
+function observation_product(points::Union{Tuple,AbstractVector{<:ObservedResult}},request;
+ unit=nothing,frequency_unit=nothing,band=nothing,reference_id=nothing)
+ isempty(points) && throw(ArgumentError("at least one observation is required"))
+ first_product=observation_product(first(points),request)
+ target=something(unit,first_product.unit)
+ coordinate=first_product.coordinates
+ frequency_target=frequency_unit===nothing ? get(coordinate,:frequency_unit,nothing) : frequency_unit
+ selections=band===nothing ? nothing : _retained_band_samples(points,request,band,reference_id)
+ products=map(eachindex(points)) do index
+ point=points[index]
+ product=observation_product(point,request)
+ c=product.coordinates
+ c.kind==coordinate.kind || throw(ArgumentError("overlaid products require the same coordinate kind"))
+ if c.kind in (:matrix,:diagonal,:vector)
+ aligned=c.kind===:vector ? Set(c.positions)==Set(coordinate.positions) :
+ Set(c.rows)==Set(coordinate.rows) && Set(c.columns)==Set(coordinate.columns)
+ aligned ||
+ throw(DimensionMismatch("overlaid products must retain the requested original coefficients"))
+ reordered=c.kind===:vector ? c.positions!=coordinate.positions :
+ c.rows!=coordinate.rows || c.columns!=coordinate.columns
+ if reordered
+ identity=request_identity(product.request)
+ prefix=identity isa Tuple ? identity : (identity,)
+ indices=c.kind===:matrix ? (coordinate.rows,coordinate.columns,Colon()) :
+ c.kind===:vector ? (coordinate.positions,Colon()) : (coordinate.rows,Colon())
+ product=_selected_product(product,(prefix...,indices...),indices)
+ end
+ end
+ if selections!==nothing
+ c=product.coordinates
+ c.kind in (:matrix,:diagonal,:vector) || throw(ArgumentError(
+ "retained band selection requires frequency coordinates"))
+ samples=filter(in(selections[index]),c.samples)
+ identity=request_identity(product.request)
+ prefix=identity isa Tuple ? identity : (identity,)
+ indices=c.kind===:matrix ? (c.rows,c.columns,samples) :
+ c.kind===:vector ? (c.positions,samples) : (c.rows,samples)
+ product=_selected_product(product,(prefix...,indices...),indices)
+ end
+ detach(_reexpress_product(product;unit=target,frequency_unit=frequency_target))
+ end
+ selections===nothing || any(p -> !isempty(p.coordinates.samples),products) ||
+ throw(ArgumentError("comparison band $(repr(band)) contains no retained samples in the supplied observations"))
+ return products
+end
+
+# Comparison records, not plotting-side frequency rules, define a retained band.
+function _retained_band_samples(points,request,band,reference_id)
+ matching=map(points) do point
+ id=get(point.gridpoint,:id,nothing)
+ filter(point.errors) do row
+ isequal(row.result_id,id) && isequal(row.band,band) &&
+ request_identity(row.request)==request_identity(request) &&
+ (reference_id===nothing || isequal(row.reference_id,reference_id))
+ end
+ end
+ records=collect(Iterators.flatten(matching))
+ isempty(records) && throw(ArgumentError("no completed comparison retains band $(repr(band)) for this request and reference"))
+ references=unique(row.reference_id for row in records)
+ length(references)==1 || throw(ArgumentError("retained band has multiple references; supply reference_id"))
+ retained_reference=only(references)
+ definition(row)=(;
+ indices=row.settings.indices,
+ requested_bounds=get(row.settings,:requested_bounds,nothing),
+ actual_bounds=get(row.settings,:actual_bounds,nothing),
+ fundamental=get(row.settings,:fundamental,nothing),
+ harmonics=get(row.settings,:harmonics,nothing))
+ first_definition=definition(first(records))
+ all(row -> isequal(definition(row),first_definition),records) || throw(ArgumentError(
+ "completed comparisons disagree on the saved definition or sample selection of band $(repr(band))"))
+ return map(eachindex(points)) do index
+ id=get(points[index].gridpoint,:id,nothing)
+ !isempty(matching[index]) || isequal(id,retained_reference) || throw(ArgumentError(
+ "observation $(repr(id)) has no completed comparison for band $(repr(band)) and the selected reference"))
+ copy(first_definition.indices)
+ end
+end
+
+# Direct owner dispatch obeys the same retained-input requirements as construction.
+function observation_quantity(source::ObservedResult,request;unit=nothing,clip=nothing,atol=nothing,frequencies=nothing)
+ atol===nothing && frequencies===nothing || throw(ArgumentError("retained quantities cannot apply new cutoffs or samples"))
+ product=observation_product(source,request;unit)
+ clip===nothing || clip===product.clipped || throw(ArgumentError("retained clipping cannot be changed"))
+ return detach(product)
+end
diff --git a/src/commons/types.jl b/src/commons/types.jl
new file mode 100644
index 000000000..906007b21
--- /dev/null
+++ b/src/commons/types.jl
@@ -0,0 +1,206 @@
+"""
+$(TYPEDEF)
+
+Supertype for complete LineCableModels computation inputs.
+"""
+abstract type AbstractProblemDefinition end
+
+"""
+$(TYPEDEF)
+
+Supertype for scientific and higher-order computation selections.
+"""
+abstract type AbstractFormulation end
+
+"""
+$(TYPEDEF)
+
+Supertype for completed LineCableModels computation results.
+"""
+abstract type AbstractProblemResult end
+
+"""
+$(TYPEDEF)
+
+Supertype for direct results owned by LineCableModels computations.
+"""
+abstract type AbstractCoreResult <: AbstractProblemResult end
+
+"""
+$(TYPEDEF)
+
+Supertype for completed finite collections whose element type is `T`.
+"""
+abstract type AbstractResultSpace{T} <: AbstractProblemResult end
+
+"""
+$(TYPEDEF)
+
+Supertype for deterministic result spaces whose element type is `T`.
+"""
+abstract type AbstractParametricResult{T} <: AbstractResultSpace{T} end
+
+"""
+$(TYPEDEF)
+
+Supertype for uncertainty result spaces whose element type is `T`.
+"""
+abstract type AbstractUncertaintyResult{T} <: AbstractResultSpace{T} end
+
+"""
+$(TYPEDEF)
+
+Retain formulation-owned inputs. The selected formulation defines defaults and
+validation. Construction preserves the supplied named tuple, including the
+identity of mutable values within it.
+
+$(TYPEDFIELDS)
+"""
+struct FormulationOptions{NT <: NamedTuple}
+ "Supplied formulation options."
+ data::NT
+ FormulationOptions(data::NamedTuple) = new{typeof(data)}(data)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct formulation inputs from keywords, or an empty record with no keywords.
+Owner-specific interpretation and validation occur when the formulation resolves
+the options, not when this record is constructed.
+"""
+FormulationOptions(; kwargs...) = FormulationOptions((; kwargs...))
+
+"""
+$(TYPEDEF)
+
+Retain computation-owned inputs. The receiving computation or backend owns
+defaults and validation.
+
+$(TYPEDFIELDS)
+"""
+struct ComputationOptions{NT <: NamedTuple}
+ "Supplied computation options."
+ data::NT
+ ComputationOptions(data::NamedTuple) = new{typeof(data)}(data)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct computation inputs from keywords, or an empty record with no keywords.
+The receiving computation validates the supported keys and values.
+"""
+ComputationOptions(; kwargs...) = ComputationOptions((; kwargs...))
+
+"""
+$(TYPEDEF)
+
+Retain computation-owned supplemental output. The producing computation defines its contents. Immutability is shallow: arrays and other mutable payload values
+are neither copied nor frozen. Nested diagnostic type bounds are preserved.
+
+$(TYPEDFIELDS)
+"""
+struct ComputationDetails{NT <: NamedTuple}
+ "Named-tuple supplemental output, accessed explicitly through `.data`."
+ data::NT
+ ComputationDetails(data::NamedTuple) = new{typeof(data)}(data)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct supplemental output from keywords. With no keywords, return an empty
+record indicating that the computation supplied no supplemental output.
+"""
+ComputationDetails(; kwargs...) = ComputationDetails((; kwargs...))
+
+"""
+$(TYPEDEF)
+
+Store one passive formula selection until its defining formulation resolves the
+identifier, model parameters, and formulation-owned physical and numerical options.
+
+$(TYPEDFIELDS)
+"""
+struct FormulaDefinition{ID, Order, P <: NamedTuple, O <: FormulationOptions, E}
+ "Explicit model parameters without evaluated physical state."
+ parameters::P
+ "Explicit physical choices and numerical controls owned by the selected equation."
+ options::O
+ "Optional equivalent homogeneous-earth selection."
+ equivalent_earth::E
+end
+
+"""
+$(TYPEDEF)
+
+Hold the formula object, the operation and the `Val` selectors of one expression of a
+formula. Calling the expression passes the formula, the selectors and the runtime arguments
+to the operation, in that order.
+
+$(TYPEDFIELDS)
+"""
+struct Expression{S, F, A <: Tuple}
+ "Selected formula whose concrete type selects the operation's method."
+ selection::S
+ "Native domain method accepting the selected formulation first."
+ method::F
+ "Semantic Val selectors inserted before runtime arguments."
+ arguments::A
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the expression of `selection` for the operation `method` and optional `Val` selectors.
+Throw `ArgumentError` if a selector is not a `Val` instance.
+"""
+function Expression(selection::S, method::F, arguments...) where {S, F}
+ all(argument -> argument isa Val, arguments) || throw(ArgumentError(
+ "Expression semantic selectors must be Val instances"))
+ return Expression{S, F, typeof(arguments)}(selection, method, arguments)
+end
+
+@inline function (expression::Expression)(arguments...)
+ return expression.method(expression.selection, expression.arguments..., arguments...)
+end
+
+"""
+$(TYPEDEF)
+
+Hold a formula resolved at one evaluation point: the formula, the input of that point and the
+values that the formula's expressions share there. `expression(functor, workspace)` evaluates
+an expression of the formula at that point. The state holds plain values. Arrays come from
+the buffers of the workspace.
+
+$(TYPEDFIELDS)
+"""
+struct Functor{F, I <: NamedTuple, S <: NamedTuple}
+ "Formula whose expressions this Functor evaluates."
+ formula::F
+ "Inputs of the evaluation point, with the options of the expression evaluated there."
+ input::I
+ "Plain values that the expressions of the formula share at that point."
+ state::S
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the Functor of `formula` at the point that `input` describes. A formula without a method
+of its own does not share values, and its state is empty. A family or a formula that checks its
+input, shares values or reads arrays from the buffers of `workspace` adds a method on its own
+type.
+"""
+Functor(formula, input::NamedTuple; workspace = nothing) = Functor(formula, input, (;))
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the Functor of the same formula and state at a point whose input extends
+`functor.input` with `extension`, such as one conductor pair of an earth calculation.
+"""
+function Functor(functor::Functor, extension::NamedTuple)
+ return Functor(functor.formula, merge(functor.input, extension), functor.state)
+end
diff --git a/src/commons/uncertainty.jl b/src/commons/uncertainty.jl
new file mode 100644
index 000000000..57bbf4043
--- /dev/null
+++ b/src/commons/uncertainty.jl
@@ -0,0 +1,14 @@
+"""
+Return the nominal value of a deterministic or uncertain quantity.
+"""
+nominal(value) = value
+nominal(value::Complex) = complex(nominal(real(value)), nominal(imag(value)))
+nominal(values::AbstractArray) = nominal.(values)
+
+"""
+Return the standard uncertainty of a quantity. A deterministic number returns the zero
+of its own type.
+"""
+uncertainty(value::Number) = zero(value)
+uncertainty(value::Complex) = complex(uncertainty(real(value)), uncertainty(imag(value)))
+uncertainty(values::AbstractArray) = uncertainty.(values)
diff --git a/src/datamodel/DataModel.jl b/src/datamodel/DataModel.jl
index 4d964a91b..42c9ad470 100644
--- a/src/datamodel/DataModel.jl
+++ b/src/datamodel/DataModel.jl
@@ -1,104 +1,117 @@
"""
- LineCableModels.DataModel
+ 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.
+Define the physical cable object model and completed line arrangements.
# Overview
-- Provides objects for detailed cable modeling with the [`CableDesign`](@ref) and supporting types: [`CircStrands`](@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.
+- Declare primitives, material-bearing regions, and resolved geometry.
+- Compose regions through [`Stack`](@ref), [`Group`](@ref),
+ [`Assembly`](@ref), and [`Enclosure`](@ref).
+- Build one physical declaration as a completed [`CableDesign`](@ref).
+- Place completed designs and resolve connections as a [`LineCableSystem`](@ref).
+- Expose detached cable geometry and physical material ranges to consumers.
+- Store cable designs in [`CablesLibrary`](@ref).
# Dependencies
$(IMPORTS)
-# Exports
-
-$(EXPORTS)
"""
module DataModel
# Export public API
-export Thickness, Diameter # Type definitions
-export CircStrands, RectStrands, Strip, Tubular, SectorParams, Sector # Conductor types
-export Semicon, Insulator, SectorInsulator # 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, get_outer_radius, MaxFill # Helpers
-export preview, equivalent
+export CableDesign, CableGeometry, PlacedRegion, LineCableSystem
+export build, homogenize
+export CablesLibrary, DatasheetInfo, datasheet
+export trefoil_formation, flat_formation, outer_radius
+export AbstractShape, AbstractPrimitive
+export AbstractCablePart, Region, Stack
+export Group, Assembly
+export Enclosure
+export Disk, Rectangle, Ellipse, Sector, Annulus, Polygon, Shell
+export Pose2
+export EmptyBoundary
+export resolve, boundary, area, perimeter, centroid, support, r_in, r_ex, thickness
+export tessellate
+export Ring, Polar, Fill, Lattice, capacity, placements
+export FillFactor
+export LayRatio, Pitch, LayAngle, Helix, pitch, angle, overlength
+export ncables, nphases
+
+public AssemblyMember, AssemblyShape, BentStrip, BoundedPlacement, EnclosureBoundary
+public geometry_tolerance
+public equivalent_dielectric_permeability, radial_components, radial_position,
+ conductor_zone_position, same_radial_position, bounded_members
+public DifferenceShape, EllipseOffset, ShellShape, SectorShape
# 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 ..PlotBuilder.BackendHandler: set_backend!, ensure_backend!, current_backend_symbol,
- backend_available, renderfig, next_fignum
-import ..PlotBuilder.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, Greater, PhysicalFillLimit, Satisfies
-using Measurements
-using DataFrames
-using Colors
-using Plots
-using DisplayAs: DisplayAs
-using LinearAlgebra
-using Makie: Point, Point2f # otherwise will require adding GeometryBasics as a dependency
-# Abstract types & interfaces
+#! explicit-imports: off
+# IMPORTS is expanded in the module docstring rather than called as Julia code.
+using DocStringExtensions: IMPORTS
+#! explicit-imports: on
+using DocStringExtensions: TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES, FUNCTIONNAME
+import ..Units
+import ..Commons
+import ..TextDisplay
+import ..LineCableModels: add!, build, homogenize, validate, nominal, uncertainty
+import ..LineCableModels: line_length
+import ..LineCableModels: parameterize
+import ..LineCableModels: Gridpoint, materialize, realize, realize_arguments
+import Random
+using ..Materials: AbstractMaterial, Material, RadialDielectric
+import GeometryBasics
+using GeometryBasics: Point2f
+import Base: angle
+import LinearAlgebra
+using SpecialFunctions: ellipe
+
+# Abstract types and interfaces
+include("interfaces.jl")
include("types.jl")
-include("radii.jl")
+include("geometry/pose.jl")
+include("geometry/primitives.jl")
+include("geometry/shell.jl")
+include("geometry/sector.jl")
+include("geometry/ellipse.jl")
+include("geometry/resolve.jl")
+include("design/region.jl")
+include("design/stack.jl")
+include("placement/patterns.jl")
+include("placement/paths.jl")
+include("placement/compaction.jl")
+include("placement/bounded.jl")
+include("design/group.jl")
+include("design/assembly.jl")
+include("design/enclosure.jl")
# Submodule `BaseParams`
include("baseparams/BaseParams.jl")
using .BaseParams
-# Constructors
-include("macros.jl")
-include("validation.jl")
-
-# Conductors
-include("strands_handler.jl")
-include("circstrands.jl")
-include("rectstrands.jl")
-include("strip.jl")
-include("tubular.jl")
-include("conductorgroup.jl")
-include("sector.jl")
-
-# Insulators
-include("insulator.jl")
-include("semicon.jl")
-include("insulatorgroup.jl")
-include("sectorinsulator.jl")
-
-
-# Groups
-include("nominaldata.jl")
-include("cablecomponent.jl")
-include("cabledesign.jl")
+include("design/cabledesign.jl")
+include("flatten.jl")
# Library
-include("cableslibrary.jl")
-include("linecablesystem.jl")
-
-# Helpers & overrides
-include("helpers.jl")
-include("preview.jl")
-include("io.jl")
-include("typecoercion.jl")
-
-# Aliases for backward compatibility
-const WireArray = CircStrands
-export WireArray
+include("cableslibrary/datasheetinfo.jl")
+include("cableslibrary/cableslibrary.jl")
+include("linecablesystem/clearance.jl")
+include("linecablesystem/linecablesystem.jl")
+
+# Geometry and language protocols
+include("geometry.jl")
+include("preview/geometry.jl")
+include("preview/materials.jl")
+
+# Bounded human-readable representations for the completed physical grammar.
+include("textdisplay.jl")
+
+public preview_shapes, preview_materials
+public PreviewShape, material_property_ranges
+public flatten
+
+# Construction interfaces shared with Engine and UQ. Not modelling options.
+public clearance_geometry, interface_clearance, collect_clearance_requirements, with_clearance
+public clearance_summary, warn_clearance_summary, realize_clearance
end # module DataModel
diff --git a/src/datamodel/baseparams/BaseParams.jl b/src/datamodel/baseparams/BaseParams.jl
index e141eba35..1dc3981c3 100644
--- a/src/datamodel/baseparams/BaseParams.jl
+++ b/src/datamodel/baseparams/BaseParams.jl
@@ -1,1370 +1,25 @@
"""
- LineCableModels.DataModel.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)
+Calculate cable-geometry equivalences, reference-state resistance, and
+export-oriented dielectric reductions. Each function promotes its inputs with
+Base numeric rules.
"""
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_circstrands_coords
-export calc_inductance_trifoil
-export calc_circstrands_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.CircStrands`](@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``:
+export equivalent_alpha, parallel, helix, strip_resistance
+export tubular_resistance, wire_coordinates
+export strand_gmr, tubular_gmr, equivalent_gmr, equivalent_mu
+export shunt_capacitance, shunt_conductance, series_shunt_admittance
+export solenoid_factor, equivalent_rho, equivalent_eps
+export equivalent_conductivity
-```math
-\\lambda = \\frac{L_p}{D_e}
-```
-where ``D_e`` and ``L_p`` are the dimensions represented in the figure.
+using DocStringExtensions: TYPEDSIGNATURES
+using ...Commons: vacuum_permittivity
-
+include("geometry.jl")
+include("resistance.jl")
+include("inductance.jl")
+include("dielectrics.jl")
-# Arguments
-
-- `r_in`: Inner radius of the cable layer \\[m\\].
-- `r_ex`: 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
-r_in = 0.01
-r_ex = 0.015
-lay_ratio = 12
-
-mean_diam, pitch, overlength = $(FUNCTIONNAME)(r_in, r_ex, lay_ratio)
-# mean_diam ≈ 0.025 [m]
-# pitch ≈ 0.3 [m]
-# overlength > 1.0 [1/m]
-```
-"""
-function calc_helical_params(
- r_in::T,
- r_ex::T,
- lay_ratio::T,
-) where {T <: REALSCALAR}
- mean_diameter = 2 * (r_in + (r_ex - r_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(r_in, r_ex, lay_ratio)
- T = resolve_T(r_in, r_ex, lay_ratio)
- return calc_helical_params(
- coerce_to_T(r_in, T),
- coerce_to_T(r_ex, 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
-
-- `r_in`: Internal radius of the tubular conductor \\[m\\].
-- `r_ex`: 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
-r_in = 0.01
-r_ex = 0.02
-rho = 1.7241e-8
-alpha = 0.00393
-T0 = 20
-T = 25
-resistance = $(FUNCTIONNAME)(r_in, r_ex, rho, alpha, T0, T)
-# Output: ~9.10e-8 Ω
-```
-
-# See also
-
-- [`calc_temperature_correction`](@ref)
-"""
-function calc_tubular_resistance(
- r_in::T,
- r_ex::T,
- rho::T,
- alpha::T,
- T0::T,
- Top::T,
-) where {T <: REALSCALAR}
- cross_section = π * (r_ex^2 - r_in^2)
- return calc_temperature_correction(alpha, Top, T0) * rho / cross_section
-end
-
-function calc_tubular_resistance(r_in, r_ex, rho, alpha, T0, Top)
- T = resolve_T(r_in, r_ex, rho, alpha, T0, Top)
- return calc_tubular_resistance(
- coerce_to_T(r_in, T),
- coerce_to_T(r_ex, 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
-
-- `r_in`: Internal radius of the tubular conductor \\[m\\].
-- `r_ex`: 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
-r_in = 0.01
-r_ex = 0.02
-mu_r = 1.0
-L = $(FUNCTIONNAME)(r_in, r_ex, mu_r)
-# Output: ~2.31e-7 H/m
-```
-
-# See also
-
-- [`calc_tubular_resistance`](@ref)
-"""
-function calc_tubular_inductance(
- r_in::T,
- r_ex::T,
- mu_r::T,
-) where {T <: REALSCALAR}
- return mu_r * μ₀ / (2 * π) * log(r_ex / r_in)
-end
-
-function calc_tubular_inductance(r_in, r_ex, mu_r)
- T = resolve_T(r_in, r_ex, mu_r)
- return calc_tubular_inductance(
- coerce_to_T(r_in, T),
- coerce_to_T(r_ex, 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\\].
-- `r_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.CircStrands`](@ref)
-"""
-function calc_circstrands_coords(
- num_wires::U,
- radius_wire::T,
- r_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 : r_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_circstrands_coords(num_wires::Int, radius_wire, r_in; C = nothing)
- T =
- C === nothing ? resolve_T(radius_wire, r_in) :
- resolve_T(radius_wire, r_in, C...)
- C_val = C === nothing ? coerce_to_T((0.0, 0.0), T) : coerce_to_T(C, T)
- return calc_circstrands_coords(
- num_wires,
- coerce_to_T(radius_wire, T),
- coerce_to_T(r_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[n] {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_circstrands_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_circstrands_gmr(lay_rad, N::Int, rad_wire, mu_r)
- T = resolve_T(lay_rad, rad_wire, mu_r)
- return calc_circstrands_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
-
-- `r_ex`: External radius of the tubular conductor \\[m\\].
-- `r_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 `r_ex` is less than `r_in`.
-
-# Examples
-
-```julia
-r_ex = 0.02
-r_in = 0.01
-mu_r = 1.0
-gmr = $(FUNCTIONNAME)(r_ex, r_in, mu_r)
-println(gmr) # Expected output: ~0.0135 [m]
-```
-"""
-function calc_tubular_gmr(r_ex::T, r_in::T, mu_r::T) where {T <: REALSCALAR}
- if (r_ex < r_in) || (r_ex <= 0.0)
- throw(
- ArgumentError(
- "Invalid parameters: r_ex must be >= r_in and positive.",
- ),
- )
- end
-
- # Constants
- if isapprox(r_in, r_ex)
- # Tube collapses into a thin shell with infinitesimal thickness and the GMR is simply the radius
- gmr = r_ex
- elseif abs(r_in / r_ex) < eps() && abs(r_in) > TOL
- # Tube becomes infinitely thick up to floating point precision
- gmr = Inf
- else
- is_solid = isapprox(r_in, 0.0)
- term1 =
- is_solid ? 0.0 :
- (r_in^4 / (r_ex^2 - r_in^2)^2) * log(r_ex / r_in)
- term2 = (3 * r_in^2 - r_ex^2) / (4 * (r_ex^2 - r_in^2))
- Lin = (μ₀ * mu_r / (2 * π)) * (term1 - term2)
-
- # Compute the GMR
- gmr = exp(log(r_ex) - (2 * π / μ₀) * Lin)
- end
-
- return gmr
-end
-
-function calc_tubular_gmr(r_ex, r_in, mu_r)
- T = resolve_T(r_ex, r_in, mu_r)
- return calc_tubular_gmr(
- coerce_to_T(r_ex, T),
- coerce_to_T(r_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\\].
-- `r_ex`: External radius of the conductor \\[m\\].
-- `r_in`: Internal radius of the conductor \\[m\\].
-
-# Returns
-
-- Relative permeability (`mu_r`) of the conductor material \\[dimensionless\\].
-
-# Errors
-
-- Throws `ArgumentError` if `r_ex` is less than `r_in`.
-
-# Notes
-
-Assumes a tubular geometry for the conductor, reducing to the solid case if `r_in` is zero.
-
-# Examples
-
-```julia
-gmr = 0.015
-r_ex = 0.02
-r_in = 0.01
-mu_r = $(FUNCTIONNAME)(gmr, r_ex, r_in)
-println(mu_r) # Expected output: ~1.7 [dimensionless]
-```
-
-# See also
-- [`calc_tubular_gmr`](@ref)
-"""
-function calc_equivalent_mu(gmr::T, r_ex::T, r_in::T) where {T <: REALSCALAR}
- if (r_ex < r_in) || (r_ex <= 0.0)
- throw(
- ArgumentError(
- "Invalid parameters: r_ex must be >= r_in and positive.",
- ),
- )
- end
- is_solid = isapprox(r_in, 0.0) || isapprox(r_in, r_ex)
- term1 =
- is_solid ? 0.0 :
- (r_in^4 / (r_ex^2 - r_in^2)^2) * log(r_ex / r_in)
- term2 = (3 * r_in^2 - r_ex^2) / (4 * (r_ex^2 - r_in^2))
- # Compute the log difference
- log_diff = log(gmr) - log(r_ex)
-
- # Compute mu_r
- mu_r = -log_diff / (term1 - term2)
-
- return mu_r
-end
-
-function calc_equivalent_mu(gmr, r_ex, r_in)
- T = resolve_T(gmr, r_ex, r_in)
- return calc_equivalent_mu(
- coerce_to_T(gmr, T),
- coerce_to_T(r_ex, T),
- coerce_to_T(r_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
-
-- `r_in`: Internal radius of the coaxial structure \\[m\\].
-- `r_ex`: 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
-r_in = 0.01
-r_ex = 0.02
-epsr = 2.3
-capacitance = $(FUNCTIONNAME)(r_in, r_ex, epsr)
-println(capacitance) # Expected output: ~1.24e-10 [F/m]
-```
-"""
-function calc_shunt_capacitance(
- r_in::T,
- r_ex::T,
- epsr::T,
-) where {T <: REALSCALAR}
- return 2 * π * ε₀ * epsr / log(r_ex / r_in)
-end
-
-function calc_shunt_capacitance(r_in, r_ex, epsr)
- T = resolve_T(r_in, r_ex, epsr)
- return calc_shunt_capacitance(
- coerce_to_T(r_in, T),
- coerce_to_T(r_ex, 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
-
-- `r_in`: Internal radius of the coaxial structure \\[m\\].
-- `r_ex`: 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
-r_in = 0.01
-r_ex = 0.02
-rho = 1e9
-g = $(FUNCTIONNAME)(r_in, r_ex, rho)
-println(g) # Expected output: 2.7169e-9 [S·m]
-```
-"""
-function calc_shunt_conductance(r_in::T, r_ex::T, rho::T) where {T <: REALSCALAR}
- return 2 * π * (1 / rho) / log(r_ex / r_in)
-end
-
-function calc_shunt_conductance(r_in, r_ex, rho)
- T = resolve_T(r_in, r_ex, rho)
- return calc_shunt_conductance(
- coerce_to_T(r_in, T),
- coerce_to_T(r_ex, 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 = CircStrands(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 = getproperty(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 CircStrands types
-@inline function _is_circstrands(x)
- DM = parentmodule(@__MODULE__) # DataModel
- return isdefined(DM, :CircStrands) &&
- (x isa getproperty(DM, :CircStrands)) # no compile-time ref to CircStrands
-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)
-circstrands1 = CircStrands(0.01, 0.002, 7, 10, material_props)
-circstrands2 = CircStrands(0.02, 0.002, 7, 15, material_props)
-gmd = $(FUNCTIONNAME)(circstrands1, circstrands2) # 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_circstrands_coords`](@ref)
-- [`calc_equivalent_gmr`](@ref)
-"""
-function calc_gmd(co1::T, co2::U) where {T <: AbstractCablePart, U <: AbstractCablePart}
-
- if _is_circstrands(co1) #co1 isa CircStrands
- coords1 = calc_circstrands_coords(co1.num_wires, co1.radius_wire, co1.r_in)
- n1 = co1.num_wires
- r1 = co1.radius_wire
- s1 = pi * r1^2
- else
- coords1 = [(0.0, 0.0)]
- n1 = 1
- r1 = co1.r_ex
- s1 = co1.cross_section
- end
-
- # if co2 isa CircStrands
- if _is_circstrands(co2)
- coords2 = calc_circstrands_coords(co2.num_wires, co2.radius_wire, co2.r_in)
- n2 = co2.num_wires
- r2 = co2.radius_wire
- s2 = pi * r2^2
- else
- coords2 = [(0.0, 0.0)]
- n2 = 1
- r2 = co2.r_ex
- 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 ([`CircStrands`](@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\\].
-- `r_ex`: External radius of the insulation \\[m\\].
-- `r_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, r_ex::T, r_in::T) where {T <: REALSCALAR}
- return (C_eq * log(r_ex / r_in)) / (2 * pi) / ε₀
-end
-
-function calc_equivalent_eps(C_eq, r_ex, r_in)
- T = resolve_T(C_eq, r_ex, r_in)
- return calc_equivalent_eps(
- coerce_to_T(C_eq, T),
- coerce_to_T(r_ex, T),
- coerce_to_T(r_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\\].
-- `r_in`: Internal radius of the coaxial structure \\[m\\].
-- `r_ex`: 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, r_in, r_ex)
-```
-"""
-function calc_sigma_lossfact(G_eq::T, r_in::T, r_ex::T) where {T <: REALSCALAR}
- return G_eq * log(r_ex / r_in) / (2 * pi)
-end
-
-function calc_sigma_lossfact(G_eq, r_in, r_ex)
- T = resolve_T(G_eq, r_in, r_ex)
- return calc_sigma_lossfact(
- coerce_to_T(G_eq, T),
- coerce_to_T(r_in, T),
- coerce_to_T(r_ex, T),
- )
-end
-
-end # module BaseParams
diff --git a/src/datamodel/baseparams/dielectrics.jl b/src/datamodel/baseparams/dielectrics.jl
new file mode 100644
index 000000000..0bf0a3d66
--- /dev/null
+++ b/src/datamodel/baseparams/dielectrics.jl
@@ -0,0 +1,127 @@
+"""
+$(TYPEDSIGNATURES)
+
+Calculate coaxial shunt capacitance
+``C=2\\pi\\varepsilon_0\\varepsilon_r/\\log(r_{ex}/r_{in})`` \\[F/m\\].
+
+`r_in` and `r_ex` are the inner and outer dielectric radii in meters,
+with ``0 zero(rin) && rex > rin || throw(DomainError(
+ (rin, rex), "shunt capacitance requires 0 < r_in < r_ex"
+ ))
+ permittivity >= zero(permittivity) || throw(DomainError(
+ permittivity, "relative permittivity must be nonnegative"
+ ))
+ return 2 * (one(rin) * π) * vacuum_permittivity(typeof(rin)) * permittivity / log(rex / rin)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate coaxial shunt conductance
+``G=2\\pi/(\\rho\\log(r_{ex}/r_{in}))`` \\[S/m\\].
+
+`r_in` and `r_ex` are the inner and outer dielectric radii in meters,
+with ``0 zero(rin) && rex > rin || throw(DomainError(
+ (rin, rex), "shunt conductance requires 0 < r_in < r_ex"
+ ))
+ resistivity > zero(resistivity) || throw(DomainError(
+ resistivity, "resistivity must be positive"
+ ))
+ return 2 * (one(rin) * π) / resistivity / log(rex / rin)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Combine two radial dielectric layers connected in series. Each layer is
+represented by the shunt admittance
+``Y_i=G_i+\\mathrm{j}\\omega C_i``. The equivalent is
+
+```math
+Y_{eq}=\\frac{Y_1Y_2}{Y_1+Y_2}.
+```
+
+# Arguments
+
+- `conductance1`: shunt conductance of the accumulated dielectric \\[S/m\\].
+- `capacitance1`: shunt capacitance of the accumulated dielectric \\[F/m\\].
+- `conductance2`: shunt conductance of the added dielectric \\[S/m\\].
+- `capacitance2`: shunt capacitance of the added dielectric \\[F/m\\].
+- `omega`: angular reference frequency \\[rad/s\\].
+
+# Returns
+
+- Named tuple containing the equivalent `conductance` \\[S/m\\] and
+ `capacitance` \\[F/m\\].
+"""
+function series_shunt_admittance(
+ conductance1::Real,
+ capacitance1::Real,
+ conductance2::Real,
+ capacitance2::Real,
+ omega::Real
+)
+ G1, C1, G2, C2, angular_frequency = promote(
+ float(conductance1), float(capacitance1),
+ float(conductance2), float(capacitance2), float(omega)
+ )
+ angular_frequency > zero(angular_frequency) || throw(DomainError(
+ angular_frequency, "angular frequency must be positive"
+ ))
+ equivalent = parallel(
+ complex(G1, angular_frequency * C1),
+ complex(G2, angular_frequency * C2)
+ )
+ return (
+ conductance = real(equivalent),
+ capacitance = imag(equivalent) / angular_frequency
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Recover equivalent relative permittivity from coaxial capacitance:
+``\\varepsilon_{eq}=C\\log(r_{ex}/r_{in})/(2\\pi\\varepsilon_0)``.
+
+`capacitance` is nonnegative capacitance per unit length in F/m.
+`r_ex` and `r_in` are the outer and inner dielectric radii in meters,
+with ``0= zero(value) || throw(DomainError(value, "capacitance must be nonnegative"))
+ rin > zero(rin) && rex > rin || throw(DomainError(
+ (rin, rex), "equivalent permittivity requires 0 < r_in < r_ex"
+ ))
+ return value * log(rex / rin) / (2 * (one(rin) * π) * vacuum_permittivity(typeof(rin)))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Recover equivalent conductivity from coaxial conductance:
+``\\sigma_{eq}=G\\log(r_{ex}/r_{in})/(2\\pi)``.
+
+`conductance` is nonnegative conductance per unit length in S/m.
+`r_in` and `r_ex` are the inner and outer dielectric radii in meters,
+with ``0= zero(value) || throw(DomainError(value, "conductance must be nonnegative"))
+ rin > zero(rin) && rex > rin || throw(DomainError(
+ (rin, rex), "equivalent conductivity requires 0 < r_in < r_ex"
+ ))
+ return value * log(rex / rin) / (2 * (one(rin) * π))
+end
diff --git a/src/datamodel/baseparams/geometry.jl b/src/datamodel/baseparams/geometry.jl
new file mode 100644
index 000000000..d77326501
--- /dev/null
+++ b/src/datamodel/baseparams/geometry.jl
@@ -0,0 +1,113 @@
+"""
+$(TYPEDSIGNATURES)
+
+Return `(mean_diameter, pitch_length, overlength)` for a helical radial layer:
+
+```math
+D_e=r_{in}+r_{ex},\\qquad L_p=\\lambda D_e,\\qquad
+k=\\sqrt{1+(\\pi D_e/L_p)^2}.
+```
+
+`r_in` and `r_ex` are the inner and outer layer radii in meters, with
+``0\\le r_{in}\\le r_{ex}``. `lay_ratio` is the nonnegative, dimensionless
+ratio ``\\lambda=L_p/D_e`` used in EN 50182. All inputs must be finite.
+The returned diameter and pitch are in meters. Overlength ``k`` is dimensionless.
+The overlength equals one when pitch is zero. `lay_ratio=0` represents
+a straight layer and returns a pitch of zero.
+"""
+function helix(r_in::Real, r_ex::Real, lay_ratio::Real)
+ rin, rex, ratio = promote(float(r_in), float(r_ex), float(lay_ratio))
+ all(isfinite, (rin, rex, ratio)) || throw(DomainError(
+ (rin, rex, ratio), "helix geometry must be finite"
+ ))
+ rin >= zero(rin) || throw(DomainError(rin, "inner radius must be nonnegative"))
+ rex >= rin ||
+ throw(DomainError(rex, "outer radius must not be smaller than inner radius"))
+ ratio >= zero(ratio) || throw(DomainError(ratio, "lay ratio must be nonnegative"))
+ mean_diameter = rin + rex
+ pitch_length = ratio * mean_diameter
+ overlength = iszero(pitch_length) ? one(pitch_length) :
+ sqrt(one(pitch_length) +
+ (oftype(pitch_length, π) * mean_diameter / pitch_length)^2)
+ return mean_diameter, pitch_length, overlength
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the centers of `num_wires` equally spaced circular wires. For
+``N>1`` wires, the center of wire ``i=0,\\ldots,N-1`` is
+
+```math
+C+r_l(\\cos(2\\pi i/N),\\sin(2\\pi i/N)),\\qquad
+r_l=r_{in}+r_w.
+```
+
+`num_wires` is the nonnegative integer count ``N``. `radius_wire` is the
+positive wire radius ``r_w`` and `r_in` is the nonnegative inner radius of
+the wire layer, both in meters. `C` is the layer center `(x, y)` in meters
+and defaults to `(0, 0)`. Coordinates and radii must be finite.
+
+The result is a vector of `(x, y)` tuples in meters, ordered counterclockwise
+from the positive x direction. Zero wires return an empty vector. One wire
+is placed at `C`.
+"""
+function wire_coordinates(
+ num_wires::Integer,
+ radius_wire::Real,
+ r_in::Real;
+ C = nothing
+)
+ num_wires >= 0 || throw(DomainError(num_wires, "number of wires must be nonnegative"))
+ center = C === nothing ? (zero(radius_wire), zero(radius_wire)) : C
+ rw, rin, cx, cy = promote(
+ float(radius_wire), float(r_in), float(center[1]), float(center[2])
+ )
+ all(isfinite, (rw, rin, cx, cy)) || throw(DomainError(
+ (rw, rin, cx, cy), "wire geometry must be finite"
+ ))
+ rw > zero(rw) || throw(DomainError(rw, "wire radius must be positive"))
+ rin >= zero(rin) || throw(DomainError(rin, "inner radius must be nonnegative"))
+ radius = num_wires == 1 ? zero(rin) : rin + rw
+ num_wires == 0 && return Tuple{typeof(rin), typeof(rin)}[]
+ step = 2 * (one(rin) * π) / num_wires
+ return [(cx + radius * cos(index * step), cy + radius * sin(index * step))
+ for index in 0:(num_wires - 1)]
+end
+
+function wire_coordinates(num_wires::Integer, radius_wire::Real, r_in::Real, C::Tuple)
+ wire_coordinates(num_wires, radius_wire, r_in; C)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the dimensionless permeability factor for a helical solenoid:
+
+```math
+k_\\mu=1+\\frac{2\\pi^2N^2(r_i^2-r_c^2)}{\\log(r_i/r_c)}.
+```
+
+`turns_per_length` is the nonnegative number of turns per meter ``N``.
+`r_con` is the conductor radius ``r_c`` and `r_ins` is the outer insulation
+radius ``r_i``, both in meters, with ``0\\le r_c= zero(turns) || throw(DomainError(turns, "turns per unit length must be nonnegative"))
+ conductor >= zero(conductor) || throw(DomainError(
+ conductor, "conductor radius must be nonnegative"
+ ))
+ insulator > conductor || throw(DomainError(
+ insulator, "insulator radius must exceed conductor radius"
+ ))
+ pi_value = one(turns) * π
+ return one(turns) +
+ 2 * pi_value^2 * turns^2 *
+ (insulator^2 - conductor^2) / log(insulator / conductor)
+end
diff --git a/src/datamodel/baseparams/inductance.jl b/src/datamodel/baseparams/inductance.jl
new file mode 100644
index 000000000..4cb38d7b9
--- /dev/null
+++ b/src/datamodel/baseparams/inductance.jl
@@ -0,0 +1,197 @@
+"""
+$(TYPEDSIGNATURES)
+
+Calculate the GMR of a circular wire array:
+``GMR=(r_g N a^{N-1})^{1/N}``, with
+``r_g=r_w\\exp(-\\mu_r/4)``.
+
+`lay_radius` is the radius `a` of the strand-center circle in meters.
+`count` is the positive integer strand count `N`. `wire_radius` is the
+positive strand radius `r_w` in meters. `mu_r` is the positive,
+dimensionless relative permeability. The lay radius must be positive for
+multiple wires and may be zero for one wire. The returned geometric mean
+radius (GMR) is in meters.
+"""
+function strand_gmr(lay_radius::Real, count::Integer, wire_radius::Real, mu_r::Real)
+ count > 0 || throw(DomainError(count, "wire count must be positive"))
+ radius, wire, permeability = promote(
+ float(lay_radius), float(wire_radius), float(mu_r)
+ )
+ wire > zero(wire) || throw(DomainError(wire, "wire radius must be positive"))
+ radius >= zero(radius) || throw(DomainError(radius, "lay radius must be nonnegative"))
+ (count == 1 || radius > zero(radius)) || throw(DomainError(
+ radius, "lay radius must be positive for a multi-wire layer"
+ ))
+ permeability > zero(permeability) || throw(DomainError(
+ permeability, "relative permeability must be positive"
+ ))
+ wire_gmr = wire * exp(-permeability / 4)
+ return exp(log(wire_gmr * count * radius^(count - 1)) / count)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate the GMR of identical circular strands from their resolved centers:
+
+```math
+\\log GMR=\\frac{1}{N^2}\\left[
+N\\log r_g+2\\sum_{i=1}^{N}\\sum_{j=i+1}^{N}\\log d_{ij}
+\\right],\\qquad
+r_g=r_w\\exp(-\\mu_r/4).
+```
+
+# Arguments
+
+- `coordinates`: strand-center coordinates \\[m\\].
+- `wire_radius`: strand radius \\[m\\].
+- `mu_r`: relative permeability \\[dimensionless\\].
+
+# Returns
+
+- Geometric mean radius of the complete strand set \\[m\\].
+"""
+function strand_gmr(coordinates, wire_radius::Real, mu_r::Real)
+ isempty(coordinates) && throw(ArgumentError(
+ "strand GMR requires at least one center coordinate"
+ ))
+ wire, permeability = promote(float(wire_radius), float(mu_r))
+ wire > zero(wire) || throw(DomainError(
+ wire, "wire radius must be positive"
+ ))
+ permeability > zero(permeability) || throw(DomainError(
+ permeability, "relative permeability must be positive"
+ ))
+ count = length(coordinates)
+ logarithmic_sum = count * log(tubular_gmr(wire, zero(wire), permeability))
+ for left in eachindex(coordinates)
+ for right in (left + 1):length(coordinates)
+ distance = hypot(
+ coordinates[left][1] - coordinates[right][1],
+ coordinates[left][2] - coordinates[right][2]
+ )
+ distance > zero(distance) || throw(ArgumentError(
+ "strand centers must be distinct"
+ ))
+ logarithmic_sum += 2log(distance)
+ end
+ end
+ return exp(logarithmic_sum / count^2)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate the GMR of an annular conductor.
+
+```math
+\\log GMR=\\log r_2-\\mu_r\\left[
+\\frac{r_1^4}{(r_2^2-r_1^2)^2}\\log\\frac{r_2}{r_1}
+-\\frac{3r_1^2-r_2^2}{4(r_2^2-r_1^2)}\\right].
+```
+
+`r_in` and `r_ex` are the inner radius ``r_1`` and outer radius ``r_2``
+in meters, with ``0≤r_1≤r_2`` and ``r_2>0``. `mu_r` is the positive,
+dimensionless relative permeability. The returned GMR is in meters.
+
+# Notes
+
+The expression reduces to the solid-conductor result at zero inner radius and
+to the outer radius for an infinitesimally thin shell.
+"""
+function tubular_gmr(r_ex::Real, r_in::Real, mu_r::Real)
+ rex, rin, permeability = promote(float(r_ex), float(r_in), float(mu_r))
+ rin >= zero(rin) && rex > zero(rex) && rex >= rin || throw(DomainError(
+ (rin, rex), "outer radius must be positive and not smaller than inner radius"
+ ))
+ permeability > zero(permeability) || throw(DomainError(
+ permeability, "relative permeability must be positive"
+ ))
+ isapprox(rin, rex) && return rex
+ iszero(rin) && return rex * exp(-permeability / 4)
+ denominator = rex^2 - rin^2
+ term1 = rin^4 / denominator^2 * log(rex / rin)
+ term2 = (3 * rin^2 - rex^2) / (4 * denominator)
+ return exp(log(rex) - permeability * (term1 - term2))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate the GMR of two heterogeneous conductor zones:
+
+```math
+GMR_{eq}=GMR_1^{\\beta^2}GMR_2^{(1-\\beta)^2}
+ GMD^{2\\beta(1-\\beta)},\\qquad
+\\beta=\\frac{A_1}{A_1+A_2}.
+```
+
+# Arguments
+
+- `gmr1`: GMR of the accumulated conductor zone \\[m\\].
+- `area1`: cross-sectional area of the accumulated conductor zone \\[m²\\].
+- `gmr2`: GMR of the added conductor zone \\[m\\].
+- `area2`: cross-sectional area of the added conductor zone \\[m²\\].
+- `gmd`: geometric mean distance between the zones \\[m\\].
+
+# Returns
+
+- Equivalent GMR of the combined conductor \\[m\\].
+"""
+function equivalent_gmr(
+ gmr1::Real,
+ area1::Real,
+ gmr2::Real,
+ area2::Real,
+ gmd::Real
+)
+ first_gmr, first_area, second_gmr, second_area, distance = promote(
+ float(gmr1), float(area1), float(gmr2), float(area2), float(gmd)
+ )
+ first_gmr > zero(first_gmr) && second_gmr > zero(second_gmr) ||
+ throw(DomainError((first_gmr, second_gmr), "GMR values must be positive"))
+ first_area > zero(first_area) && second_area > zero(second_area) ||
+ throw(DomainError((first_area, second_area), "conductor areas must be positive"))
+ distance > zero(distance) || throw(DomainError(
+ distance, "geometric mean distance must be positive"
+ ))
+ fraction = first_area / (first_area + second_area)
+ complement = one(fraction) - fraction
+ return first_gmr^(fraction^2) * second_gmr^(complement^2) *
+ distance^(2 * fraction * complement)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Recover dimensionless relative permeability from the GMR of an annular conductor:
+
+```math
+\\mu_r=\\frac{\\log(r_{ex}/GMR)}{A-B},\\qquad
+A=\\frac{r_{in}^4}{(r_{ex}^2-r_{in}^2)^2}\\log\\frac{r_{ex}}{r_{in}},\\qquad
+B=\\frac{3r_{in}^2-r_{ex}^2}{4(r_{ex}^2-r_{in}^2)}.
+```
+
+`gmr`, `r_ex`, and `r_in` are the geometric mean radius, outer conductor
+radius, and inner conductor radius in meters. The GMR and outer radius must
+be positive, with ``0≤r_{in}≤r_{ex}``. A `NaN` input returns `NaN`.
+
+For a solid conductor (`r_in=0`), the result is
+``-4\\log(GMR/r_{ex})``. The implementation sets ``A=0`` when the radii are
+approximately equal. In the thin-shell limit, GMR approaches the outer radius
+regardless of permeability, so the inverse does not determine permeability
+reliably.
+"""
+function equivalent_mu(gmr::Real, r_ex::Real, r_in::Real)
+ radius, rex, rin = promote(float(gmr), float(r_ex), float(r_in))
+ any(isnan, (radius, rex, rin)) && return radius + rex + rin
+ radius > zero(radius) || throw(DomainError(radius, "GMR must be positive"))
+ rin >= zero(rin) && rex > zero(rex) && rex >= rin || throw(DomainError(
+ (rin, rex), "outer radius must be positive and not smaller than inner radius"
+ ))
+ is_solid = iszero(rin) || isapprox(rin, rex)
+ denominator = rex^2 - rin^2
+ term1 = is_solid ? zero(rin) : rin^4 / denominator^2 * log(rex / rin)
+ term2 = (3 * rin^2 - rex^2) / (4 * denominator)
+ return -(log(radius) - log(rex)) / (term1 - term2)
+end
diff --git a/src/datamodel/baseparams/resistance.jl b/src/datamodel/baseparams/resistance.jl
new file mode 100644
index 000000000..3a1a25d0c
--- /dev/null
+++ b/src/datamodel/baseparams/resistance.jl
@@ -0,0 +1,104 @@
+"""
+$(TYPEDSIGNATURES)
+
+Calculate the equivalent temperature coefficient of two parallel conductors:
+
+```math
+\\alpha_{eq}=\\frac{\\alpha_1R_2+\\alpha_2R_1}{R_1+R_2}.
+```
+
+`alpha1` and `alpha2` are temperature coefficients in K⁻¹. `R1` and `R2`
+are the corresponding resistances at the same reference temperature, both
+in Ω/m or both in Ω. The returned coefficient is in K⁻¹.
+"""
+function equivalent_alpha(alpha1::Real, R1::Real, alpha2::Real, R2::Real)
+ a1, r1, a2, r2 = promote(float(alpha1), float(R1), float(alpha2), float(R2))
+ return (a1 * r2 + a2 * r1) / (r1 + r2)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the parallel equivalent ``Z_{eq}=Z_1Z_2/(Z_1+Z_2)``.
+
+`Z1` and `Z2` must have the same units and length basis, such as Ω/m
+or Ω. The result has those same units. An infinite input returns the other
+input. 2 zero inputs return zero.
+"""
+function parallel(Z1::Number, Z2::Number)
+ z1, z2 = promote(Z1, Z2)
+ isinf(z1) && return z2
+ isinf(z2) && return z1
+ iszero(z1) && iszero(z2) && return zero(z1)
+ return z1 * z2 / (z1 + z2)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate reference-state strip resistance per unit length:
+
+```math
+R=\\frac{\\rho}{w t}.
+```
+
+# Arguments
+
+- `thickness`: strip thickness \\[m\\].
+- `width`: strip width \\[m\\].
+- `rho`: material resistivity at its reference temperature \\[Ω·m\\].
+
+# Returns
+
+- Strip resistance per unit length \\[Ω/m\\].
+"""
+function strip_resistance(
+ thickness::Real,
+ width::Real,
+ rho::Real
+)
+ t, w, resistivity = promote(float(thickness), float(width), float(rho))
+ return resistivity / (t * w)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate reference-state tubular resistance per unit length:
+
+```math
+R=\\frac{\\rho}{\\pi(r_{ex}^2-r_{in}^2)}.
+```
+
+# Arguments
+
+- `r_in`: inner conductor radius \\[m\\].
+- `r_ex`: outer conductor radius \\[m\\].
+- `rho`: material resistivity at its reference temperature \\[Ω·m\\].
+
+# Returns
+
+- Tubular resistance per unit length \\[Ω/m\\].
+"""
+function tubular_resistance(
+ r_in::Real,
+ r_ex::Real,
+ rho::Real
+)
+ rin, rex, resistivity = promote(float(r_in), float(r_ex), float(rho))
+ return resistivity / ((one(rin) * π) * (rex^2 - rin^2))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Recover the equivalent resistivity from resistance and annular area:
+``\\rho_{eq}=R\\pi(r_{ex}^2-r_{in}^2)``.
+
+`R` is resistance per unit length in Ω/m. `r_ex` and `r_in` are the outer
+and inner conductor radii in meters. The returned resistivity is in Ω·m.
+"""
+function equivalent_rho(R::Real, r_ex::Real, r_in::Real)
+ resistance, rex, rin = promote(float(R), float(r_ex), float(r_in))
+ return resistance * (one(rin) * π) * (rex^2 - rin^2)
+end
diff --git a/src/datamodel/cablecomponent.jl b/src/datamodel/cablecomponent.jl
deleted file mode 100644
index 344ef3691..000000000
--- a/src/datamodel/cablecomponent.jl
+++ /dev/null
@@ -1,246 +0,0 @@
-
-"""
-$(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.r_ex == insulator_group.r_in ||
- isapprox(conductor_group.r_ex, insulator_group.r_in)
- )
- throw(
- ArgumentError("Conductor outer radius must match insulator inner radius."),
- )
- end
-
- # Radii
- r1 = conductor_group.r_in
- r2 = conductor_group.r_ex
- r3 = insulator_group.r_ex
-
- # 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.r_in, cg.r_ex, 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.r_in, ig.r_ex, 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.r_in,
- t.r_ex,
- 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
deleted file mode 100644
index 5303190d0..000000000
--- a/src/datamodel/cablecomponent/base.jl
+++ /dev/null
@@ -1,101 +0,0 @@
-
-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,
- [:r_in, :r_ex, :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,
- [:r_in, :r_ex, :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,
- [
- :r_in,
- :r_ex,
- :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,
- [
- :r_in,
- :r_ex,
- :cross_section,
- :shunt_capacitance,
- :shunt_conductance,
- ],
- )
-
- println(io, "]")
- end
- end
-end
diff --git a/src/datamodel/cabledesign.jl b/src/datamodel/cabledesign.jl
deleted file mode 100644
index 2b5e19228..000000000
--- a/src/datamodel/cabledesign.jl
+++ /dev/null
@@ -1,271 +0,0 @@
-
-"""
-$(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].r_in
- rex = cg.layers[end].r_ex
-
- # "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.r_ex # ensure interface matches
- ins_rex = ig.r_ex # 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
deleted file mode 100644
index 173078d0e..000000000
--- a/src/datamodel/cabledesign/base.jl
+++ /dev/null
@@ -1,116 +0,0 @@
-
-
-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 = [
- "r_in" => component.conductor_group.r_in,
- "r_ex" => component.conductor_group.r_ex,
- "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 = [
- "r_in" => component.insulator_group.r_in,
- "r_ex" => component.insulator_group.r_ex,
- "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
diff --git a/src/datamodel/cabledesign/dataframe.jl b/src/datamodel/cabledesign/dataframe.jl
deleted file mode 100644
index e4ad42ad0..000000000
--- a/src/datamodel/cabledesign/dataframe.jl
+++ /dev/null
@@ -1,336 +0,0 @@
-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.r_ex) ?
- 2 * cable_outer.conductor_group.r_ex :
- 2 * cable_outer.insulator_group.r_ex
- ) : 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.r_in,
- cable_core.conductor_group.r_ex,
- cable_core.conductor_props.rho,
- 0.0, 20.0, 20.0,
- ) * 1e3
-
- # Inductance calculation
- L =
- calc_inductance_trifoil(
- cable_core.conductor_group.r_in,
- cable_core.conductor_group.r_ex,
- cable_core.conductor_props.rho,
- cable_core.conductor_props.mu_r,
- cable_shield.conductor_group.r_in,
- cable_shield.conductor_group.r_ex,
- 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.r_ex,
- cable_core.insulator_group.r_ex,
- 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.r_in, # radius_in_con
- component.conductor_group.r_ex, # 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.r_ex, # 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",
- "r_in",
- "r_ex",
- "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.
- - `r_in`: The inner radius of the part, if it exists, otherwise `missing`.
- - `r_ex`: The outer radius of the part, if it exists, otherwise `missing`.
- - `diameter_in`: The inner diameter of the part (2 * r_in), if `r_in` exists, otherwise `missing`.
- - `diameter_ext`: The outer diameter of the part (2 * r_ex), if `r_ex` exists, otherwise `missing`.
- - `thickness`: The difference between `r_ex` and `r_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 `r_ex`, 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 = [:r_in, :r_ex, :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), :r_in) ?
- getproperty(part, :r_in) : missing,
- hasfield(typeof(part), :r_ex) ?
- getproperty(part, :r_ex) : missing,
- hasfield(typeof(part), :r_in) ?
- 2 * getproperty(part, :r_in) : missing,
- hasfield(typeof(part), :r_ex) ?
- 2 * getproperty(part, :r_ex) : missing,
- hasfield(typeof(part), :r_ex) &&
- hasfield(typeof(part), :r_in) ?
- (getproperty(part, :r_ex) - getproperty(part, :r_in)) :
- missing,
- hasfield(typeof(part), :cross_section) ?
- getproperty(part, :cross_section) : missing,
- hasfield(typeof(part), :num_wires) ?
- getproperty(part, :num_wires) : missing,
- hasfield(typeof(part), :resistance) ?
- getproperty(part, :resistance) : missing,
- hasfield(typeof(part), :alpha) ||
- (
- hasfield(typeof(part), :material_props) &&
- hasfield(typeof(getproperty(part, :material_props)), :alpha)
- ) ?
- (
- hasfield(typeof(part), :alpha) ?
- getproperty(part, :alpha) :
- getproperty(getproperty(part, :material_props), :alpha)
- ) : missing,
- hasfield(typeof(part), :gmr) ?
- getproperty(part, :gmr) : missing,
- hasfield(typeof(part), :gmr) &&
- hasfield(typeof(part), :r_ex) ?
- (getproperty(part, :gmr) / getproperty(part, :r_ex)) : missing,
- hasfield(typeof(part), :shunt_capacitance) ?
- getproperty(part, :shunt_capacitance) : missing,
- hasfield(typeof(part), :shunt_conductance) ?
- getproperty(part, :shunt_conductance) : missing,
- ]
-end
diff --git a/src/datamodel/cableslibrary.jl b/src/datamodel/cableslibrary.jl
deleted file mode 100644
index 82bd566e3..000000000
--- a/src/datamodel/cableslibrary.jl
+++ /dev/null
@@ -1,83 +0,0 @@
-"""
-$(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")
-include("cableslibrary/vdeparse.jl")
-
-
diff --git a/src/datamodel/cableslibrary/base.jl b/src/datamodel/cableslibrary/base.jl
index 02341d1d5..0d3b3c907 100644
--- a/src/datamodel/cableslibrary/base.jl
+++ b/src/datamodel/cableslibrary/base.jl
@@ -1,97 +1,90 @@
# Implement the AbstractDict interface
Base.length(lib::CablesLibrary) = length(lib.data)
-Base.setindex!(lib::CablesLibrary, value::CableDesign, key::String) = (lib.data[key] = value)
+function Base.setindex!(lib::CablesLibrary, design::CableDesign, key)
+ cable_id = convert(String, key)
+ cable_id == design.cable_id || throw(ArgumentError(
+ "cable key '$cable_id' differs from cable_id '$(design.cable_id)'",
+ ))
+ validate(design)
+ record = DatasheetInfo(design.nominal_data)
+ lib.data[cable_id] = design
+ lib.datasheets[cable_id] = record
+ return lib
+end
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)
+Base.haskey(lib::CablesLibrary, key) = haskey(lib.data, key)
+Base.getindex(lib::CablesLibrary, key) = getindex(lib.data, key)
"""
$(TYPEDSIGNATURES)
-Retrieves a cable design from a [`CablesLibrary`](@ref) object by its ID.
+Return the cable design stored under `cable_id`, or `default` when absent.
# 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.
+- `library`: cable-design library.
+- `cable_id`: stored cable identifier.
+- `default`: value returned when `cable_id` is absent.
# 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
-```
+- The stored [`CableDesign`](@ref), or `default`.
-# 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
+function Base.get(library::CablesLibrary, cable_id, default)
+ return get(library.data, cable_id, default)
+end
+
+function Base.get(
+ default::Union{Function, Type}, library::CablesLibrary, cable_id
+)
+ return get(default, library.data, cable_id)
end
"""
$(TYPEDSIGNATURES)
-Removes a cable design from a [`CablesLibrary`](@ref) object by its ID.
+Remove the cable design stored under `cable_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.
+- `library`: cable-design library.
+- `cable_id`: stored cable identifier.
# Returns
-- Nothing. Modifies the `data` field of the [`CablesLibrary`](@ref) object in-place by removing the specified cable design if it exists.
-
-# Examples
+- The modified `library`.
-```julia
-library = CablesLibrary()
-design = CableDesign("example", ...) # Initialize a CableDesign
-add!(library, design)
+"""
+function Base.delete!(library::CablesLibrary, cable_id)
+ delete!(library.data, cable_id)
+ delete!(library.datasheets, cable_id)
+ return library
+end
-# Remove the cable design
-$(FUNCTIONNAME)(library, "example")
-haskey(library, "example") # Returns false
-```
+"""
+Return an empty cable library.
+"""
+Base.empty(::CablesLibrary) = CablesLibrary()
-# See also
+"""
+Remove every cable design and catalog record and return `library`.
+"""
+function Base.empty!(library::CablesLibrary)
+ empty!(library.data)
+ empty!(library.datasheets)
+ return library
+end
-- [`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
+Return a shallow cable-library copy with independent dictionary storage.
+"""
+function Base.copy(library::CablesLibrary)
+ copied = CablesLibrary()
+ copied.data = copy(library.data)
+ copied.datasheets = copy(library.datasheets)
+ return copied
+end
diff --git a/src/datamodel/cableslibrary/cableslibrary.jl b/src/datamodel/cableslibrary/cableslibrary.jl
new file mode 100644
index 000000000..9f539777a
--- /dev/null
+++ b/src/datamodel/cableslibrary/cableslibrary.jl
@@ -0,0 +1,87 @@
+"""
+$(TYPEDEF)
+
+Store cable designs by `cable_id` as an `AbstractDict{String, CableDesign}`.
+
+Ordinary indexed assignment inserts or replaces a design and resets its
+catalog record from `design.nominal_data`. Use [`add!`](@ref) to reject an
+existing identifier or to supply an explicit catalog record.
+
+$(TYPEDFIELDS)
+"""
+mutable struct CablesLibrary <: AbstractDict{String, CableDesign}
+ "Cable designs indexed by `cable_id`."
+ data::Dict{String, CableDesign}
+ "Catalog records indexed by `cable_id`."
+ datasheets::Dict{String, DatasheetInfo}
+
+ @doc """
+ $(TYPEDSIGNATURES)
+
+ Construct an empty cable-design library.
+
+ # Returns
+
+ - An empty [`CablesLibrary`](@ref).
+
+ # Examples
+
+ ```jldoctest
+ library = $(FUNCTIONNAME)()
+ isempty(library)
+ # output
+ true
+ ```
+
+ """
+ function CablesLibrary()::CablesLibrary
+ return new(Dict{String, CableDesign}(), Dict{String, DatasheetInfo}())
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Add a cable design under its `cable_id`.
+
+# Arguments
+
+- `library`: destination library.
+- `design`: validated cable design.
+
+# Keywords
+
+- `datasheet=design.nominal_data`: datasheet information stored beside the
+ design. A named tuple is normalized to [`DatasheetInfo`](@ref).
+
+# Returns
+
+- The modified `library`.
+
+# Errors
+
+- Throws `ArgumentError` when `library` already contains `design.cable_id`.
+"""
+function add!(
+ library::CablesLibrary,
+ design::CableDesign;
+ datasheet::Union{DatasheetInfo, NamedTuple} = design.nominal_data
+)
+ haskey(library, design.cable_id) && throw(ArgumentError(
+ "cable design '$(design.cable_id)' already exists",
+ ))
+ validate(design)
+ record = datasheet isa DatasheetInfo ? datasheet : DatasheetInfo(datasheet)
+ library.data[design.cable_id] = design
+ library.datasheets[design.cable_id] = record
+ return library
+end
+
+"""
+Return the catalog record associated with `cable_id`.
+"""
+datasheet(library::CablesLibrary, cable_id::AbstractString) =
+ library.datasheets[String(cable_id)]
+
+include("base.jl")
+include("vdeparse.jl")
diff --git a/src/datamodel/cableslibrary/dataframe.jl b/src/datamodel/cableslibrary/dataframe.jl
deleted file mode 100644
index 8f755020a..000000000
--- a/src/datamodel/cableslibrary/dataframe.jl
+++ /dev/null
@@ -1,52 +0,0 @@
-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/cableslibrary/datasheetinfo.jl b/src/datamodel/cableslibrary/datasheetinfo.jl
new file mode 100644
index 000000000..30ecc4a01
--- /dev/null
+++ b/src/datamodel/cableslibrary/datasheetinfo.jl
@@ -0,0 +1,51 @@
+"""
+$(TYPEDEF)
+
+Store the values reported by one cable datasheet without imposing a fixed
+catalog schema.
+
+Field names and values are retained in the supplied `NamedTuple`. Property
+access delegates to that record, so `info.resistance` returns the value stored
+under `:resistance`.
+
+$(TYPEDFIELDS)
+"""
+struct DatasheetInfo{D <: NamedTuple}
+ "Named datasheet values in their stated engineering units."
+ data::D
+
+ function DatasheetInfo(data::D) where {D <: NamedTuple}
+ :data in keys(data) && throw(ArgumentError(
+ "datasheet field :data is reserved"
+ ))
+ return new{D}(data)
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct cable datasheet information from named values.
+
+# Keywords
+
+- `values...`: datasheet fields. Names and value types are retained exactly.
+
+# Returns
+
+- A [`DatasheetInfo`](@ref) record.
+"""
+DatasheetInfo(; values...) = DatasheetInfo((; values...))
+
+Base.propertynames(info::DatasheetInfo, private::Bool = false) =
+ private ? (:data, keys(getfield(info, :data))...) : keys(getfield(info, :data))
+
+function Base.getproperty(info::DatasheetInfo, name::Symbol)
+ name === :data && return getfield(info, :data)
+ return getproperty(getfield(info, :data), name)
+end
+
+Base.keys(info::DatasheetInfo) = keys(getfield(info, :data))
+Base.pairs(info::DatasheetInfo) = pairs(getfield(info, :data))
+Base.length(info::DatasheetInfo) = length(getfield(info, :data))
+Base.getindex(info::DatasheetInfo, name::Symbol) = getindex(getfield(info, :data), name)
diff --git a/src/datamodel/cableslibrary/vdeparse.jl b/src/datamodel/cableslibrary/vdeparse.jl
index 43e7446bf..fbdf03d5d 100644
--- a/src/datamodel/cableslibrary/vdeparse.jl
+++ b/src/datamodel/cableslibrary/vdeparse.jl
@@ -2,238 +2,238 @@
# ─────────────────────────── Mappings ───────────────────────────
const MAP = Dict{Symbol, Dict{String, String}}(
- :designation => Dict(
- "N" => "DIN VDE standard",
- "(N)" => "similar to DIN VDE standard",
- ),
-
- # Conductor material (omitted ⇒ copper)
- :conductor => Dict(
- "A" => "aluminium conductor",
- "-" => "copper conductor",
- ),
-
- # Insulation (omitted ⇒ paper)
- :insulation => Dict(
- "2X" => "cross-linked PE (XLPE)",
- "Y" => "PVC",
- "H" => "LSOH compound",
- "-" => "impregnated paper",
- ),
-
- # Screen / concentric conductor
- :metallic_screen => Dict(
- "CW" => "concentric conductor of copper in waveconal formation",
- "CE" => "concentric conductor of copper over each individual core",
- "SE" => "screen of copper wires over each individual core",
- "C" => "concentric conductor of copper",
- "S" => "screen of copper wires",
- "H" => "conductive layers",
- ),
-
- # Water blocking right after screen, inside parentheses
- :waterblocking => Dict(
- "FL" => "longitudinally and radially water-proof protection",
- "F" => "longitudinally water-proof protection",
- "L" => "radially water-proof protection",
- ),
-
- # Inner sheath (e.g., …XSH… : H before outer sheath)
- :inner_sheath => Dict(
- "H" => "LSOH compound inner sheath",
- ),
-
- # Armouring
- :armouring => Dict(
- "B" => "steel tape armouring",
- "F" => "armour of galvanised flat steel wires",
- "G" => "counter helix of galvanised steel tape",
- "R" => "armour of galvanised round steel wires",
- ),
-
- # Sheath
- :sheath => Dict(
- "KL" => "aluminium sheath",
- "K" => "lead sheath",
- ),
-
- # Outer sheath
- :outer_sheath => Dict(
- "A" => "outer sheath made of fibrous material",
- "2Y" => "PE outer sheath",
- "Y" => "PVC outer sheath",
- ),
-
- # Grounding / protective conductor suffix
- :grounding => Dict(
- "I" => "with grounding (protective) conductor",
- "J" => "with grounding (protective) conductor",
- "O" => "without grounding (protective) conductor",
- ),
+ :designation => Dict(
+ "N" => "DIN VDE standard",
+ "(N)" => "similar to DIN VDE standard"
+ ),
+
+ # Conductor material (omitted ⇒ copper)
+ :conductor => Dict(
+ "A" => "aluminium conductor",
+ "-" => "copper conductor"
+ ),
+
+ # Insulation (omitted ⇒ paper)
+ :insulation => Dict(
+ "2X" => "cross-linked PE (XLPE)",
+ "Y" => "PVC",
+ "H" => "LSOH compound",
+ "-" => "impregnated paper"
+ ),
+
+ # Screen / concentric conductor
+ :metallic_screen => Dict(
+ "CW" => "concentric conductor of copper in waveconal formation",
+ "CE" => "concentric conductor of copper over each individual core",
+ "SE" => "screen of copper wires over each individual core",
+ "C" => "concentric conductor of copper",
+ "S" => "screen of copper wires",
+ "H" => "conductive layers"
+ ),
+
+ # Water blocking right after screen, inside parentheses
+ :waterblocking => Dict(
+ "FL" => "longitudinally and radially water-proof protection",
+ "F" => "longitudinally water-proof protection",
+ "L" => "radially water-proof protection"
+ ),
+
+ # Inner sheath (such as `…XSH…`, with `H` before the outer sheath)
+ :inner_sheath => Dict(
+ "H" => "LSOH compound inner sheath",
+ ),
+
+ # Armouring
+ :armouring => Dict(
+ "B" => "steel tape armouring",
+ "F" => "armour of galvanised flat steel wires",
+ "G" => "counter helix of galvanised steel tape",
+ "R" => "armour of galvanised round steel wires"
+ ),
+
+ # Sheath
+ :sheath => Dict(
+ "KL" => "aluminium sheath",
+ "K" => "lead sheath"
+ ),
+
+ # Outer sheath
+ :outer_sheath => Dict(
+ "A" => "outer sheath made of fibrous material",
+ "2Y" => "PE outer sheath",
+ "Y" => "PVC outer sheath"
+ ),
+
+ # Grounding / protective conductor suffix
+ :grounding => Dict(
+ "I" => "with grounding (protective) conductor",
+ "J" => "with grounding (protective) conductor",
+ "O" => "without grounding (protective) conductor"
+ )
)
-# Canonical order of appearance within the stub
+# Expected order of appearance within the stub
const ORDER = [
- :designation,
- :conductor,
- :insulation,
- :metallic_screen,
- :waterblocking, # immediately after :metallic_screen, parenthesized
- :inner_sheath, # H before metallic sheath (e.g., …XSH…)
- :sheath,
- :armouring,
- :outer_sheath,
- :grounding,
+ :designation,
+ :conductor,
+ :insulation,
+ :metallic_screen,
+ :waterblocking, # immediately after :metallic_screen, parenthesized
+ :inner_sheath, # H before metallic sheath (such as …XSH…)
+ :sheath,
+ :armouring,
+ :outer_sheath,
+ :grounding
]
# ─────────────────────── Regex builders ────────────────────────
escape_for_rx(s) = replace(s, r"([.^$|?*+\[\]{}\\])" => "\\\\\1")
-# Return the non-capturing alternation **as a string**, e.g. "(?:FL|F|L)"
+# Return the non-capturing alternation **as a string**, such as "(?:FL|F|L)"
function union_pat_str(tokens::Vector{String})
- ts = sort(tokens; by = length, rev = true) # longest first
- "(?:" * join(escape_for_rx.(ts), "|") * ")"
+ ts = sort(tokens; by = length, rev = true) # longest first
+ "(?:" * join(escape_for_rx.(ts), "|") * ")"
end
-# If you really want a Regex anchored at start, wrap union_pat_str
+# Anchor the alternation at the start of the input.
union_pat(tokens::Vector{String}) = Regex("^" * union_pat_str(tokens))
const RXS = let rxs = Dict{Symbol, Regex}()
- for fld in ORDER
- fld_keys = collect(keys(MAP[fld]))
- if fld == :waterblocking
- # parenthesized immediately after :metallic_screen (e.g., "(FL)"), anchored
- core = union_pat_str(fld_keys) # "(?:FL|F|L)"
- rxs[fld] = Regex("^\\(" * core * "\\)") # "^\((?:FL|F|L)\)"
- else
- rxs[fld] = union_pat(fld_keys) # e.g. "^(?:CE|SE|CW|S|C|H)"
- end
- end
- rxs
+ for fld in ORDER
+ fld_keys = collect(keys(MAP[fld]))
+ if fld == :waterblocking
+ # parenthesized immediately after :metallic_screen (such as "(FL)"), anchored
+ core = union_pat_str(fld_keys) # "(?:FL|F|L)"
+ rxs[fld] = Regex("^\\(" * core * "\\)") # "^\((?:FL|F|L)\)"
+ else
+ rxs[fld] = union_pat(fld_keys) # such as "^(?:CE|SE|CW|S|C|H)"
+ end
+ end
+ rxs
end
# Trailing specs (anchored at start of tail)
const RX_CORES_X_CSA = r"^(\d+)\s*[x×]\s*(\d+(?:\.\d+)?)(?:\s*/\s*(\d+(?:\.\d+)?))?"
-const RX_VOLTAGE = r"^(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)\s*(?:kV|KV|kv)\b"
-const RX_TYPE = r"^([RSEMOH]{1,3})(?:\s*/\s*V)?\b" # RM, SE, OH, … + optional /V
+const RX_VOLTAGE = r"^(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)\s*(?:kV|KV|kv)\b"
+const RX_TYPE = r"^([REMOH]{1,3})(?:\s*/\s*V)?\b" # RM, OH, … + optional /V
# KISS conductor type mapping
const TYPE_MAP = Dict(
- 'R' => "round", 'S' => "sector", 'O' => "oval",
- 'E' => "solid", 'M' => "stranded", 'H' => "hollow",
- 'V' => "compact",
+ 'R' => "round", 'O' => "oval",
+ 'E' => "solid", 'M' => "stranded", 'H' => "hollow",
+ 'V' => "compact"
)
function decode_type(code::AbstractString; has_compact::Bool = false)
- words = String[]
- for c in code
- if haskey(TYPE_MAP, c)
- push!(words, TYPE_MAP[c])
- else
- @warn "Unknown conductor type letter ignored." letter=String(c)
- end
- end
- if has_compact
- push!(words, "compact")
- end
- # de-dup preserving order
- seen = Set{String}();
- uniq = String[]
- for w in words
- if !(w in seen)
- ;
- push!(uniq, w);
- push!(seen, w);
- end
- end
- return join(uniq, ", ")
+ words = String[]
+ for c in code
+ if haskey(TYPE_MAP, c)
+ push!(words, TYPE_MAP[c])
+ else
+ @warn "Unknown conductor type letter ignored." letter=string(c)
+ end
+ end
+ if has_compact
+ push!(words, "compact")
+ end
+ # de-dup preserving order
+ seen = Set{String}()
+ uniq = String[]
+ for w in words
+ if !(w in seen)
+ push!(uniq, w)
+ push!(seen, w)
+ end
+ end
+ return join(uniq, ", ")
end
# ─────────────────────────── Parser ────────────────────────────
"""
- vdeparse(code::AbstractString) -> Dict{Symbol,String}
+$(TYPEDSIGNATURES)
-Parses VDE/DIN 0271/0276 cable codes:
-- **stub** (first non-space token): designation → conductor_material (default copper) → insulation (default paper) → screen → waterblocking → inner_sheath → armouring → sheath → grounding
-- **tail**: cores × cross-section (optional screen csa), voltage, conductor type (R/S/O + E/M/H, optional `/V` ⇒ compact)
+Parse a VDE/DIN 0271 or 0276 cable designation.
-Only parsed keys are returned; defaults are materialized when omitted.
+# Arguments
+
+- `code`: cable designation containing the compact type token followed by
+ optional conductor count, cross-sections, voltage, and conductor form.
+
+# Returns
+
+- A dictionary of parsed designation fields. Omitted conductor and insulation
+ tokens produce copper and impregnated-paper defaults. Unparsed compact-token
+ text is returned under `:unparsed_stub`.
"""
function vdeparse(code::AbstractString)::Dict{Symbol, String}
- # normalize spaces (NBSP -> space) and trim
- s = replace(code, '\u00A0' => ' ')
- s = strip(s)
-
- # split into stub token (first non-space chunk) + tail
- m = match(r"^\S+", s)
- if m === nothing
- return Dict{Symbol, String}() # empty / whitespace line
- end
- stub = m.match # e.g., "2XS(F)2Y"
- tail = strip(s[(length(stub)+1):end])
-
- # parse stub left-to-right
- out = Dict{Symbol, String}()
- rest = stub
- for fld in ORDER
- rx = RXS[fld]
- mm = match(rx, rest)
- if mm !== nothing
- tok = mm.match
- key = (fld == :waterblocking) ? tok[2:(end-1)] : tok
- out[fld] = MAP[fld][key]
- rest = rest[(length(tok)+1):end] # consume
- else
- # materialize normative omissions
- if fld == :conductor
- out[fld] = "copper conductor"
- elseif fld == :insulation
- out[fld] = "impregnated paper"
- end
- end
- end
- # Any non-empty rest means unknown extra within the stub itself
- if !isempty(rest)
- out[:unparsed_stub] = rest
- @warn "Unparsed stub residue." residue=rest
- end
-
- # parse tail in anchored passes
- if !isempty(tail)
- if (mm = match(RX_CORES_X_CSA, tail)) !== nothing
- out[:cores] = mm.captures[1]
- out[:conductor_cross_section] = mm.captures[2]
- if mm.captures[3] !== nothing
- out[:metallic_screen_cross_section] = mm.captures[3]
- end
- tail = strip(tail[(length(mm.match)+1):end])
- end
- end
-
- if !isempty(tail)
- if (mm = match(RX_VOLTAGE, tail)) !== nothing
- out[:voltage] = "$(mm.captures[1])/$(mm.captures[2]) kV"
- tail = strip(tail[(length(mm.match)+1):end])
- end
- end
-
- if !isempty(tail)
- if (mm = match(RX_TYPE, tail)) !== nothing
- raw = mm.captures[1]
- has_compact = occursin(r"/\s*V\b", mm.match)
- out[:conductor_type] = decode_type(raw; has_compact = has_compact)
- tail = strip(tail[(length(mm.match)+1):end])
- end
- end
-
- if !isempty(tail)
- out[:unparsed] = tail
- @warn "Unparsed trailing token(s)." residue=tail
- end
-
- return out
+ # Normalize spaces (NBSP to space) and trim the result.
+ s = replace(code, '\u00A0' => ' ')
+ s = strip(s)
+
+ # split into stub token (first non-space chunk) + tail
+ m = match(r"^\S+", s)
+ if m === nothing
+ return Dict{Symbol, String}() # empty / whitespace line
+ end
+ stub = m.match # such as "2XS(F)2Y"
+ tail = strip(s[(length(stub) + 1):end])
+
+ # parse stub left-to-right
+ out = Dict{Symbol, String}()
+ rest = stub
+ for fld in ORDER
+ rx = RXS[fld]
+ mm = match(rx, rest)
+ if mm !== nothing
+ tok = mm.match
+ key = (fld == :waterblocking) ? tok[2:(end - 1)] : tok
+ out[fld] = MAP[fld][key]
+ rest = rest[(length(tok) + 1):end] # consume
+ else
+ # Materialize values omitted by the source format.
+ if fld == :conductor
+ out[fld] = "copper conductor"
+ elseif fld == :insulation
+ out[fld] = "impregnated paper"
+ end
+ end
+ end
+ # Any non-empty rest means unknown extra within the stub itself
+ if !isempty(rest)
+ out[:unparsed_stub] = rest
+ @warn "Unparsed stub residue." residue=rest
+ end
+
+ # parse tail in anchored passes
+ if !isempty(tail) && (mm = match(RX_CORES_X_CSA, tail)) !== nothing
+ out[:cores] = mm.captures[1]
+ out[:conductor_cross_section] = mm.captures[2]
+ if mm.captures[3] !== nothing
+ out[:metallic_screen_cross_section] = mm.captures[3]
+ end
+ tail = strip(tail[(length(mm.match) + 1):end])
+ end
+
+ if !isempty(tail) && (mm = match(RX_VOLTAGE, tail)) !== nothing
+ out[:voltage] = "$(mm.captures[1])/$(mm.captures[2]) kV"
+ tail = strip(tail[(length(mm.match) + 1):end])
+ end
+
+ if !isempty(tail) && (mm = match(RX_TYPE, tail)) !== nothing
+ raw = mm.captures[1]
+ has_compact = occursin(r"/\s*V\b", mm.match)
+ out[:conductor_type] = decode_type(raw; has_compact = has_compact)
+ tail = strip(tail[(length(mm.match) + 1):end])
+ end
+
+ if !isempty(tail)
+ out[:unparsed] = tail
+ @warn "Unparsed trailing token(s)." residue=tail
+ end
+
+ return out
end
# # ───────────────────────── Smoke tests ─────────────────────────
@@ -248,5 +248,3 @@ end
# for ex in exs
# println(ex, " → ", vdeparse(ex))
# end
-
-
diff --git a/src/datamodel/circstrands.jl b/src/datamodel/circstrands.jl
deleted file mode 100644
index e58380ceb..000000000
--- a/src/datamodel/circstrands.jl
+++ /dev/null
@@ -1,171 +0,0 @@
-"""
-$(TYPEDEF)
-
-Represents an array of wires equally spaced around a circumference of arbitrary radius, with attributes:
-
-$(TYPEDFIELDS)
-"""
-struct CircStrands{T <: REALSCALAR, U <: Int} <: AbstractStrandsLayer{T}
- "Internal radius of the wire array \\[m\\]."
- r_in::T
- "External radius of the wire array \\[m\\]."
- r_ex::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 [`CircStrands`](@ref) instance based on specified geometric and material parameters.
-
-# Arguments
-
-- `r_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 [`CircStrands`](@ref) object with calculated geometric and electrical properties.
-
-# Examples
-
-```julia
-material_props = Material(1.7241e-8, 1.0, 0.999994, 20.0, 0.00393)
-circstrands = $(FUNCTIONNAME)(0.01, Diameter(0.002), 7, 10, material_props, temperature=25)
-println(circstrands.mean_diameter) # Outputs mean diameter in m
-println(circstrands.resistance) # Outputs resistance in Ω/m
-```
-
-# See also
-
-- [`Material`](@ref)
-- [`ConductorGroup`](@ref)
-- [`calc_tubular_resistance`](@ref)
-- [`calc_circstrands_gmr`](@ref)
-- [`calc_helical_params`](@ref)
-"""
-function CircStrands(
- r_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
- r_ex = num_wires == 1 ? radius_wire : r_in + 2 * radius_wire # TODO: The resolved outer radius for stranded cores should account for compression. See rectstrands.jl for an example of area-preserving expansion.
- # Issue URL: https://github.com/Electa-Git/LineCableModels.jl/issues/32
-
- mean_diameter, pitch_length, overlength = calc_helical_params(
- r_in,
- r_ex,
- 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_circstrands_gmr(
- r_in + radius_wire,
- num_wires,
- radius_wire,
- material_props.mu_r,
- )
-
- # Initialize object
- return CircStrands(
- r_in,
- r_ex,
- radius_wire,
- num_wires,
- lay_ratio,
- mean_diameter,
- pitch_length,
- lay_direction,
- material_props,
- temperature,
- cross_section,
- R_all_wires,
- gmr,
- )
-end
-
-const _REQ_CIRCSTRANDS = (:r_in, :radius_wire, :num_wires, :lay_ratio, :material_props)
-const _OPT_CIRCSTRANDS = (:temperature, :lay_direction)
-const _DEFS_CIRCSTRANDS = (T₀, 1)
-
-Validation.has_radii(::Type{CircStrands}) = false
-Validation.has_temperature(::Type{CircStrands}) = true
-Validation.required_fields(::Type{CircStrands}) = _REQ_CIRCSTRANDS
-Validation.keyword_fields(::Type{CircStrands}) = _OPT_CIRCSTRANDS
-Validation.keyword_defaults(::Type{CircStrands}) = _DEFS_CIRCSTRANDS
-
-Validation.coercive_fields(::Type{CircStrands}) =
- (:r_in, :radius_wire, :lay_ratio, :material_props, :temperature) # not :num_wires, :lay_direction
-# accept proxies for radii
-Validation.is_radius_input(::Type{CircStrands}, ::Val{:r_in},
- x::AbstractCablePart) = true
-Validation.is_radius_input(::Type{CircStrands}, ::Val{:r_ex},
- x::Diameter) = true
-
-Validation.extra_rules(::Type{CircStrands}) = (
- # radii (post-parse they must be numeric)
- Normalized(:r_in), Finite(:r_in), Nonneg(:r_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)),
-)
-
-maxfill(::Type{CircStrands}, rin::Real, rw::Real) =
- rin == 0 ? 1 : floor(Int, π / asin(rw / (rin + rw)))
-
-# normalize proxies -> numbers
-Validation.parse(::Type{CircStrands}, nt) = begin
- rin, rw = _normalize_radii(CircStrands, nt.r_in, nt.radius_wire)
- (; nt..., r_in = rin, radius_wire = rw)
-end
-
-# This macro expands to a weakly-typed constructor for CircStrands
-@construct CircStrands _REQ_CIRCSTRANDS _OPT_CIRCSTRANDS _DEFS_CIRCSTRANDS
diff --git a/src/datamodel/conductorgroup.jl b/src/datamodel/conductorgroup.jl
deleted file mode 100644
index b18332cc0..000000000
--- a/src/datamodel/conductorgroup.jl
+++ /dev/null
@@ -1,238 +0,0 @@
-"""
-$(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, L <: AbstractLayout} <:
-# AbstractConductorPart{T}
-# # MultiCoreGroup...... under CableDesign?
-# # L<: Concentric, SectorShaped
-# # Concentric -> stacks over radii
-# # SectorShaped -> stacks over angles
-
-# end
-
-mutable struct ConductorGroup{T <: REALSCALAR} <: AbstractConductorPart{T}
- "Inner radius of the conductor group \\[m\\]."
- r_in::T
- "Outer radius of the conductor group \\[m\\]."
- r_ex::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}(
- r_in::T,
- r_ex::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}(r_in, r_ex, 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 CircStrands{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.r_in,
- central.r_ex,
- 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 `r_in` to `group.r_ex` if absent.
-3. Compute `Tnew = resolve_T(group, r_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`, `r_ex`, `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 `r_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 `r_in`, the constructor retrieves its `r_ex` value, allowing the new cable part to be placed directly over the existing part in a layered cable design.
- - For uncertain geometries, the preceding part's outer-radius derivative graph
- is retained. Adjacent layers therefore share one physical boundary and
- cumulative-radius covariance is preserved across different part types.
-
-# 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, CircStrands, 0.02, 0.002, 7, 15, material_props, temperature = 25)
-```
-
-# See also
-
-- [`ConductorGroup`](@ref)
-- [`CircStrands`](@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, :r_in, group.r_ex)
- kwv = haskey(kwv, :r_in) ? kwv : merge(kwv, (; r_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 `:r_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.r_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.r_ex += (new_part.r_ex - new_part.r_in)
- group.cross_section += new_part.cross_section
-
- # CircStrands / Strip bookkeeping
- if new_part isa CircStrands || new_part isa Strip
- old_wires = group.num_wires
- old_turns = group.num_turns
- nw = new_part isa CircStrands ? 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")
diff --git a/src/datamodel/conductorgroup/base.jl b/src/datamodel/conductorgroup/base.jl
deleted file mode 100644
index 3d4998d3d..000000000
--- a/src/datamodel/conductorgroup/base.jl
+++ /dev/null
@@ -1,4 +0,0 @@
-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/design/assembly.jl b/src/datamodel/design/assembly.jl
new file mode 100644
index 000000000..9b859e74a
--- /dev/null
+++ b/src/datamodel/design/assembly.jl
@@ -0,0 +1,252 @@
+"""
+Store one explicitly placed member of an `Assembly`.
+"""
+struct AssemblyMember{E <: AbstractCablePart, P <: Pose2}
+ item::E
+ at::P
+end
+
+"""
+Store the resolved, potentially disconnected geometric boundary of an `Assembly`.
+
+The member geometric boundaries remain exact. An assembly of sector
+cores is not replaced by a circular envelope. Consumers that require one
+containing domain must request an explicit `Enclosure`.
+"""
+struct AssemblyShape{T <: Real, S <: Tuple} <: AbstractShape{T}
+ members::S
+
+ function AssemblyShape(members::S) where {S <: Tuple}
+ isempty(members) && throw(ArgumentError(
+ "an assembly boundary requires at least one member"
+ ))
+ all(member -> member isa AbstractShape, members) || throw(ArgumentError(
+ "assembly boundary members must be resolved shapes"
+ ))
+ T = promote_type(map(eltype, members)...)
+ return new{T, S}(members)
+ end
+end
+
+boundary(shape::AssemblyShape) = shape
+area(shape::AssemblyShape) = sum(area, shape.members)
+perimeter(shape::AssemblyShape) = sum(perimeter, shape.members)
+function support(shape::AssemblyShape, angle::Real)
+ maximum(member -> support(member, angle), shape.members)
+end
+support(shape::AssemblyShape) = maximum(support, shape.members)
+
+function centroid(shape::AssemblyShape)
+ areas = map(area, shape.members)
+ total = sum(areas)
+ centers = map(centroid, shape.members)
+ return (
+ sum(index -> areas[index] * centers[index][1], eachindex(areas)) / total,
+ sum(index -> areas[index] * centers[index][2], eachindex(areas)) / total
+ )
+end
+
+function resolve(at::Pose2, shape::AssemblyShape)
+ AssemblyShape(map(member -> resolve(at, member), shape.members))
+end
+
+AssemblyMember(item::AbstractCablePart) = AssemblyMember(item, Pose2(0, 0, 0))
+
+function Base.:(==)(left::AssemblyMember, right::AssemblyMember)
+ left.item == right.item && left.at == right.at
+end
+
+"""
+$(TYPEDEF)
+
+Arrange physical members while retaining their independent terminal
+identities.
+
+An assembly stores either one prototype with a repetition pattern or an
+explicit tuple of independently placed members. Both forms resolve through
+the same terminal-preserving operation.
+
+$(TYPEDFIELDS)
+"""
+struct Assembly{A, E, P, H, C, N} <: AbstractCablePart
+ "Pose relative to the containing frame."
+ at::A
+ "Repeated prototype or tuple of explicit members."
+ item::E
+ "Cross-sectional placement definition for a repeated prototype."
+ pattern::P
+ "Longitudinal path definition or `nothing`."
+ path::H
+ "Compaction definition or `nothing`."
+ compact::C
+ "Exact repeated-member terminal names, or `nothing` for explicit members."
+ names::N
+
+ function Assembly(
+ at::A,
+ item::E,
+ pattern::P,
+ path::H,
+ compact::C,
+ names::N
+ ) where {A, E, P, H, C, N}
+ at isa Pose2 || throw(ArgumentError("assembly pose must resolve to Pose2"))
+ repeated = item isa AbstractCablePart
+ explicit = item isa Tuple && !isempty(item) &&
+ all(member -> member isa AssemblyMember, item)
+ repeated || explicit ||
+ throw(ArgumentError(
+ "assembly item must be one prototype or explicit placed members"
+ ))
+ if repeated
+ pattern === nothing && throw(ArgumentError(
+ "a repeated assembly requires a placement pattern"
+ ))
+ names isa Union{Nothing, AbstractVector{Symbol}, Tuple{Vararg{Symbol}}} ||
+ throw(ArgumentError(
+ "assembly names must be exact symbols or nothing"
+ ))
+ names === nothing ||
+ all(name -> !isempty(String(name)), names) ||
+ throw(ArgumentError(
+ "assembly names cannot be empty"
+ ))
+ else
+ pattern === nothing && path === nothing && compact === nothing &&
+ names === nothing || throw(ArgumentError(
+ "explicit assembly members own their poses and terminal identities"
+ ))
+ end
+ return new{A, E, P, H, C, N}(at, item, pattern, path, compact, names)
+ end
+end
+
+function Base.:(==)(left::Assembly, right::Assembly)
+ left.at == right.at && left.item == right.item &&
+ left.pattern == right.pattern && left.path == right.path &&
+ left.compact == right.compact && left.names == right.names
+end
+
+function _append_assembly_member!(
+ regions::Vector{PlacedRegion},
+ child::CableGeometry,
+ assembly_at::Pose2,
+ member_at::Pose2;
+ terminal_map = identity,
+ pattern = nothing,
+ member::Int = 1,
+ path = nothing
+)
+ radius = hypot(member_at.x, member_at.y)
+ extent = zero(support(boundary(child)))
+ for source in child.regions
+ placed = resolve(assembly_at * member_at, source)
+ terminal = source.terminal === nothing ? nothing : terminal_map(source.terminal)
+ local_primitive = resolve(member_at, source.primitive)
+ extent = max(extent, support(local_primitive))
+ patterns = pattern === nothing ? placed.placement.patterns :
+ (placed.placement.patterns...,
+ (owner = Assembly, pattern = pattern, member = member, pose = member_at))
+ paths = path === nothing ? source.paths :
+ (source.paths..., (path = path, radius = radius))
+ push!(regions,
+ PlacedRegion(
+ source.source,
+ placed.primitive,
+ terminal,
+ (patterns = patterns,),
+ paths
+ ))
+ end
+ return extent
+end
+
+function _resolve_repeated(assembly::Assembly)
+ child = resolve(EmptyBoundary(), assembly.item)
+ child_terminals = unique(Symbol[source.terminal
+ for source in child.regions
+ if source.terminal !== nothing])
+ length(child_terminals) <= 1 || throw(ArgumentError(
+ "a repeated assembly prototype may resolve at most one terminal"
+ ))
+ poses = placements(assembly.pattern, child, assembly.compact)
+ count = length(poses)
+ count > 0 || throw(ArgumentError("assembly placement cannot be empty"))
+ member_names = if isempty(child_terminals)
+ assembly.names === nothing || throw(ArgumentError(
+ "a zero-terminal repeated assembly does not accept terminal names"
+ ))
+ fill(nothing, count)
+ else
+ assembly.names === nothing && throw(ArgumentError(
+ "a terminal-bearing repeated assembly requires exact names"
+ ))
+ values = collect(assembly.names)
+ length(values) == count || throw(DimensionMismatch(
+ "assembly requires $count names; got $(length(values))"
+ ))
+ allunique(values) || throw(ArgumentError("assembly names must be unique"))
+ values
+ end
+
+ regions = PlacedRegion[]
+ boundaries = AbstractShape[]
+ for (member, (name, placement)) in enumerate(zip(member_names, poses))
+ pose = _placement_pose(placement)
+ mapping = isempty(child_terminals) ? identity :
+ terminal -> terminal === only(child_terminals) ? name : terminal
+ _append_assembly_member!(
+ regions,
+ child,
+ assembly.at,
+ pose;
+ terminal_map = mapping,
+ pattern = assembly.pattern,
+ member,
+ path = assembly.path
+ )
+ push!(boundaries, resolve(assembly.at * pose, boundary(child)))
+ end
+ return CableGeometry(regions, AssemblyShape(Tuple(boundaries)))
+end
+
+function _resolve_explicit(assembly::Assembly)
+ regions = PlacedRegion[]
+ terminals = Symbol[]
+ boundaries = AbstractShape[]
+ for member in assembly.item
+ child = resolve(EmptyBoundary(), member.item)
+ child_terminals = unique(Symbol[source.terminal
+ for source in child.regions
+ if source.terminal !== nothing])
+ for terminal in child_terminals
+ terminal in terminals && throw(ArgumentError(
+ "explicit assembly terminal :$terminal is not unique"
+ ))
+ push!(terminals, terminal)
+ end
+ _append_assembly_member!(
+ regions, child, assembly.at, member.at
+ )
+ push!(boundaries, resolve(assembly.at * member.at, boundary(child)))
+ end
+ return CableGeometry(regions, AssemblyShape(Tuple(boundaries)))
+end
+
+function resolve(
+ ::EmptyBoundary,
+ assembly::Assembly{<:Any, <:AbstractCablePart}
+)
+ _resolve_repeated(assembly)
+end
+
+resolve(
+ ::EmptyBoundary,
+ assembly::Assembly{<:Any, <:Tuple}
+) = _resolve_explicit(assembly)
+
+function resolve(::AbstractShape, ::Assembly)
+ throw(ArgumentError(
+ "an Assembly requires explicit placement inside a Stack or Enclosure"
+ ))
+end
diff --git a/src/datamodel/design/cabledesign.jl b/src/datamodel/design/cabledesign.jl
new file mode 100644
index 000000000..07ba6ccaf
--- /dev/null
+++ b/src/datamodel/design/cabledesign.jl
@@ -0,0 +1,321 @@
+"""
+$(TYPEDEF)
+
+Store one completed cable design built from an authoritative physical declaration.
+
+`origin` is the serialized declaration. Geometry and terminal indexing are
+derived together by [`build`](@ref) and cannot be supplied independently.
+
+$(TYPEDFIELDS)
+"""
+struct CableDesign{
+ T <: Real,
+ O <: AbstractCablePart,
+ G <: CableGeometry,
+ N <: NamedTuple
+}
+ "Stable cable identifier."
+ cable_id::String
+ "Authoritative physical declaration."
+ origin::O
+ "Descriptive catalog data supplied with the physical declaration."
+ nominal_data::N
+ "Resolved physical geometry."
+ geometry::G
+ "Retained terminals in physical order."
+ terminal_order::Vector{Symbol}
+ "Terminal index for every resolved region. Zero denotes no terminal."
+ terminal_map::Vector{Int}
+
+ function CableDesign{T, O, G, N}(
+ cable_id::String,
+ origin::O,
+ nominal_data::N,
+ geometry::G,
+ terminal_order::Vector{Symbol},
+ terminal_map::Vector{Int}
+ ) where {
+ T <: Real, O <: AbstractCablePart, G <: CableGeometry, N <: NamedTuple
+ }
+ return validate(new{T, O, G, N}(
+ cable_id,
+ origin,
+ nominal_data,
+ geometry,
+ terminal_order,
+ terminal_map
+ ))
+ end
+end
+
+Base.eltype(::CableDesign{T}) where {T} = T
+Base.eltype(::Type{<:CableDesign{T}}) where {T} = T
+
+function validate(design::CableDesign)
+ isempty(design.cable_id) && throw(ArgumentError(
+ "CableDesign.cable_id cannot be empty"
+ ))
+ isempty(design.geometry.regions) && throw(ArgumentError(
+ "CableDesign.geometry.regions must contain at least one resolved region"
+ ))
+ isempty(design.terminal_order) && throw(ArgumentError(
+ "CableDesign.terminal_order must contain at least one retained terminal"
+ ))
+ all(name -> !isempty(String(name)), design.terminal_order) || throw(ArgumentError(
+ "CableDesign.terminal_order cannot contain an empty terminal name"
+ ))
+ allunique(design.terminal_order) || throw(ArgumentError(
+ "CableDesign.terminal_order must contain unique terminal names; " *
+ "received $(repr(design.terminal_order))"
+ ))
+ length(design.terminal_map) == length(design.geometry.regions) ||
+ throw(DimensionMismatch(
+ "CableDesign.terminal_map must contain one entry per resolved region; " *
+ "received $(length(design.terminal_map)) entries for " *
+ "$(length(design.geometry.regions)) regions"
+ ))
+ reference = first(design.geometry.regions).source.material.T0
+ for (index, placed) in pairs(design.geometry.regions)
+ validate(placed.source.material)
+ expected = if placed.terminal === nothing
+ 0
+ else
+ something(findfirst(==(placed.terminal), design.terminal_order), 0)
+ end
+ design.terminal_map[index] == expected || throw(DimensionMismatch(
+ "CableDesign.terminal_map[$index] must be $expected for resolved " *
+ "terminal $(repr(placed.terminal)); received $(design.terminal_map[index])"
+ ))
+ placed.source.material.kind === :conductor && placed.terminal === nothing &&
+ throw(ArgumentError(
+ "CableDesign.geometry.regions[$index] is conductive but has no terminal"
+ ))
+ placed.source.material.kind !== :conductor && placed.terminal !== nothing &&
+ throw(ArgumentError(
+ "CableDesign.geometry.regions[$index] is nonconductive but carries " *
+ "terminal $(repr(placed.terminal))"
+ ))
+ isapprox(placed.source.material.T0, reference) || throw(ArgumentError(
+ "CableDesign.geometry.regions[$index].source.material.T0 must match " *
+ "the design reference temperature $reference °C; received " *
+ "$(placed.source.material.T0) °C"
+ ))
+ bounded_index = findall(
+ entry -> entry.pattern isa BoundedPlacement,
+ placed.placement.patterns
+ )
+ length(bounded_index) <= 1 || throw(ArgumentError(
+ "CableDesign.geometry.regions[$index] belongs to more than one " *
+ "bounded formation"
+ ))
+ isempty(bounded_index) && continue
+ placed.source.material.kind === :conductor || throw(ArgumentError(
+ "CableDesign.geometry.regions[$index] is a bounded strand but its " *
+ "material is not conductive"
+ ))
+ placed.source.primitive isa Union{Disk, Rectangle} || throw(ArgumentError(
+ "CableDesign.geometry.regions[$index] has an unsupported bounded " *
+ "strand declaration $(nameof(typeof(placed.source.primitive)))"
+ ))
+ (placed.primitive isa Union{Disk, Rectangle, Polygon, BentStrip} ||
+ (placed.primitive isa Annulus && placed.source.primitive isa Rectangle)) ||
+ throw(ArgumentError(
+ "CableDesign.geometry.regions[$index] has unsupported resolved " *
+ "bounded geometry $(nameof(typeof(placed.primitive)))"
+ ))
+ declared_area = area(placed.source.primitive)
+ resolved_area = area(placed.primitive)
+ isapprox(
+ resolved_area,
+ declared_area;
+ rtol = 5.0e-6,
+ atol = 0
+ ) || throw(ArgumentError(
+ "CableDesign.geometry.regions[$index] does not preserve its " *
+ "declared strand area"
+ ))
+ entry_index = only(bounded_index)
+ entry = placed.placement.patterns[entry_index]
+ entry.member == 1 || continue
+ enclosing_placements = placed.placement.patterns[(entry_index + 1):end]
+ formation_indices = [peer_index
+ for (peer_index, peer) in pairs(design.geometry.regions)
+ if begin
+ peer_entries = findall(
+ member -> member.pattern isa BoundedPlacement,
+ peer.placement.patterns
+ )
+ if length(peer_entries) != 1
+ false
+ else
+ peer_entry_index = only(peer_entries)
+ peer_entry = peer.placement.patterns[peer_entry_index]
+ isequal(
+ peer_entry.pattern.boundary,
+ entry.pattern.boundary
+ ) && peer.terminal === placed.terminal &&
+ isequal(
+ peer.placement.patterns[
+ (peer_entry_index + 1):end
+ ],
+ enclosing_placements
+ )
+ end
+ end]
+ members = sort([
+ begin
+ peer = design.geometry.regions[peer_index]
+ peer_entry = findfirst(
+ member -> member.pattern isa BoundedPlacement,
+ peer.placement.patterns
+ )
+ peer.placement.patterns[peer_entry].member
+ end for peer_index in formation_indices
+ ])
+ members == collect(1:length(members)) ||
+ throw(ArgumentError(
+ "bounded-formation member identities must be contiguous from one"
+ ))
+ source_area = sum(
+ peer_index -> area(
+ design.geometry.regions[peer_index].source.primitive
+ ),
+ formation_indices
+ )
+ formation_area = sum(
+ peer_index -> area(design.geometry.regions[peer_index].primitive),
+ formation_indices
+ )
+ isapprox(
+ source_area,
+ formation_area;
+ rtol = 5.0e-6,
+ atol = 0
+ ) || throw(ArgumentError(
+ "a bounded formation must preserve its total declared strand area"
+ ))
+ boundary = entry.pattern.boundary
+ boundary isa Union{Disk, SectorShape} ||
+ throw(ArgumentError(
+ "a bounded formation has an unsupported authoritative boundary"
+ ))
+ boundary_area = area(boundary)
+ formation_area <= boundary_area * (1 + 5.0e-6) ||
+ throw(ArgumentError(
+ "bounded strand area cannot exceed its authoritative boundary"
+ ))
+ end
+ return design
+end
+
+function Base.:(==)(left::CableDesign, right::CableDesign)
+ left.cable_id == right.cable_id && left.origin == right.origin &&
+ left.nominal_data == right.nominal_data && left.geometry == right.geometry &&
+ left.terminal_order == right.terminal_order &&
+ left.terminal_map == right.terminal_map
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build a completed cable design from v1 physical declarations.
+
+The method validates physical invariants before resolving contextual geometry and assigning retained terminals, and freezes the resulting [`CableGeometry`](@ref).
+It does not perform a formulation computation.
+
+# Arguments
+
+- `CableDesign`: completed target type.
+- `cable_id`: stable cable identifier.
+- `parts`: physical `Region`, `Stack`, `Group`, `Assembly`, or `Enclosure` declarations.
+- `nominal_data`: descriptive nominal_data data, or `nothing`.
+
+# Keywords
+
+- `combine`: gridspace composition mode. It is validated here for a common
+ scalar and parametric API. Scalar construction uses one value.
+
+# Returns
+
+- One completed `CableDesign`.
+"""
+function build(
+ ::Type{CableDesign},
+ cable_id::AbstractString,
+ parts::Tuple{Vararg{AbstractCablePart}},
+ nominal_data::Union{Nothing, NamedTuple};
+ combine::Symbol = :product
+)
+ isempty(parts) && throw(ArgumentError("a cable design requires one physical part"))
+ origin = length(parts) == 1 ? only(parts) : Stack(parts...)
+ # 1. Public conveniences have already lowered into the physical grammar.
+ # A conductive Region does not acquire a terminal from its material class.
+ normalized = origin
+
+ # 2. Validate formulation-independent declarations.
+ combine in (:product, :zip) || throw(ArgumentError(
+ "combine must be :product or :zip"
+ ))
+ identifier = String(cable_id)
+ isempty(identifier) && throw(ArgumentError("cable_id cannot be empty"))
+ nominal_data = nominal_data === nothing ? (;) : nominal_data
+ nominal_data isa NamedTuple || throw(ArgumentError(
+ "nominal_data must be a named tuple or nothing"
+ ))
+
+ # 3. Resolve intrinsic primitives against their contextual geometric boundaries.
+ # 4. Resolve pattern placements and compaction through `placements` dispatch.
+ # 5. Resolve longitudinal path radii while traversing the same physical tree.
+ # 6. Collect the resolved primitives as one CableGeometry in physical order.
+ geometry = resolve(EmptyBoundary(), normalized)
+ isempty(geometry.regions) && throw(ArgumentError(
+ "a cable design requires one region"
+ ))
+
+ # 7. Assign conductive primitives to the terminal declared by their Group.
+ # 8. Establish terminal order and the region-to-terminal map.
+ terminal_order = Symbol[]
+ for placed in geometry.regions
+ if placed.source.material.kind === :conductor && placed.terminal === nothing
+ throw(ArgumentError(
+ "conductive region :$(placed.source.tag) is not owned by a Group terminal"
+ ))
+ end
+ placed.terminal === nothing || placed.terminal in terminal_order ||
+ push!(terminal_order, placed.terminal)
+ end
+ isempty(terminal_order) && throw(ArgumentError(
+ "a cable design requires at least one retained terminal"
+ ))
+ terminal_map = Int[placed.terminal === nothing ? 0 :
+ something(findfirst(==(placed.terminal), terminal_order), 0)
+ for placed in geometry.regions]
+
+ reference = first(geometry.regions).source.material.T0
+ all(placed -> isapprox(placed.source.material.T0, reference), geometry.regions) ||
+ throw(ArgumentError("all cable materials must share one reference temperature"))
+
+ # 9. Freeze the authoritative origin and its completed geometry together.
+ T = promote_type(
+ (eltype(placed.primitive) for placed in geometry.regions)...,
+ (eltype(placed.source.material) for placed in geometry.regions)...
+ )
+ return CableDesign{T, typeof(normalized), typeof(geometry), typeof(nominal_data)}(
+ identifier,
+ normalized,
+ nominal_data,
+ geometry,
+ terminal_order,
+ terminal_map
+ )
+end
+
+function build(
+ ::Type{CableDesign},
+ cable_id::AbstractString,
+ parts::AbstractCablePart...;
+ nominal_data = nothing,
+ combine::Symbol = :product
+)
+ return build(CableDesign, cable_id, parts, nominal_data; combine)
+end
diff --git a/src/datamodel/design/enclosure.jl b/src/datamodel/design/enclosure.jl
new file mode 100644
index 000000000..3254543ca
--- /dev/null
+++ b/src/datamodel/design/enclosure.jl
@@ -0,0 +1,369 @@
+"""
+$(TYPEDEF)
+
+Mark a resolved fill or wall region that establishes an enclosure boundary.
+
+Geometry methods use this marker to identify an enclosure boundary.
+"""
+struct EnclosureBoundary end
+
+"""
+$(TYPEDEF)
+
+Contain a physical cable object in an explicit cross-section, fill, and wall.
+
+$(TYPEDFIELDS)
+"""
+struct Enclosure{
+ A,
+ S <: AbstractPrimitive,
+ E <: AbstractCablePart,
+ F,
+ W
+} <: AbstractCablePart
+ "Physical enclosure identity."
+ tag::Symbol
+ "Pose relative to the containing frame."
+ at::A
+ "Containing cross-sectional primitive."
+ primitive::S
+ "Enclosed physical object."
+ item::E
+ "Filling material or filling Region."
+ fill::F
+ "Optional physical wall."
+ wall::W
+
+ function Enclosure(
+ tag::Symbol,
+ at::A,
+ primitive::S,
+ item::E,
+ fill::F,
+ wall::W
+ ) where {A, S <: AbstractPrimitive, E <: AbstractCablePart, F, W}
+ isempty(String(tag)) && throw(ArgumentError("enclosure tag cannot be empty"))
+ at isa Pose2 || throw(ArgumentError("enclosure pose must resolve to Pose2"))
+ fill isa Union{Material, Region} ||
+ throw(ArgumentError("enclosure fill must be Material or Region"))
+ wall isa Union{Nothing, AbstractCablePart} ||
+ throw(ArgumentError("enclosure wall must be a cable part or nothing"))
+ return new{A, S, E, F, W}(tag, at, primitive, item, fill, wall)
+ end
+end
+
+function Base.:(==)(left::Enclosure, right::Enclosure)
+ left.tag == right.tag && left.at == right.at &&
+ left.primitive == right.primitive && left.item == right.item &&
+ left.fill == right.fill && left.wall == right.wall
+end
+
+function resolve(
+ container::Disk,
+ holes::Tuple,
+ material::Material,
+ tag::Symbol
+)
+ length(holes) == 1 && return _disk_fill(container, only(holes), material, tag)
+ # A coaxial terminal may supply a disk plus several annuli as its occupied
+ # shapes. Their union is one disk only when the radial intervals are complete.
+ # Preserve that exact annular fill instead of manufacturing a Boolean region
+ # that computational flattening would classify as non-radial interstitial fill.
+ if all(holes) do shape
+ shape isa Union{Disk, Annulus} &&
+ iszero(shape.at.x) && iszero(shape.at.y) &&
+ iszero(container.at.x) && iszero(container.at.y)
+ end
+ intervals = sort!([(r_in(shape), r_ex(shape)) for shape in holes]; by = first)
+ outer = zero(container.r)
+ tolerance = 100 * eps(typeof(float(_geometry_scalar(container.r)))) * container.r
+ for (inner, radius) in intervals
+ inner <= outer + tolerance || return _difference_fill(container, holes, material, tag)
+ outer = max(outer, radius)
+ end
+ return _disk_fill(container, Disk(outer), material, tag)
+ end
+ return _difference_fill(container, holes, material, tag)
+end
+
+function _disk_fill(
+ container::Disk,
+ contents::Disk,
+ material::Material,
+ tag::Symbol
+)
+ fill_region = Region(
+ Symbol(tag, :_fill),
+ Annulus(support(contents), container.r),
+ material
+ )
+ return resolve(EmptyBoundary(), fill_region)
+end
+
+function _disk_fill(container::Disk, contents, material::Material, tag::Symbol)
+ _difference_fill(container, (contents,), material, tag)
+end
+
+function _difference_fill(container, holes, material, tag)
+ source = Region(Symbol(tag, :_fill), _definition(container), material)
+ primitive = DifferenceShape(container, holes)
+ area(primitive) > zero(eltype(primitive)) || throw(DomainError(
+ area(primitive), "enclosure fill must have positive area"
+ ))
+ return CableGeometry(PlacedRegion[PlacedRegion(source, primitive)], boundary(container))
+end
+
+function resolve(container::AbstractShape, holes::Tuple, material::Material, tag::Symbol)
+ _difference_fill(container, holes, material, tag)
+end
+
+_definition(primitive::Disk) = Disk(primitive.r)
+_definition(primitive::Rectangle) = Rectangle(primitive.w, primitive.h)
+_definition(primitive::Ellipse) = Ellipse(primitive.a, primitive.b)
+_definition(shape::SectorShape) = shape.primitive
+_definition(primitive::Annulus) = Annulus(primitive.ri, primitive.ro)
+_definition(primitive::Polygon) = Polygon(primitive.points)
+
+function resolve(
+ ::AbstractPrimitive,
+ holes::Tuple,
+ fill::Region,
+ ::Symbol
+)
+ length(holes) == 1 ? resolve(only(holes), fill) :
+ throw(ArgumentError(
+ "an explicit fill Region requires one occupied boundary"
+ ))
+end
+
+function _contained(container::Disk, child::AbstractShape)
+ extent = support(child)
+ tolerance = 5.0e-6 * container.r
+ return extent <= container.r + tolerance
+end
+
+function _contained(container::Rectangle, child::AbstractShape)
+ return support(child, 0) <= container.w / 2 &&
+ support(child, pi) <= container.w / 2 &&
+ support(child, pi / 2) <= container.h / 2 &&
+ support(child, -pi / 2) <= container.h / 2
+end
+
+function _contained(container::Ellipse, child::AbstractShape)
+ tolerance = sqrt(eps(float(max(container.a, container.b))))
+ angles = range(zero(container.a), oftype(container.a, 2pi); length = 4097)
+ return all(Iterators.take(angles, 4096)) do angle
+ support(child, angle) <= support(container, angle) + tolerance
+ end
+end
+
+function _contained(container::Annulus, child::Disk)
+ offset = hypot(
+ child.at.x - container.at.x,
+ child.at.y - container.at.y
+ )
+ return (offset + child.r < container.ro ||
+ isapprox(offset + child.r, container.ro)) &&
+ (offset - child.r > container.ri ||
+ isapprox(offset - child.r, container.ri))
+end
+
+function _contained(container::Annulus, child::Annulus)
+ concentric = isapprox(child.at.x, container.at.x) &&
+ isapprox(child.at.y, container.at.y)
+ return concentric &&
+ (child.ro < container.ro || isapprox(child.ro, container.ro)) &&
+ (child.ri > container.ri || isapprox(child.ri, container.ri))
+end
+
+function _contained(container::Annulus, child::BentStrip)
+ concentric = isapprox(child.at.x, container.at.x) &&
+ isapprox(child.at.y, container.at.y)
+ return concentric &&
+ (child.ro < container.ro || isapprox(child.ro, container.ro)) &&
+ (child.ri > container.ri || isapprox(child.ri, container.ri))
+end
+
+function _contained(container::SectorShape, child::Disk)
+ accommodates(container, centroid(child), child.r)
+end
+
+function _contained(container::SectorShape, child::Polygon)
+ cosine = cos(child.at.φ)
+ sine = sin(child.at.φ)
+ tolerance = 5.0e-6 * container.primitive.r_back
+ return all(child.points) do point
+ resolved = (
+ child.at.x + cosine * point[1] - sine * point[2],
+ child.at.y + sine * point[1] + cosine * point[2]
+ )
+ _geometry_scalar(clearance(container, resolved) + tolerance) >= 0
+ end
+end
+
+function _contained(::AbstractShape, ::AbstractShape)
+ throw(ArgumentError(
+ "enclosure containment is not implemented for this resolved shape pair"
+ ))
+end
+
+function fill_holes(::AbstractCablePart, contents::CableGeometry)
+ return Tuple(source.primitive for source in contents.regions)
+end
+
+fill_holes(::Enclosure, contents::CableGeometry) = (boundary(contents),)
+
+function fill_holes(group::Group, contents::CableGeometry)
+ if group.boundary isa Disk && last(contents.regions).source.primitive isa Rectangle
+ outer = boundary(contents)
+ center = first(contents.regions).primitive
+ if center isa Disk && center.at.x == outer.at.x && center.at.y == outer.at.y
+ # The formation already resolved its exact occupied disk.
+ return (outer,)
+ end
+ end
+ group.item isa Assembly || return Tuple(source.primitive for source in contents.regions)
+ outer = boundary(contents)
+ outer isa AssemblyShape || throw(ArgumentError(
+ "a grouped assembly must retain its member boundaries"
+ ))
+ return outer.members
+end
+
+function fill_holes(assembly::Assembly, contents::CableGeometry)
+ outer = boundary(contents)
+ outer isa AssemblyShape || throw(ArgumentError(
+ "an enclosed assembly must retain its member boundaries"
+ ))
+ return outer.members
+end
+
+function resolve(context::EmptyBoundary, enclosure::Enclosure)
+ container = resolve(EmptyBoundary(), enclosure.primitive)
+ contents = resolve(EmptyBoundary(), enclosure.item)
+ return _resolve_enclosure(enclosure, container, contents)
+end
+
+function _resolve_enclosure(enclosure::Enclosure, container, contents::CableGeometry)
+ holes = enclosure.fill isa Material ?
+ fill_holes(enclosure.item, contents) : (boundary(contents),)
+ all(hole -> _contained(container, hole), holes) || throw(DomainError(
+ holes,
+ "enclosed geometry must fit inside the enclosure boundary"
+ ))
+ # A complete circular partition is one occupied disk even through terminal
+ # and stack wrappers. Incomplete formations retain their individual holes.
+ occupied = boundary(contents)
+ if container isa Disk && occupied isa Disk &&
+ occupied.at.x == container.at.x && occupied.at.y == container.at.y &&
+ isapprox(sum(area, holes), area(occupied);
+ rtol=0, atol=geometry_tolerance(area(occupied)))
+ holes = (occupied,)
+ end
+
+ regions = PlacedRegion[]
+ for source in contents.regions
+ placed = resolve(enclosure.at, source)
+ push!(regions,
+ PlacedRegion(
+ source.source,
+ placed.primitive,
+ source.terminal,
+ placed.placement,
+ source.paths
+ ))
+ end
+
+ remaining_area = area(container) - sum(area, holes; init = zero(area(container)))
+ tolerance = geometry_tolerance(area(container))
+ remaining_area >= -tolerance || throw(DomainError(
+ remaining_area, "enclosure contents exceed the containing boundary area"
+ ))
+ if enclosure.fill isa Region || remaining_area > tolerance
+ fill_result = resolve(
+ container,
+ holes,
+ enclosure.fill,
+ enclosure.tag
+ )
+ outer_extent = support(container)
+ isapprox(support(boundary(fill_result)), outer_extent) || throw(DomainError(
+ support(boundary(fill_result)),
+ "enclosure fill must reach the containing boundary"
+ ))
+ for source in fill_result.regions
+ placed = resolve(enclosure.at, source)
+ push!(regions,
+ PlacedRegion(
+ source.source,
+ placed.primitive,
+ source.terminal,
+ (
+ patterns = (
+ placed.placement.patterns...,
+ (
+ owner = Enclosure,
+ pattern = EnclosureBoundary(),
+ member = 1,
+ pose = enclosure.at
+ )
+ ),
+ ),
+ source.paths
+ ))
+ end
+ end
+
+ outer = container
+ if enclosure.wall !== nothing
+ wall_result = resolve(container, enclosure.wall)
+ for source in wall_result.regions
+ placed = resolve(enclosure.at, source)
+ push!(regions,
+ PlacedRegion(
+ source.source,
+ placed.primitive,
+ source.terminal,
+ (
+ patterns = (
+ placed.placement.patterns...,
+ (
+ owner = Enclosure,
+ pattern = EnclosureBoundary(),
+ member = 1,
+ pose = enclosure.at
+ )
+ ),
+ ),
+ source.paths
+ ))
+ end
+ outer = boundary(wall_result)
+ end
+ return CableGeometry(regions, resolve(enclosure.at, boundary(outer)))
+end
+
+function resolve(context::AbstractShape, enclosure::Enclosure)
+ container = resolve(EmptyBoundary(), enclosure.primitive)
+ container isa Annulus || throw(ArgumentError(
+ "a contextual Enclosure requires an annular containing primitive"
+ ))
+ placed = resolve(enclosure.at, container)
+ context isa Disk &&
+ isapprox(context.at.x, placed.at.x) &&
+ isapprox(context.at.y, placed.at.y) &&
+ context.r <= placed.ri + geometry_tolerance(placed.ri) || throw(DomainError(
+ context,
+ "an annular Enclosure must be concentric with and outside the preceding circular boundary"
+ ))
+ # A filled course defines its complement down to the preceding physical
+ # boundary. Explicit wire radii remain fixed. Contextual rings use this disk.
+ # An explicit fill Region has its own geometry and is not extended.
+ enclosure.fill isa Material || isapprox(context.r, placed.ri;
+ rtol=0, atol=geometry_tolerance(placed.ri)) || throw(DomainError(
+ context, "an explicit fill Region must continue the preceding boundary"))
+ inner = Disk(context.r, container.at)
+ contents = resolve(inner, enclosure.item)
+ effective = Annulus(context.r, container.ro, container.at)
+ return _resolve_enclosure(enclosure, effective, contents)
+end
diff --git a/src/datamodel/design/group.jl b/src/datamodel/design/group.jl
new file mode 100644
index 000000000..f793ebfe0
--- /dev/null
+++ b/src/datamodel/design/group.jl
@@ -0,0 +1,507 @@
+"""
+$(TYPEDEF)
+
+Represent one repeated-member coalescing scope.
+
+Each conductive descendant resolves to terminal `name`. A group containing no
+conductive descendant is a valid physical group and contributes no terminal.
+
+$(TYPEDFIELDS)
+"""
+struct Group{A, E <: AbstractCablePart, P, H, C, B} <: AbstractCablePart
+ "Coalesced terminal name when the group contains conductive descendants."
+ name::Symbol
+ "Pose relative to the containing frame."
+ at::A
+ "Repeated physical member."
+ item::E
+ "Cross-sectional placement definition."
+ pattern::P
+ "Longitudinal path definition or `nothing`."
+ path::H
+ "Compaction definition or `nothing`."
+ compact::C
+ "Formation boundary, or rectangular-course packing limit. `nothing` for unbounded groups."
+ boundary::B
+
+ function Group(
+ name::Symbol,
+ at::A,
+ item::E,
+ pattern::P,
+ path::H,
+ compact::C,
+ boundary::B
+ ) where {A, E <: AbstractCablePart, P, H, C, B}
+ isempty(String(name)) && throw(ArgumentError("group name cannot be empty"))
+ at isa Pose2 || throw(ArgumentError("group pose must resolve to Pose2"))
+ boundary === nothing || boundary isa Union{Disk, Sector} || throw(ArgumentError(
+ "group boundary must be a Disk, Sector, or nothing"
+ ))
+ boundary === nothing || pattern === nothing || throw(ArgumentError(
+ "a bounded formation owns its member placements and cannot carry a group pattern"
+ ))
+ boundary === nothing || path === nothing || throw(ArgumentError(
+ "a bounded formation cannot carry a group-level longitudinal path"
+ ))
+ boundary isa Sector && compact !== nothing && throw(ArgumentError(
+ "sector bounded formations are intrinsically compacted"
+ ))
+ return new{A, E, P, H, C, B}(
+ name, at, item, pattern, path, compact, boundary
+ )
+ end
+end
+
+Group(name::Symbol, at, item, pattern, path, compact) =
+ Group(name, at, item, pattern, path, compact, nothing)
+
+function Base.:(==)(left::Group, right::Group)
+ left.name == right.name && left.at == right.at && left.item == right.item &&
+ left.pattern == right.pattern && left.path == right.path &&
+ left.compact == right.compact && left.boundary == right.boundary
+end
+
+function _path_radius(pattern::Ring, pose::Pose2, primitive::Annulus)
+ return iszero(pattern.r) ? (r_in(primitive) + r_ex(primitive)) / 2 : pattern.r
+end
+_path_radius(pattern::Ring, pose::Pose2, primitive::AbstractShape) = pattern.r
+function _path_radius(::Nothing, pose::Pose2, primitive::Union{Annulus, BentStrip})
+ (r_in(primitive) + r_ex(primitive)) / 2
+end
+_path_radius(::Nothing, pose::Pose2, primitive::AbstractShape) = hypot(pose.x, pose.y)
+_path_radius(pattern, pose::Pose2, primitive::AbstractShape) = hypot(pose.x, pose.y)
+
+function _resolved_path_radius(compact, pattern, pose, primitive)
+ _path_radius(pattern, pose, primitive)
+end
+function _resolved_path_radius(
+ compact, pattern, pose, primitive::Union{Polygon, BentStrip}
+)
+ center = centroid(primitive)
+ return hypot(center...)
+end
+function _resolved_path_radius(::FillFactor, pattern, pose, primitive::Annulus)
+ (r_in(primitive) + r_ex(primitive)) / 2
+end
+
+_member_definition(region::Region) = region.primitive
+_member_definition(::AbstractCablePart) = nothing
+
+function bounded_declarations(group::Group)
+ group.item isa Stack || throw(ArgumentError(
+ "a bounded formation must own an ordered Stack of strand declarations"
+ ))
+ parts = collect(group.item.items)
+ for part in parts
+ part isa Group || throw(ArgumentError(
+ "a bounded formation Stack may contain only strand Groups"
+ ))
+ part.item isa Region || throw(ArgumentError(
+ "each bounded strand declaration must own one Region"
+ ))
+ part.at == Pose2(0, 0, 0) || throw(ArgumentError(
+ "bounded strand declarations must share the formation origin"
+ ))
+ part.boundary === nothing || throw(ArgumentError(
+ "nested bounded formations are not supported"
+ ))
+ part.pattern === nothing || throw(ArgumentError(
+ "a bounded strand inventory cannot declare a Ring or other placement"
+ ))
+ part.compact === nothing || throw(ArgumentError(
+ "bounded compaction belongs to the containing formation"
+ ))
+ end
+ return parts
+end
+
+function strand_path(paths, course::Int, maximum_course::Int)
+ iszero(course) && return nothing
+ paths === nothing && return nothing
+ paths isa Tuple || return paths
+ length(paths) == maximum_course || throw(DimensionMismatch(
+ "lay schedule requires exactly $maximum_course inferred courses; got $(length(paths))"
+ ))
+ return paths[course]
+end
+
+function circular_members(boundary_shape::Disk, parts, compact::Bool)
+ length(parts) == 2 || throw(ArgumentError(
+ "a circular stranded core requires center and outer strand declarations"
+ ))
+ center_part, strand_part = parts
+ center = center_part.item.primitive
+ strand = strand_part.item.primitive
+ center isa Disk || throw(ArgumentError(
+ "a circular stranded core requires a Disk center wire"
+ ))
+ strand isa Disk || throw(ArgumentError(
+ "circular strand packing requires Disk source wires"
+ ))
+ center.r <= boundary_shape.r || throw(DomainError(
+ center.r, "the center wire exceeds the circular core boundary"
+ ))
+
+ outer = circular_courses(boundary_shape, center, strand, compact)
+ sites = [(
+ site = (boundary_shape.at.x, boundary_shape.at.y),
+ course = 0,
+ member = 1,
+ angle = zero(boundary_shape.r)
+ ); outer]
+ maximum_course = maximum(getproperty.(sites, :course))
+ members = NamedTuple[]
+ for (member, placement) in enumerate(sites)
+ source = member == 1 ? center_part.item : strand_part.item
+ path = member == 1 ? nothing :
+ strand_path(strand_part.path, placement.course, maximum_course)
+ angle = placement.angle
+ push!(members, (;
+ source,
+ placement.course,
+ member,
+ path,
+ angle,
+ placement.site
+ ))
+ end
+ primitives = compact ? deform_disk_members(boundary_shape, members) :
+ [resolve(Pose2(member.site..., member.angle), member.source.primitive)
+ for member in members]
+ return members, primitives
+end
+
+function sector_members(boundary_shape::SectorShape, parts)
+ length(parts) == 1 || throw(ArgumentError(
+ "a sector stranded core uses one declaration for its center and course wires"
+ ))
+ part = only(parts)
+ wire = part.item.primitive
+ wire isa Disk || throw(ArgumentError(
+ "a sector stranded core requires Disk source wires"
+ ))
+ courses, primitives, maximum_course = sector_courses(boundary_shape, wire)
+ members = [(
+ source = part.item,
+ placement.course,
+ member,
+ path = strand_path(part.path, placement.course, maximum_course),
+ placement.angle,
+ placement.site
+ ) for (member, placement) in enumerate(courses)]
+ return members, primitives
+end
+
+function rectangular_members(boundary_shape::Disk, parts, compact::Bool)
+ compact || throw(ArgumentError(
+ "rectangular strands require area-preserving bending"
+ ))
+ length(parts) == 2 || throw(ArgumentError(
+ "a rectangular stranded core requires center and strand declarations"
+ ))
+ center_part, strand_part = parts
+ center = center_part.item.primitive
+ strand = strand_part.item.primitive
+ center isa Disk || throw(ArgumentError(
+ "a rectangular stranded core requires a Disk center wire"
+ ))
+ strand isa Rectangle || throw(ArgumentError(
+ "rectangular strand packing requires Rectangle source strands"
+ ))
+ strips = rectangular_strands(boundary_shape, center, strand)
+ maximum_course = maximum(getproperty.(strips, :course))
+ members = NamedTuple[(
+ source = center_part.item,
+ course = 0,
+ member = 1,
+ path = nothing,
+ angle = zero(boundary_shape.r),
+ site = (boundary_shape.at.x, boundary_shape.at.y)
+ )]
+ primitives = AbstractShape[
+ resolve(Pose2(boundary_shape.at.x, boundary_shape.at.y, boundary_shape.at.φ), center)
+ ]
+ for strip in strips
+ push!(members, (
+ source = strand_part.item,
+ strip.course,
+ member = length(members) + 1,
+ path = strand_path(strand_part.path, strip.course, maximum_course),
+ angle = strip.primitive.at.φ,
+ strip.site
+ ))
+ push!(primitives, strip.primitive)
+ end
+ return members, primitives
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct the members and placed primitives of a bounded wire formation from
+its physical boundary, ordered part declarations, and compaction selection.
+Coordinates and dimensions are in meters. Return `(members, primitives)` in
+construction order.
+"""
+function bounded_members(boundary_shape::Disk, parts, compact::Bool)
+ outer = last(parts).item.primitive
+ return outer isa Disk ?
+ circular_members(boundary_shape, parts, compact) :
+ rectangular_members(boundary_shape, parts, compact)
+end
+
+bounded_members(boundary_shape::SectorShape, parts, ::Nothing) =
+ sector_members(boundary_shape, parts)
+
+"""
+$(TYPEDSIGNATURES)
+
+Resolve a bounded wire group into `(boundary_shape, members, primitives)`.
+The returned geometric boundary follows the group's pose and the occupied rectangular
+courses when present. Coordinates and dimensions are in meters. Member and
+primitive order follows construction. An empty formation raises `ArgumentError`.
+"""
+function bounded_members(group::Group)
+ boundary_shape = resolve(group.at, group.boundary)
+ parts = bounded_declarations(group)
+ members, primitives = bounded_members(boundary_shape, parts, group.compact)
+ isempty(members) && throw(ArgumentError(
+ "a bounded formation requires at least one strand"
+ ))
+ if boundary_shape isa Disk && last(parts).item.primitive isa Rectangle
+ # Complete bent courses occupy exactly this disk. The requested
+ # packing limit is not an additional physical layer.
+ boundary_shape = Disk(r_ex(last(primitives)), boundary_shape.at)
+ end
+ return boundary_shape, members, primitives
+end
+
+function bounded_pose(absolute, origin::Pose2, angle)
+ dx = absolute[1] - origin.x
+ dy = absolute[2] - origin.y
+ cosine = cos(origin.φ)
+ sine = sin(origin.φ)
+ return Pose2(
+ cosine * dx + sine * dy,
+ -sine * dx + cosine * dy,
+ angle
+ )
+end
+
+function resolve_bounded(group::Group)
+ outer, members, primitives = bounded_members(group)
+ length(primitives) == length(members) || throw(DimensionMismatch(
+ "bounded resolution must preserve the inferred strand count"
+ ))
+ formation_center = centroid(outer)
+ regions = PlacedRegion[]
+ for (formation_member, (member, primitive)) in enumerate(zip(members, primitives))
+ absolute_center = centroid(primitive)
+ pose = bounded_pose(member.site, group.at, member.angle)
+ patterns = ((
+ owner = Group,
+ pattern = BoundedPlacement(outer, member.course),
+ member = formation_member,
+ pose = pose
+ ),)
+ paths = member.path === nothing ? () :
+ ((
+ path = member.path,
+ radius = _path_radius(
+ nothing,
+ Pose2(absolute_center[1] - formation_center[1],
+ absolute_center[2] - formation_center[2]),
+ primitive
+ )
+ ),)
+ terminal = member.source.material.kind === :conductor ? group.name : nothing
+ push!(regions,
+ PlacedRegion(
+ member.source,
+ primitive,
+ terminal,
+ (patterns = patterns,),
+ paths
+ ))
+ end
+ return CableGeometry(regions, outer)
+end
+
+_radial_half_extent(definition::Disk) = definition.r
+_radial_half_extent(definition::Rectangle) = definition.h / 2
+function _radial_half_extent(definition::AbstractPrimitive)
+ support(resolve(EmptyBoundary(), definition))
+end
+
+_contextual_pattern(pattern, item, child, compact, context) = pattern
+function _contextual_pattern(
+ pattern::Ring,
+ item,
+ child,
+ compact,
+ context
+)
+ definition = _member_definition(item)
+ inner = context isa EmptyBoundary ? zero(support(boundary(child))) : support(context)
+ radial = definition === nothing ? support(boundary(child)) :
+ _radial_half_extent(definition)
+ radius = something(pattern.r, inner + radial)
+ count = if pattern.n isa Int
+ pattern.n
+ elseif definition === nothing
+ capacity(Ring, radius, support(boundary(child)); gap_frac = pattern.gap_frac)
+ else
+ capacity(
+ Ring(pattern.n; r = radius, φ0 = pattern.φ0,
+ span = pattern.span, gap_frac = pattern.gap_frac),
+ definition,
+ compact
+ )
+ end
+ count > 0 || throw(ArgumentError(
+ "the group geometry cannot admit one member"
+ ))
+ return Ring(
+ count;
+ r = radius,
+ φ0 = pattern.φ0,
+ span = pattern.span,
+ gap_frac = pattern.gap_frac
+ )
+end
+
+function _group_placements(pattern, item, child, compact, context)
+ concrete = _contextual_pattern(pattern, item, child, compact, context)
+ subject = _member_definition(item)
+ subject === nothing && (subject = child)
+ return concrete, placements(concrete, subject, compact)
+end
+
+function _minimum_radius(primitive::Union{Annulus, BentStrip})
+ iszero(primitive.at.x) && iszero(primitive.at.y) && return r_in(primitive)
+ return invoke(_minimum_radius, Tuple{AbstractShape}, primitive)
+end
+function _minimum_radius(primitive::AbstractShape)
+ center = centroid(primitive)
+ center_radius = hypot(center...)
+ iszero(center_radius) && return zero(eltype(primitive))
+ φ = atan(center[2], center[1])
+ return -support(primitive, φ + pi)
+end
+
+function _resolve_group(
+ context::Union{EmptyBoundary, AbstractShape},
+ group::Group
+)
+ if group.boundary !== nothing
+ context isa EmptyBoundary || throw(ArgumentError(
+ "a bounded stranded formation must be the innermost physical formation"
+ ))
+ return resolve_bounded(group)
+ end
+ if group.pattern === nothing
+ child = resolve(context, group.item)
+ regions = PlacedRegion[]
+ for source in child.regions
+ placed = resolve(group.at, source)
+ terminal = source.source.material.kind === :conductor ? group.name :
+ source.terminal
+ # This scope coalesces even terminal-preserving child assemblies.
+ patterns = map(placed.placement.patterns) do entry
+ entry.owner === Assembly ? merge(entry, (owner = Group,)) : entry
+ end
+ center = centroid(source.primitive)
+ paths = group.path === nothing ? source.paths :
+ (source.paths...,
+ (
+ path = group.path,
+ radius = _path_radius(
+ nothing,
+ Pose2(center[1], center[2], 0),
+ source.primitive
+ )
+ ))
+ push!(regions, PlacedRegion(
+ source.source,
+ placed.primitive,
+ terminal,
+ (patterns = patterns,),
+ paths
+ ))
+ end
+ outer = group.path === nothing ? boundary(child) : Disk(support(boundary(child)))
+ return CableGeometry(regions, resolve(group.at, outer))
+ end
+
+ child = resolve(EmptyBoundary(), group.item)
+ pattern, members = _group_placements(
+ group.pattern, group.item, child, group.compact, context
+ )
+ isempty(members) && throw(ArgumentError("group placement cannot be empty"))
+
+ regions = PlacedRegion[]
+ local_extent = nothing
+ for (member, placement) in enumerate(members)
+ pose = _placement_pose(placement)
+ placed_at = group.at * pose
+ for source in child.regions
+ terminal = source.source.material.kind === :conductor ? group.name :
+ source.terminal
+ definition = _placement_definition(
+ placement,
+ source.source.primitive
+ )
+ local_primitive = placement isa _ResolvedPlacement ?
+ resolve(pose, definition) :
+ resolve(pose, source.primitive)
+ primitive = resolve(group.at, local_primitive)
+ placed = resolve(placed_at, source)
+ extent = support(local_primitive)
+ local_extent = local_extent === nothing ? extent : max(local_extent, extent)
+ patterns = map(placed.placement.patterns) do entry
+ entry.owner === Assembly ? merge(entry, (owner = Group,)) : entry
+ end
+ patterns = (patterns...,
+ (owner = Group, pattern = pattern, member = member, pose = pose))
+ paths = group.path === nothing ? source.paths :
+ (source.paths...,
+ (
+ path = group.path,
+ radius = _resolved_path_radius(
+ group.compact,
+ pattern,
+ pose,
+ local_primitive
+ )
+ ))
+ push!(regions, PlacedRegion(
+ source.source,
+ primitive,
+ terminal,
+ (patterns = patterns,),
+ paths
+ ))
+ end
+ end
+ local_extent === nothing && throw(ArgumentError("group placement cannot be empty"))
+ local_boundary = Disk(local_extent)
+ return CableGeometry(regions, resolve(group.at, local_boundary))
+end
+
+resolve(context::EmptyBoundary, group::Group) = _resolve_group(context, group)
+
+function resolve(context::AbstractShape, group::Group)
+ result = _resolve_group(context, group)
+ current_radius = support(context)
+ tolerance = sqrt(eps(typeof(float(current_radius)))) *
+ max(one(current_radius), current_radius)
+ for source in result.regions
+ minimum_radius = _minimum_radius(source.primitive)
+ minimum_radius + tolerance >= current_radius || throw(DomainError(
+ minimum_radius,
+ "group geometry overlaps the current stack boundary at radius $current_radius"
+ ))
+ end
+ return result
+end
diff --git a/src/datamodel/design/region.jl b/src/datamodel/design/region.jl
new file mode 100644
index 000000000..b735cae00
--- /dev/null
+++ b/src/datamodel/design/region.jl
@@ -0,0 +1,151 @@
+"""
+$(TYPEDEF)
+
+Represent one homogeneous physical domain with intrinsic geometry.
+
+$(TYPEDFIELDS)
+"""
+struct Region{P, M} <: AbstractCablePart
+ "Physical identity within its containing cable object."
+ tag::Symbol
+ "Intrinsic cross-sectional geometry."
+ primitive::P
+ "Constitutive material data."
+ material::M
+
+ function Region(tag::Symbol, primitive::P, material::M) where {
+ P, M
+ }
+ isempty(String(tag)) && throw(ArgumentError("region tag cannot be empty"))
+ primitive isa Union{AbstractPrimitive, Shell} || throw(ArgumentError(
+ "region geometry must be an intrinsic primitive or contextual Shell"
+ ))
+ material isa AbstractMaterial ||
+ throw(ArgumentError("region material must resolve to AbstractMaterial"))
+ return new{P, M}(tag, primitive, material)
+ end
+end
+
+# A material for the cable part of `role` in a region, restricted to the `allowed` kinds.
+function validate(material, ::Type{Region}, role::Symbol, allowed::Tuple)
+ material isa AbstractMaterial || throw(ArgumentError(
+ "$role material must resolve to AbstractMaterial"
+ ))
+ material.kind in allowed || throw(ArgumentError(
+ "$role material must have kind $(join(string.(allowed), " or ")); " *
+ "got :$(material.kind)"
+ ))
+ return material
+end
+
+function Base.:(==)(left::Region, right::Region)
+ left.tag == right.tag &&
+ left.primitive == right.primitive &&
+ left.material == right.material
+end
+
+function Base.isequal(left::Region, right::Region)
+ isequal(left.tag, right.tag) &&
+ isequal(left.primitive, right.primitive) &&
+ isequal(left.material, right.material)
+end
+
+function Base.hash(region::Region, h::UInt)
+ hash((:Region, region.tag, region.primitive, region.material), h)
+end
+
+"""
+$(TYPEDEF)
+
+Pair one physical region with its resolved primitive and retained terminal, with resolved declarations for placement and paths.
+
+Each placement retains its physical scope through the existing owner type.
+`Assembly` placements preserve independent terminals and are not strand courses.
+An enclosing `Group` coalesces those placements into its own terminal scope.
+Member indices, placement poses and longitudinal paths remain unchanged.
+
+$(TYPEDFIELDS)
+"""
+struct PlacedRegion{
+ R <: Region,
+ S <: AbstractShape,
+ P <: NamedTuple,
+ H <: Tuple
+}
+ "Authoritative homogeneous physical region."
+ source::R
+ "Resolved cross-sectional primitive with its absolute pose."
+ primitive::S
+ "Retained electrical terminal, or `nothing` for a nonconductive region."
+ terminal::Union{Nothing, Symbol}
+ "Ordered `(owner, pattern, member, pose)` placements. `Group` coalesces terminals, `Assembly` preserves them."
+ placement::P
+ "Ordered `(path, radius)` declarations traversed by the region."
+ paths::H
+
+ function PlacedRegion(
+ source::R,
+ primitive::S,
+ terminal::Union{Nothing, Symbol},
+ placement::P,
+ paths::H
+ ) where {
+ R <: Region,
+ S <: AbstractShape,
+ P <: NamedTuple,
+ H <: Tuple
+ }
+ terminal === nothing || !isempty(String(terminal)) ||
+ throw(
+ ArgumentError("placed-region terminal cannot be empty")
+ )
+ keys(placement) == (:patterns,) || throw(ArgumentError(
+ "placed-region placement must contain patterns"
+ ))
+ placement.patterns isa Tuple || throw(ArgumentError(
+ "placed-region patterns must be a tuple"
+ ))
+ all(placement.patterns) do entry
+ entry isa NamedTuple && keys(entry) == (:owner, :pattern, :member, :pose) &&
+ entry.owner isa Type && entry.owner <: AbstractCablePart &&
+ entry.member isa Integer && entry.member > 0 &&
+ entry.pose isa Pose2
+ end || throw(ArgumentError(
+ "placed-region patterns must contain a physical owner type, positive member indices and poses"
+ ))
+ all(paths) do entry
+ entry isa NamedTuple && keys(entry) == (:path, :radius) &&
+ entry.radius isa Real && isfinite(entry.radius) && entry.radius >= 0
+ end || throw(ArgumentError(
+ "placed-region paths must contain finite nonnegative radii"
+ ))
+ return new{R, S, P, H}(source, primitive, terminal, placement, paths)
+ end
+end
+
+function Base.:(==)(left::PlacedRegion, right::PlacedRegion)
+ left.source == right.source && left.primitive == right.primitive &&
+ left.terminal == right.terminal && left.placement == right.placement &&
+ left.paths == right.paths
+end
+
+function PlacedRegion(source::Region, primitive::AbstractShape)
+ return PlacedRegion(
+ source,
+ primitive,
+ nothing,
+ (patterns = (),),
+ ()
+ )
+end
+
+boundary(region::PlacedRegion) = boundary(region.primitive)
+area(region::PlacedRegion) = area(region.primitive)
+centroid(region::PlacedRegion) = centroid(region.primitive)
+support(region::PlacedRegion, φ::Real) = support(region.primitive, φ)
+
+function resolve(context::Union{EmptyBoundary, AbstractShape}, region::Region)
+ primitive = resolve(context, region.primitive)
+ placed = PlacedRegion(region, primitive)
+ return CableGeometry(PlacedRegion[placed], boundary(placed))
+end
diff --git a/src/datamodel/design/stack.jl b/src/datamodel/design/stack.jl
new file mode 100644
index 000000000..3fb4eb6c4
--- /dev/null
+++ b/src/datamodel/design/stack.jl
@@ -0,0 +1,78 @@
+"""
+$(TYPEDEF)
+
+Store an ordered outward composition of cable parts.
+
+$(TYPEDFIELDS)
+"""
+struct Stack{V <: AbstractVector} <: AbstractCablePart
+ "Physical members in inner-to-outer order."
+ items::V
+
+ function Stack(items::V) where {V <: AbstractVector}
+ isempty(items) && throw(ArgumentError("a stack requires at least one item"))
+ all(item -> item isa AbstractCablePart, items) ||
+ throw(ArgumentError("stack items must be cable parts"))
+ return new{V}(copy(items))
+ end
+end
+
+Stack(item::AbstractCablePart) = Stack(AbstractCablePart[item])
+Stack(items::AbstractCablePart...) = Stack(AbstractCablePart[items...])
+
+Base.:(==)(left::Stack, right::Stack) = left.items == right.items
+
+"""
+$(TYPEDEF)
+
+Store the resolved physical regions and outer boundary of one cable root.
+
+$(TYPEDFIELDS)
+"""
+struct CableGeometry{V <: AbstractVector, B <: AbstractShape}
+ "Resolved regions in physical traversal order."
+ regions::V
+ "Resolved outer boundary of the complete root."
+ outer::B
+
+ function CableGeometry(regions::V, outer::B) where {
+ V <: AbstractVector,
+ B <: AbstractShape
+ }
+ isempty(regions) && throw(ArgumentError(
+ "cable geometry requires at least one placed region"
+ ))
+ all(region -> region isa PlacedRegion, regions) || throw(ArgumentError(
+ "cable geometry regions must be PlacedRegion values"
+ ))
+ all(regions) do region
+ region_area = area(region)
+ center = centroid(region)
+ region_area isa Real && isfinite(region_area) && region_area > 0 &&
+ all(isfinite, center)
+ end || throw(ArgumentError(
+ "cable geometry requires finite, positive-area placed regions"
+ ))
+ extent = support(outer)
+ extent isa Real && isfinite(extent) && extent > 0 || throw(ArgumentError(
+ "cable geometry requires a finite positive outer extent"
+ ))
+ return new{V, B}(regions, outer)
+ end
+end
+
+boundary(geometry::CableGeometry) = geometry.outer
+
+Base.:(==)(left::CableGeometry, right::CableGeometry) =
+ left.regions == right.regions && left.outer == right.outer
+
+function resolve(context::Union{EmptyBoundary, AbstractShape}, stack::Stack)
+ regions = PlacedRegion[]
+ state = context
+ for item in stack.items
+ result = resolve(state, item)
+ append!(regions, result.regions)
+ state = boundary(result)
+ end
+ return CableGeometry(regions, state)
+end
diff --git a/src/datamodel/flatten.jl b/src/datamodel/flatten.jl
new file mode 100644
index 000000000..699a2b467
--- /dev/null
+++ b/src/datamodel/flatten.jl
@@ -0,0 +1,1648 @@
+"""
+$(TYPEDSIGNATURES)
+
+Apply the multiplicative overlength of every longitudinal path to conductor
+resistance \\[Ω/m\\]. An empty path tuple leaves the resistance unchanged.
+"""
+function path_corrected_resistance(resistance::T, ::Tuple{}) where {T}
+ resistance
+end
+
+function path_corrected_resistance(resistance::T, paths::Tuple) where {T}
+ inner = path_corrected_resistance(resistance, Base.front(paths))
+ entry = last(paths)
+ return inner * convert(T, overlength(entry.path, entry.radius))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Add the turns per unit length contributed by a tuple of longitudinal paths
+\\[1/m\\].
+"""
+function turns_per_length(paths::Tuple, ::Type{T}) where {T <: Real}
+ return sum(
+ entry -> inv(pitch(entry.path, entry.radius)),
+ paths;
+ init = zero(T)
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate the geometric mean distance between two conductor-zone element sets
+\\[m\\]. Coincident element centers use the greater element radius as their
+finite self-distance. Element areas weight heterogeneous zones by their
+cross-sectional participation.
+
+# Arguments
+
+- `left_coordinates`: element centers of the accumulated zone \\[m\\].
+- `left_radius`: element radius of the accumulated zone \\[m\\].
+- `right_coordinates`: element centers of the added zone \\[m\\].
+- `right_radius`: element radius of the added zone \\[m\\].
+- `left_element_area`: area represented by each accumulated element \\[m²\\].
+- `right_element_area`: area represented by each added element \\[m²\\].
+
+# Returns
+
+- Area-weighted geometric mean distance \\[m\\].
+"""
+function geometric_mean_distance(
+ left_coordinates,
+ left_radius::Real,
+ right_coordinates,
+ right_radius::Real,
+ left_element_area::Real = one(float(left_radius)),
+ right_element_area::Real = one(float(right_radius))
+)
+ isempty(left_coordinates) && throw(ArgumentError(
+ "the first conductor zone requires at least one element coordinate"
+ ))
+ isempty(right_coordinates) && throw(ArgumentError(
+ "the second conductor zone requires at least one element coordinate"
+ ))
+ logarithmic_sum, weights = promote(
+ zero(float(left_radius)),
+ zero(float(right_radius))
+ )
+ weight = left_element_area * right_element_area
+ weight > zero(weight) || throw(DomainError(
+ weight, "conductor-zone element areas must be positive"
+ ))
+ for left in left_coordinates, right in right_coordinates
+
+ distance = hypot(left[1] - right[1], left[2] - right[2])
+ effective_distance = iszero(distance) ? max(left_radius, right_radius) : distance
+ effective_distance > zero(effective_distance) || throw(DomainError(
+ effective_distance, "conductor-zone distance must be positive"
+ ))
+ logarithmic_sum += weight * log(effective_distance)
+ weights += weight
+ end
+ return exp(logarithmic_sum / weights)
+end
+
+function geometric_mean_distance(
+ left_coordinates,
+ left_radii::AbstractVector,
+ right_coordinates,
+ right_radii::AbstractVector,
+ left_element_areas::AbstractVector,
+ right_element_areas::AbstractVector
+)
+ isempty(left_coordinates) && throw(ArgumentError(
+ "the first conductor zone requires at least one element coordinate"
+ ))
+ isempty(right_coordinates) && throw(ArgumentError(
+ "the second conductor zone requires at least one element coordinate"
+ ))
+ length(left_coordinates) == length(left_radii) == length(left_element_areas) ||
+ throw(DimensionMismatch(
+ "first conductor coordinates, radii, and areas must have equal lengths"
+ ))
+ length(right_coordinates) == length(right_radii) == length(right_element_areas) ||
+ throw(DimensionMismatch(
+ "second conductor coordinates, radii, and areas must have equal lengths"
+ ))
+ logarithmic_sum,
+ weights = promote(
+ zero(float(first(left_radii))),
+ zero(float(first(right_radii)))
+ )
+ for left in eachindex(left_coordinates), right in eachindex(right_coordinates)
+
+ weight = left_element_areas[left] * right_element_areas[right]
+ weight > zero(weight) || throw(DomainError(
+ weight, "conductor-zone element areas must be positive"
+ ))
+ distance = hypot(
+ left_coordinates[left][1] - right_coordinates[right][1],
+ left_coordinates[left][2] - right_coordinates[right][2]
+ )
+ effective_distance = iszero(distance) ?
+ max(left_radii[left], right_radii[right]) : distance
+ effective_distance > zero(effective_distance) || throw(DomainError(
+ effective_distance, "conductor-zone distance must be positive"
+ ))
+ logarithmic_sum += weight * log(effective_distance)
+ weights += weight
+ end
+ return exp(logarithmic_sum / weights)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate the equivalent radial permeability of dielectric layers and apply
+the helical-solenoid correction associated with the enclosed conductor.
+
+# Arguments
+
+- `layers`: ordered dielectric layers with radii \\[m\\] and material
+ permeability \\[dimensionless\\].
+- `turns`: equivalent conductor turns per unit length \\[1/m\\].
+- `conductor_radius`: outer conductor radius \\[m\\].
+- `dielectric_radius`: outer dielectric radius \\[m\\].
+
+# Returns
+
+- Corrected relative permeability \\[dimensionless\\].
+"""
+function equivalent_dielectric_permeability(
+ layers,
+ turns::Real,
+ conductor_radius::Real,
+ dielectric_radius::Real
+)
+ isempty(layers) && throw(ArgumentError(
+ "equivalent dielectric permeability requires at least one layer"
+ ))
+ weighted = sum(layers) do layer
+ layer.material.mu_r * log(layer.r_ex / layer.r_in)
+ end
+ radial = weighted / log(dielectric_radius / conductor_radius)
+ return radial * solenoid_factor(
+ turns,
+ conductor_radius,
+ dielectric_radius
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Reduce one geometrically uniform conductor zone to resistance, GMR, path, and
+cross-sectional values. Primitive-specific methods preserve the implemented
+disk, annulus, and equivalent-area cable-sector assumptions.
+"""
+
+function conductor_zone(
+ definition::Disk,
+ primitive::Disk,
+ zone,
+ ::Type{T},
+ expected_inner
+) where {T <: Real}
+ source = first(zone)
+ material = convert(Material{T}, source.source.material)
+ radius = convert(T, primitive.r)
+ all(item -> isapprox(
+ area(item.primitive),
+ (one(T) * pi) * radius^2
+ ), zone) || throw(ArgumentError(
+ "resolved disk strands do not preserve their declared area"
+ ))
+ coordinates = Tuple{T, T}[convert.(T, centroid(item.primitive))
+ for item in zone]
+ center = conductor_zone_position(zone)
+ center_radius = hypot(
+ first(coordinates)[1] - center[1],
+ first(coordinates)[2] - center[2]
+ )
+ circular_locus = all(
+ point -> isapprox(
+ hypot(point[1] - center[1], point[2] - center[2]),
+ center_radius
+ ), coordinates)
+ element_area = (one(T) * pi) * radius^2
+ zone_area = length(zone) * element_area
+ zone_r_in,
+ zone_r_ex = if length(zone) == 1 && iszero(center_radius)
+ zero(T), radius
+ elseif !circular_locus
+ outer_radius = maximum(coordinates) do point
+ hypot(point[1] - center[1], point[2] - center[2]) + radius
+ end
+ expected_outer = expected_inner + 2radius
+ isapprox(outer_radius, expected_outer) || throw(ArgumentError(
+ "noncircular disk strand course must extend one strand diameter " *
+ "beyond the preceding course"
+ ))
+ expected_inner, outer_radius
+ else
+ declared_inner = center_radius - radius
+ isapprox(declared_inner, expected_inner) || throw(ArgumentError(
+ "disk strand layer does not begin at the preceding radial boundary"
+ ))
+ expected_inner, expected_inner + 2radius
+ end
+ patterned = any(entry -> entry.owner === Group, source.placement.patterns)
+ uniform_paths = all(item -> item.paths == source.paths, zone)
+ turns = uniform_paths ? turns_per_length(source.paths, T) :
+ sum(item -> turns_per_length(item.paths, T), zone) / length(zone)
+ base_resistance = tubular_resistance(
+ zero(T),
+ radius,
+ material.rho
+ )
+ resistance = if uniform_paths
+ path_corrected_resistance(base_resistance, source.paths) / length(zone)
+ else
+ resistances = map(zone) do item
+ path_corrected_resistance(base_resistance, item.paths)
+ end
+ reduce(parallel, resistances)
+ end
+ gmr = patterned ?
+ (circular_locus ?
+ strand_gmr(
+ center_radius,
+ length(zone),
+ radius,
+ material.mu_r
+ ) :
+ strand_gmr(coordinates, radius, material.mu_r)) :
+ tubular_gmr(zone_r_ex, zone_r_in, material.mu_r)
+ return (
+ r_in = zone_r_in,
+ r_ex = zone_r_ex,
+ area = zone_area,
+ wires = patterned ? length(zone) : 0,
+ turns = convert(T, turns),
+ resistance,
+ gmr,
+ element_radius = radius,
+ element_area,
+ coordinates,
+ position = center,
+ material,
+ pairwise_gmd = patterned
+ )
+end
+
+function conductor_zone(
+ definition::Annulus,
+ primitive::Annulus,
+ zone,
+ ::Type{T},
+ expected_inner
+) where {T <: Real}
+ length(zone) == 1 || throw(ArgumentError(
+ "flatten requires one region per annular conductor layer"
+ ))
+ source = only(zone)
+ material = convert(Material{T}, source.source.material)
+ center = (convert(T, primitive.at.x), convert(T, primitive.at.y))
+ zone_r_in = convert(T, primitive.ri)
+ zone_r_ex = convert(T, primitive.ro)
+ zone_area = (one(T) * pi) * (zone_r_ex^2 - zone_r_in^2)
+ isapprox(area(source.primitive), zone_area) || throw(ArgumentError(
+ "resolved annular conductor does not preserve its declared area"
+ ))
+ turns = turns_per_length(source.paths, T)
+ resistance = path_corrected_resistance(
+ material.rho / zone_area,
+ source.paths
+ )
+ return (
+ r_in = zone_r_in,
+ r_ex = zone_r_ex,
+ area = zone_area,
+ wires = isempty(source.paths) ? 0 : 1,
+ turns = convert(T, turns),
+ resistance,
+ gmr = tubular_gmr(zone_r_ex, zone_r_in, material.mu_r),
+ element_radius = zone_r_ex,
+ element_area = zone_area,
+ coordinates = Tuple{T, T}[center],
+ position = center,
+ material,
+ pairwise_gmd = false
+ )
+end
+
+function conductor_zone(
+ definition::Shell,
+ primitive::Annulus,
+ zone,
+ ::Type{T},
+ expected_inner
+) where {T <: Real}
+ length(zone) == 1 || throw(ArgumentError(
+ "flatten requires one region per contextual conductor shell"
+ ))
+ source = only(zone)
+ material = convert(Material{T}, source.source.material)
+ center = (convert(T, primitive.at.x), convert(T, primitive.at.y))
+ zone_r_in = convert(T, primitive.ri)
+ zone_r_ex = convert(T, primitive.ro)
+ isapprox(zone_r_in, expected_inner) || throw(ArgumentError(
+ "resolved conductor shell does not begin at the preceding radial boundary"
+ ))
+ isapprox(zone_r_ex - zone_r_in, definition.t) || throw(ArgumentError(
+ "resolved conductor shell does not preserve its declared thickness"
+ ))
+ zone_area = (one(T) * pi) * (zone_r_ex^2 - zone_r_in^2)
+ turns = turns_per_length(source.paths, T)
+ resistance = path_corrected_resistance(
+ material.rho / zone_area,
+ source.paths
+ )
+ return (
+ r_in = zone_r_in,
+ r_ex = zone_r_ex,
+ area = zone_area,
+ wires = isempty(source.paths) ? 0 : 1,
+ turns = convert(T, turns),
+ resistance,
+ gmr = tubular_gmr(zone_r_ex, zone_r_in, material.mu_r),
+ element_radius = zone_r_ex,
+ element_area = zone_area,
+ coordinates = Tuple{T, T}[center],
+ position = center,
+ material,
+ pairwise_gmd = false
+ )
+end
+
+function conductor_zone(
+ definition::Sector,
+ primitive::SectorShape,
+ zone,
+ ::Type{T},
+ expected_inner
+) where {T <: Real}
+ length(zone) == 1 || throw(ArgumentError(
+ "flatten requires one region per sector conductor"
+ ))
+ source = only(zone)
+ isempty(source.paths) || throw(ArgumentError(
+ "flatten does not support a helical sector conductor"
+ ))
+ material = convert(Material{T}, source.source.material)
+ zone_area = convert(T, area(primitive))
+ equivalent_radius = sqrt(zone_area / (one(T) * pi))
+ center = convert.(T, centroid(primitive))
+ return (
+ r_in = zero(T),
+ r_ex = equivalent_radius,
+ area = zone_area,
+ wires = 0,
+ turns = zero(T),
+ resistance = material.rho / zone_area,
+ gmr = equivalent_radius * exp(-material.mu_r / 4),
+ element_radius = equivalent_radius,
+ element_area = zone_area,
+ coordinates = Tuple{T, T}[center],
+ position = center,
+ material,
+ pairwise_gmd = false
+ )
+end
+
+function conductor_zone(
+ definition::AbstractPrimitive,
+ primitive::AbstractShape,
+ zone,
+ ::Type,
+ expected_inner
+)
+ throw(ArgumentError(
+ "flatten does not support conductor source/resolved pair " *
+ "$(nameof(typeof(definition))) → $(nameof(typeof(primitive)))"
+ ))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Reduce one homogeneous dielectric region to concentric radii and material
+properties. Primitive-specific methods retain circular and equivalent-area
+cable-sector assumptions.
+"""
+function dielectric_layer(
+ primitive::Annulus,
+ source::PlacedRegion,
+ ::Type{T}
+) where {T <: Real}
+ isempty(source.paths) || throw(ArgumentError(
+ "flatten does not support helical dielectric layers"
+ ))
+ center = (convert(T, primitive.at.x), convert(T, primitive.at.y))
+ material = source.source.material
+ material.kind === :conductor && throw(ArgumentError(
+ "a dielectric interval cannot contain a conductor material"
+ ))
+ return (
+ r_in = convert(T, r_in(source.primitive)),
+ r_ex = convert(T, r_ex(source.primitive)),
+ position = center,
+ material
+ )
+end
+
+function dielectric_layer(
+ primitive::ShellShape{
+ <:Any,
+ <:SectorShape,
+ <:SectorShape
+ },
+ source::PlacedRegion,
+ ::Type{T}
+) where {T <: Real}
+ isempty(source.paths) || throw(ArgumentError(
+ "flatten does not support a helical sector dielectric"
+ ))
+ material = source.source.material
+ material.kind === :conductor && throw(ArgumentError(
+ "a dielectric interval cannot contain a conductor material"
+ ))
+ inner_area = convert(T, area(primitive.inner))
+ outer_area = convert(T, area(primitive.outer))
+ inner_radius = sqrt(inner_area / (one(T) * pi))
+ outer_radius = sqrt(outer_area / (one(T) * pi))
+ center = convert.(T, centroid(primitive.inner))
+ return (
+ r_in = inner_radius,
+ r_ex = outer_radius,
+ position = center,
+ material
+ )
+end
+
+function dielectric_layer(
+ primitive::AbstractShape,
+ source::PlacedRegion,
+ ::Type
+)
+ throw(ArgumentError(
+ "flatten does not support dielectric primitive " *
+ "$(nameof(typeof(primitive)))"
+ ))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Reduce a nested repeated-wire conductor to parallel resistance,
+current-share-weighted GMR, equivalent temperature coefficient, turns, and
+radial extent.
+"""
+function member_resistance(definition::Disk, material)
+ return tubular_resistance(zero(definition.r), definition.r, material.rho)
+end
+
+function member_resistance(definition::Rectangle, material)
+ return strip_resistance(definition.h, definition.w, material.rho)
+end
+
+function radial_extent(shape::Disk, center)
+ return hypot(shape.at.x - center[1], shape.at.y - center[2]) + shape.r
+end
+
+function radial_extent(shape::Annulus, center)
+ return hypot(shape.at.x - center[1], shape.at.y - center[2]) + shape.ro
+end
+
+function radial_extent(shape::Polygon, center)
+ cosine = cos(shape.at.φ)
+ sine = sin(shape.at.φ)
+ return maximum(shape.points) do point
+ x = shape.at.x + cosine * point[1] - sine * point[2]
+ y = shape.at.y + sine * point[1] + cosine * point[2]
+ hypot(x - center[1], y - center[2])
+ end
+end
+
+function radial_extent(shape::BentStrip, center)
+ dx = shape.at.x - center[1]
+ dy = shape.at.y - center[2]
+ offset = hypot(dx, dy)
+ iszero(offset) && return shape.ro
+ direction = atan(dy, dx)
+ period = oftype(direction, 2pi)
+ relative = mod(direction - shape.at.φ + pi, period) - pi
+ nearest = clamp(relative, -shape.span / 2, shape.span / 2)
+ cosine = cos(relative - nearest)
+ return sqrt(max(
+ offset^2 + shape.ri^2 + 2offset * shape.ri * cosine,
+ offset^2 + shape.ro^2 + 2offset * shape.ro * cosine
+ ))
+end
+
+function radial_extent(shape::Rectangle, center)
+ cosine = cos(shape.at.φ)
+ sine = sin(shape.at.φ)
+ half_width = shape.w / 2
+ half_height = shape.h / 2
+ return maximum(
+ ((-half_width, -half_height), (half_width, -half_height),
+ (half_width, half_height), (-half_width, half_height))
+ ) do point
+ x = shape.at.x + cosine * point[1] - sine * point[2]
+ y = shape.at.y + sine * point[1] + cosine * point[2]
+ hypot(x - center[1], y - center[2])
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate the mutual GMD of two resolved strand sections \\[m\\].
+General shapes retain the centroid-distance approximation. An annulus
+surrounding the other section uses the uniform-current area average from
+Rosa [rosa1908](@cite), Eq. (57):
+
+```math
+\\log D = \\frac{r_o^2\\log r_o-r_i^2\\log r_i}{r_o^2-r_i^2}-\\frac12.
+```
+
+# Arguments
+
+- `left`, `right`: resolved strand cross-sections, with coordinates in \\[m\\].
+
+# Returns
+
+- Mutual geometric mean distance \\[m\\].
+
+# Notes
+
+The annular expression is independent of the enclosed section's shape or
+eccentricity while that section remains entirely inside the annular hole.
+It does not replace the separate annular self-GMR or imply an exact integral
+for arbitrary deformed wire strands.
+
+# Errors
+
+- `ArgumentError`: coincident centroids without an enclosing annular section.
+"""
+function geometric_mean_distance(left::AbstractShape, right::AbstractShape)
+ left_center, right_center = centroid(left), centroid(right)
+ distance = hypot(left_center[1] - right_center[1], left_center[2] - right_center[2])
+ distance > zero(distance) || throw(ArgumentError(
+ "nested conductor members must have distinct centroids unless one is an enclosing annulus"
+ ))
+ return distance
+end
+
+function geometric_mean_distance(left::Annulus, right::AbstractShape)
+ tolerance = 64eps(float(nominal(left.ro)))
+ if left.ri > zero(left.ri) && radial_extent(right, centroid(left)) <= left.ri + tolerance
+ # A dimensionless ratio of radii avoids subtraction of nearly equal
+ # squared radii in Rosa's area average for a thin annulus.
+ ratio = (left.ro - left.ri) / left.ri
+ return left.ri * exp(log1p(ratio) * (1 + ratio)^2 / (ratio * (2 + ratio)) - one(ratio) / 2)
+ end
+ return invoke(geometric_mean_distance, Tuple{AbstractShape, AbstractShape}, left, right)
+end
+
+geometric_mean_distance(left::AbstractShape, right::Annulus) =
+ geometric_mean_distance(right, left)
+
+function geometric_mean_distance(left::Annulus, right::Annulus)
+ outer, inner = left.ro >= right.ro ? (left, right) : (right, left)
+ return invoke(geometric_mean_distance, Tuple{Annulus, AbstractShape}, outer, inner)
+end
+
+function nested_conductor_zone(
+ sources,
+ ::Type{T},
+ expected_inner::T
+) where {T <: Real}
+ isempty(sources) && throw(ArgumentError(
+ "a nested conductor requires at least one resolved primitive"
+ ))
+ all(sources) do source
+ source.source.primitive isa Union{Disk, Rectangle} &&
+ source.primitive isa Union{Disk, Rectangle, Polygon, BentStrip, Annulus}
+ end || throw(ArgumentError(
+ "nested conductor flattening requires Disk or Rectangle source members " *
+ "resolved as Disk, Rectangle, Polygon, BentStrip, or Annulus geometry"
+ ))
+
+ materials = Material{T}[convert(Material{T}, source.source.material)
+ for source in sources]
+ reference = first(materials).T0
+ all(material -> isapprox(material.T0, reference), materials) || throw(
+ ArgumentError("all nested conductor materials must share one reference temperature")
+ )
+
+ areas = T[convert(T, area(source.source.primitive)) for source in sources]
+ all(eachindex(sources)) do index
+ isapprox(
+ convert(T, area(sources[index].primitive)),
+ areas[index];
+ rtol = 5.0e-6,
+ atol = zero(T)
+ )
+ end || throw(ArgumentError(
+ "resolved compacted strands must preserve every declared source area"
+ ))
+ equivalent_radii = sqrt.(areas ./ (one(T) * π))
+ total_area = sum(areas)
+ center = convert.(T, conductor_zone_position(sources))
+ resistances = T[path_corrected_resistance(
+ member_resistance(
+ sources[index].source.primitive,
+ materials[index]
+ ),
+ sources[index].paths
+ ) for index in eachindex(sources)]
+
+ resistance = first(resistances)
+ alpha = first(materials).alpha
+ for index in Iterators.drop(eachindex(sources), 1)
+ alpha = equivalent_alpha(
+ alpha,
+ resistance,
+ materials[index].alpha,
+ resistances[index]
+ )
+ resistance = parallel(resistance, resistances[index])
+ end
+
+ conductances = inv.(resistances)
+ weights = conductances ./ sum(conductances)
+ log_gmr = zero(T)
+ for left in eachindex(sources)
+ primitive = sources[left].primitive
+ self_gmr = primitive isa Annulus ?
+ tubular_gmr(primitive.ro, primitive.ri, materials[left].mu_r) :
+ equivalent_radii[left] * exp(-materials[left].mu_r / 4)
+ log_gmr += weights[left]^2 * log(self_gmr)
+ for right in (left + 1):length(sources)
+ distance = geometric_mean_distance(sources[left].primitive, sources[right].primitive)
+ log_gmr += 2weights[left] * weights[right] * log(distance)
+ end
+ end
+
+ turn_values = T[turns_per_length(source.paths, T) for source in sources]
+ turns = sum(weights .* turn_values)
+ bounded = map(sources) do source
+ index = findfirst(
+ entry -> entry.pattern isa BoundedPlacement,
+ source.placement.patterns
+ )
+ index === nothing && return nothing
+ any(entry -> entry.owner === Group, source.placement.patterns[(index + 1):end]) &&
+ return nothing
+ return source.placement.patterns[index].pattern.boundary
+ end
+ outer = if all(!isnothing, bounded)
+ envelope = first(bounded)
+ all(==(envelope), bounded) || throw(ArgumentError(
+ "one nested conductor cannot combine different bounded formations"
+ ))
+ iszero(expected_inner) || throw(ArgumentError(
+ "a bounded stranded formation must be the innermost conductor"
+ ))
+ sqrt(convert(T, area(envelope)) / (one(T) * π))
+ else
+ maximum(
+ index -> radial_extent(sources[index].primitive, center),
+ eachindex(sources)
+ )
+ end
+ outer > expected_inner || throw(ArgumentError(
+ "nested conductor envelope must exceed its preceding radial boundary"
+ ))
+ return (
+ r_in = expected_inner,
+ r_ex = outer,
+ cross_section = total_area,
+ num_wires = length(sources),
+ turns_per_length = turns,
+ resistance,
+ alpha,
+ gmr = exp(log_gmr),
+ reference_temperature = reference,
+ position = center
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the transverse position \\[m\\] used for a radial shape or placed region.
+Disks and annuli use their stored center. Sectors use their centroid, and sector
+shells use the inner sector's centroid. A placed region uses its primitive.
+"""
+radial_position(shape::Union{Disk, Annulus}) = (shape.at.x, shape.at.y)
+radial_position(shape::SectorShape) = centroid(shape)
+function radial_position(
+ shape::ShellShape{
+ <:Any,
+ <:SectorShape,
+ <:SectorShape
+}
+)
+ centroid(shape.inner)
+end
+radial_position(region::PlacedRegion) = radial_position(region.primitive)
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the transverse center \\[m\\] of a nonempty collection of resolved
+conductor regions. Use a shared bounded-placement or group center when present.
+Otherwise, use the primitive centroid for one region or the area-weighted
+centroid for several regions.
+"""
+function conductor_zone_position(sources)
+ isempty(sources) && throw(ArgumentError(
+ "a conductor zone requires at least one resolved region"
+ ))
+ bounded = map(sources) do source
+ index = findfirst(
+ entry -> entry.pattern isa BoundedPlacement,
+ source.placement.patterns
+ )
+ index === nothing && return nothing
+ shape = source.placement.patterns[index].pattern.boundary
+ return shape isa Disk ? (shape.at.x, shape.at.y) : centroid(shape)
+ end
+ if all(!isnothing, bounded)
+ reference = first(bounded)
+ all(position -> same_radial_position(position, reference), bounded) &&
+ return reference
+ end
+ patterned = map(sources) do source
+ entries = filter(entry -> entry.owner === Group, source.placement.patterns)
+ length(entries) == 1 || return nothing
+ primitive = source.primitive
+ hasproperty(primitive, :at) || return nothing
+ intrinsic = source.source.primitive
+ hasproperty(intrinsic, :at) || return nothing
+ local_pose = only(entries).pose * intrinsic.at
+ absolute = primitive.at
+ parent_angle = absolute.φ - local_pose.φ
+ return (
+ absolute.x - cos(parent_angle) * local_pose.x +
+ sin(parent_angle) * local_pose.y,
+ absolute.y - sin(parent_angle) * local_pose.x -
+ cos(parent_angle) * local_pose.y
+ )
+ end
+ if all(!isnothing, patterned)
+ reference = first(patterned)
+ all(position -> same_radial_position(position, reference), patterned) &&
+ return reference
+ end
+ length(sources) == 1 && return centroid(only(sources).primitive)
+ areas = map(source -> area(source.primitive), sources)
+ centers = map(source -> centroid(source.primitive), sources)
+ total = sum(areas)
+ return (
+ sum(index -> areas[index] * centers[index][1], eachindex(sources)) / total,
+ sum(index -> areas[index] * centers[index][2], eachindex(sources)) / total
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compare two transverse coordinate pairs \\[m\\] using the existing radial
+position tolerance. After promotion to floating type `T`, the Euclidean distance
+must not exceed `sqrt(eps(T))` times the larger of one meter and the greatest
+absolute coordinate. Return whether the positions satisfy this tolerance.
+"""
+function same_radial_position(left, right)
+ coordinates = promote(
+ float(left[1]),
+ float(left[2]),
+ float(right[1]),
+ float(right[2])
+ )
+ T = typeof(first(coordinates))
+ scale = max(one(T), maximum(abs, coordinates))
+ tolerance = sqrt(eps(T)) * scale
+ return hypot(
+ coordinates[1] - coordinates[3],
+ coordinates[2] - coordinates[4]
+ ) <= tolerance
+end
+
+function same_radial_chain(left::PlacedRegion, right_position)
+ return same_radial_position(radial_position(left), right_position)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Initialize the scalar conductor reduction from its innermost zone.
+"""
+function initialize_conductor(zone)
+ return (
+ r_in = zone.r_in,
+ r_ex = zone.r_ex,
+ cross_section = zone.area,
+ num_wires = zone.wires,
+ turns_per_length = zone.turns,
+ resistance = zone.resistance,
+ alpha = zone.material.alpha,
+ gmr = zone.gmr,
+ reference_temperature = zone.material.T0,
+ position = zone.position,
+ coordinates = copy(zone.coordinates),
+ element_radii = fill(zone.element_radius, length(zone.coordinates)),
+ element_areas = fill(zone.element_area, length(zone.coordinates)),
+ previous_coordinates = zone.coordinates,
+ previous_radius = zone.element_radius,
+ previous_element_area = zone.element_area,
+ pairwise_gmd = zone.pairwise_gmd
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Combine the strand-count-weighted turns per unit length of two conductor
+zones \\[1/m\\]. A zone without explicit wires leaves the accumulated value
+unchanged.
+"""
+function equivalent_turns_per_length(
+ num_wires::Integer,
+ turns_per_length::Real,
+ added_wires::Integer,
+ added_turns::Real
+)
+ iszero(added_wires) && return turns_per_length
+ total_wires = num_wires + added_wires
+ return (num_wires * turns_per_length + added_wires * added_turns) / total_wires
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Add one concentric conductor zone to an accumulated scalar reduction. The
+resistances combine in parallel, while GMR and temperature coefficient are
+updated from the properties of the two zones.
+"""
+function add_conductor_zone(conductor, zone)
+ same_radial_position(zone.position, conductor.position) || throw(ArgumentError(
+ "conductor zones must share one radial center"
+ ))
+ isapprox(zone.r_in, conductor.r_ex) || throw(ArgumentError(
+ "conductor zones must be radially contiguous"
+ ))
+ isapprox(zone.material.T0, conductor.reference_temperature) || throw(
+ ArgumentError("all cable materials must share one reference temperature")
+ )
+ pairwise_gmd = conductor.pairwise_gmd || zone.pairwise_gmd
+ distance = if pairwise_gmd
+ geometric_mean_distance(
+ conductor.coordinates,
+ conductor.element_radii,
+ zone.coordinates,
+ fill(zone.element_radius, length(zone.coordinates)),
+ conductor.element_areas,
+ fill(zone.element_area, length(zone.coordinates))
+ )
+ else
+ geometric_mean_distance(
+ conductor.previous_coordinates,
+ conductor.previous_radius,
+ zone.coordinates,
+ zone.element_radius,
+ conductor.previous_element_area,
+ zone.element_area
+ )
+ end
+ return (
+ r_in = conductor.r_in,
+ r_ex = zone.r_ex,
+ cross_section = conductor.cross_section + zone.area,
+ num_wires = conductor.num_wires + zone.wires,
+ turns_per_length = equivalent_turns_per_length(
+ conductor.num_wires,
+ conductor.turns_per_length,
+ zone.wires,
+ zone.turns
+ ),
+ resistance = parallel(
+ conductor.resistance,
+ zone.resistance
+ ),
+ alpha = equivalent_alpha(
+ conductor.alpha,
+ conductor.resistance,
+ zone.material.alpha,
+ zone.resistance
+ ),
+ gmr = equivalent_gmr(
+ conductor.gmr,
+ conductor.cross_section,
+ zone.gmr,
+ zone.area,
+ distance
+ ),
+ reference_temperature = conductor.reference_temperature,
+ position = conductor.position,
+ coordinates = append!(copy(conductor.coordinates), zone.coordinates),
+ element_radii = append!(
+ copy(conductor.element_radii),
+ fill(zone.element_radius, length(zone.coordinates))
+ ),
+ element_areas = append!(
+ copy(conductor.element_areas),
+ fill(zone.element_area, length(zone.coordinates))
+ ),
+ previous_coordinates = zone.coordinates,
+ previous_radius = zone.element_radius,
+ previous_element_area = zone.element_area,
+ pairwise_gmd
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Convert a completed scalar conductor reduction into the homogeneous material
+that reproduces its DC resistance and GMR.
+"""
+function equivalent_conductor_material(conductor)
+ return Material(
+ :conductor,
+ equivalent_rho(
+ conductor.resistance,
+ conductor.r_ex,
+ conductor.r_in
+ ),
+ zero(conductor.r_in),
+ equivalent_mu(
+ conductor.gmr,
+ conductor.r_ex,
+ conductor.r_in
+ ),
+ conductor.reference_temperature,
+ conductor.alpha
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate the shunt capacitance \\[F/m\\] and conductance \\[S/m\\] of one radial
+dielectric layer at `angular_frequency` \\[rad/s\\]. Polarization loss tangent
+excludes the conduction already represented by material resistivity.
+"""
+function dielectric_circuit(layer, angular_frequency::Real)
+ capacitance = shunt_capacitance(layer.r_in, layer.r_ex, layer.material.eps_r)
+ return (
+ capacitance = capacitance,
+ conductance = shunt_conductance(
+ layer.r_in,
+ layer.r_ex,
+ layer.material.rho
+ ) + angular_frequency * capacitance * layer.material.tan_delta
+ )
+end
+
+function dielectric_circuit(layers, angular_frequency::Real, ::Type{T}) where {T <: Real}
+ isempty(layers) && return (
+ capacitance = zero(T),
+ conductance = zero(T)
+ )
+ combined = dielectric_circuit(first(layers), angular_frequency)
+ @inbounds for layer in Iterators.drop(layers, 1)
+ circuit = dielectric_circuit(layer, angular_frequency)
+ combined = series_shunt_admittance(
+ combined.conductance,
+ combined.capacitance,
+ circuit.conductance,
+ circuit.capacitance,
+ angular_frequency
+ )
+ end
+ return combined
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Initialize the frequency-independent radial dielectric description from its
+innermost layer.
+"""
+function initialize_dielectric(layer, conductor)
+ same_radial_position(layer.position, conductor.position) || throw(ArgumentError(
+ "dielectric layers must share the conductor radial center"
+ ))
+ isapprox(layer.r_in, conductor.r_ex) || throw(ArgumentError(
+ "the first dielectric layer must begin at the conductor boundary"
+ ))
+ isapprox(layer.material.T0, conductor.reference_temperature) || throw(
+ ArgumentError("all cable materials must share one reference temperature")
+ )
+ return (
+ r_in = layer.r_in,
+ r_ex = layer.r_ex,
+ cross_section = (one(layer.r_in) * pi) *
+ (layer.r_ex^2 - layer.r_in^2),
+ position = layer.position,
+ reference_temperature = layer.material.T0,
+ layers = dielectric_layer(layer.material, layer.r_in, layer.r_ex)
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Add one radial dielectric layer to a frequency-independent radial description.
+"""
+function add_dielectric_layer(dielectric, layer)
+ same_radial_position(layer.position, dielectric.position) || throw(ArgumentError(
+ "dielectric layers must share the conductor radial center"
+ ))
+ isapprox(layer.r_in, dielectric.r_ex) || throw(ArgumentError(
+ "dielectric layers must be radially contiguous"
+ ))
+ isapprox(layer.material.T0, dielectric.reference_temperature) || throw(
+ ArgumentError("all cable materials must share one reference temperature")
+ )
+ layers = copy(dielectric.layers)
+ append!(layers, dielectric_layer(layer.material, layer.r_in, layer.r_ex))
+ return (
+ r_in = dielectric.r_in,
+ r_ex = layer.r_ex,
+ cross_section = dielectric.cross_section +
+ (one(layer.r_in) * pi) * (layer.r_ex^2 - layer.r_in^2),
+ position = dielectric.position,
+ reference_temperature = dielectric.reference_temperature,
+ layers
+ )
+end
+
+function dielectric_layer(material::Material, inner::T, outer::T) where {T <: Real}
+ return [(r_in = inner, r_ex = outer, material = convert(Material{T}, material))]
+end
+
+function dielectric_layer(material::RadialDielectric, inner::T, outer::T) where {T <: Real}
+ # Reconstruct virtual radial intervals only for computation. The homogeneous
+ # design still contains one physical shell, not these constituent surfaces.
+ total = convert(T, sum(material.weights))
+ width = log(outer / inner)
+ radial_mu = sum(w * m.mu_r for (w, m) in zip(material.weights, material.materials)) / total
+ mu_scale = material.mu_r / radial_mu
+ accumulated = zero(T)
+ previous = inner
+ layers = NamedTuple{(:r_in, :r_ex, :material), Tuple{T, T, Material{T}}}[]
+ for index in eachindex(material.materials)
+ accumulated += convert(T, material.weights[index])
+ next = index == lastindex(material.materials) ? outer :
+ inner * exp(width * accumulated / total)
+ source = material.materials[index]
+ physical = Material(source.kind, source.rho, source.eps_r, source.mu_r * mu_scale,
+ source.T0, source.alpha; rho_thermal=source.rho_thermal,
+ theta_max=source.theta_max, tan_delta=source.tan_delta, sigma_solar=source.sigma_solar)
+ push!(layers, (r_in = previous, r_ex = next,
+ material = convert(Material{T}, physical)))
+ previous = next
+ end
+ return layers
+end
+
+function empty_dielectric(conductor, ::Type{T}) where {T <: Real}
+ Layer = NamedTuple{
+ (:r_in, :r_ex, :material),
+ Tuple{T, T, Material{T}}
+ }
+ return (
+ r_in = conductor.r_ex,
+ r_ex = conductor.r_ex,
+ cross_section = zero(T),
+ position = conductor.position,
+ reference_temperature = conductor.reference_temperature,
+ layers = Layer[]
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Convert a completed scalar dielectric reduction into a homogeneous material
+that matches its capacitance and conductance at `angular_frequency` \\[rad/s\\].
+Resistivity retains the series DC conduction. The remaining conductance is
+represented by polarization loss tangent, including interfacial polarization
+from heterogeneous layers. The equivalent is a reference-frequency export,
+not a broadband replacement for the original physical layers.
+
+Relative permeability retains the radial material average and the conductor's
+helical-solenoid correction.
+"""
+function equivalent_dielectric_material(dielectric, conductor, terminal::Symbol,
+ angular_frequency::Real)
+ isempty(dielectric.layers) && return Material(
+ :insulator,
+ oftype(dielectric.r_in, Inf),
+ zero(dielectric.r_in),
+ one(dielectric.r_in),
+ conductor.reference_temperature,
+ zero(dielectric.r_in)
+ )
+ relative_mu = equivalent_dielectric_permeability(
+ dielectric.layers,
+ conductor.turns_per_length,
+ conductor.r_ex,
+ dielectric.r_ex
+ )
+ isfinite(relative_mu) || throw(ArgumentError(
+ "dielectric reduction produced a non-finite permeability " *
+ "for terminal :$terminal (turns_per_length=$(conductor.turns_per_length), " *
+ "r_con=$(conductor.r_ex), r_ins=$(dielectric.r_ex))"
+ ))
+ dc_conductance = inv(sum(dielectric.layers) do layer
+ inv(shunt_conductance(layer.r_in, layer.r_ex, layer.material.rho))
+ end)
+ polarization_conductance = dielectric.shunt_conductance - dc_conductance
+ tolerance = 100 * eps(typeof(float(nominal(dc_conductance)))) *
+ max(abs(dielectric.shunt_conductance), abs(dc_conductance))
+ polarization_conductance >= -tolerance || throw(DomainError(
+ polarization_conductance,
+ "equivalent dielectric conductance must not be smaller than its DC limit"))
+ polarization_conductance = max(zero(polarization_conductance), polarization_conductance)
+ loss_tangent = iszero(polarization_conductance) ? zero(polarization_conductance) :
+ polarization_conductance / (angular_frequency * dielectric.shunt_capacitance)
+ return Material(
+ :insulator,
+ inv(equivalent_conductivity(
+ dc_conductance,
+ dielectric.r_in,
+ dielectric.r_ex
+ )),
+ equivalent_eps(
+ dielectric.shunt_capacitance,
+ dielectric.r_ex,
+ dielectric.r_in
+ ),
+ relative_mu,
+ conductor.reference_temperature,
+ zero(dielectric.r_in);
+ tan_delta = loss_tangent
+ )
+end
+
+function _radial_regions(regions)
+ radial = PlacedRegion[]
+ sizehint!(radial, length(regions))
+ for source in regions
+ source.primitive isa DifferenceShape || begin
+ push!(radial, source)
+ continue
+ end
+ material = source.source.material
+ source.terminal === nothing || throw(ArgumentError(
+ "a non-radial enclosure fill cannot own a terminal"
+ ))
+ material.kind === :insulator || throw(ArgumentError(
+ "a non-radial enclosure fill must use an insulator material"
+ ))
+ isapprox(material.mu_r, one(material.mu_r)) || throw(ArgumentError(
+ "the coaxial reduction requires a nonmagnetic non-radial enclosure fill"
+ ))
+ end
+ return radial
+end
+
+function same_path_definitions(left, right)
+ length(left) == length(right) || return false
+ return all(eachindex(left)) do index
+ left[index].path == right[index].path
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Reduce each radial terminal section of a completed cable design to equivalent
+conductor geometry and its ordered physical dielectric layers.
+
+The reduction combines the resistance of conductors and GMR and retains each physical
+dielectric layer for subsequent material evaluation.
+
+# Arguments
+
+- `design`: completed physical cable design.
+# Returns
+
+An ordered vector of named tuples containing equivalent conductor fields and
+the uncombined physical dielectric layers for each retained terminal.
+"""
+function radial_components(design::CableDesign, ::Type{T}) where {T <: Real}
+ regions = _radial_regions(design.geometry.regions)
+ terminals = design.terminal_order
+ isempty(terminals) && throw(ArgumentError(
+ "flatten requires at least one retained terminal"
+ ))
+ terminal_starts = Int[]
+ for terminal in terminals
+ index = findfirst(region -> region.terminal === terminal, regions)
+ index === nothing && throw(ArgumentError(
+ "terminal :$terminal has no resolved conductive region"
+ ))
+ push!(terminal_starts, index)
+ end
+ issorted(terminal_starts) && allunique(terminal_starts) || throw(ArgumentError(
+ "flatten requires nonreappearing radial terminal blocks"
+ ))
+
+ Layer = NamedTuple{
+ (:r_in, :r_ex, :material),
+ Tuple{T, T, Material{T}}
+ }
+ ConductorInput = NamedTuple{
+ (:r_in, :r_ex, :cross_section, :num_wires, :turns_per_length,
+ :resistance, :alpha, :gmr, :position, :material),
+ Tuple{T, T, T, Int, T, T, T, T, Tuple{T, T}, Material{T}}
+ }
+ DielectricInput = NamedTuple{
+ (:r_in, :r_ex, :cross_section, :layers),
+ Tuple{T, T, T, Vector{Layer}}
+ }
+ ComponentInput = NamedTuple{
+ (:name, :conductor, :dielectric),
+ Tuple{Symbol, ConductorInput, DielectricInput}
+ }
+ effective = ComponentInput[]
+
+ for (terminal_index, terminal) in enumerate(terminals)
+ first_index = terminal_starts[terminal_index]
+ last_index = terminal_index == length(terminals) ? length(regions) :
+ terminal_starts[terminal_index + 1] - 1
+ block = @view regions[first_index:last_index]
+
+ zone_ranges = UnitRange{Int}[]
+ cursor = firstindex(block)
+ while cursor <= lastindex(block) && block[cursor].terminal === terminal
+ start = cursor
+ source = block[cursor]
+ signature = map(entry -> entry.pattern, source.placement.patterns)
+ while cursor < lastindex(block)
+ next = block[cursor + 1]
+ next_signature = map(entry -> entry.pattern, next.placement.patterns)
+ same_zone = next.terminal === terminal &&
+ next.source == source.source &&
+ same_path_definitions(next.paths, source.paths) &&
+ next_signature == signature
+ same_zone || break
+ cursor += 1
+ end
+ push!(zone_ranges, start:cursor)
+ cursor += 1
+ end
+ isempty(zone_ranges) && throw(ArgumentError(
+ "terminal :$terminal has no conductive zone"
+ ))
+ any(index -> block[index].terminal !== nothing, cursor:lastindex(block)) &&
+ throw(ArgumentError(
+ "terminal :$terminal reappears after its dielectric interval"
+ ))
+
+ conductor_sources = @view block[firstindex(block):(cursor - 1)]
+ nested = any(conductor_sources) do source
+ source.primitive isa Union{Polygon, BentStrip} ||
+ source.source.primitive isa Rectangle ||
+ any(
+ entry -> entry.pattern isa BoundedPlacement,
+ source.placement.patterns
+ ) ||
+ count(entry -> entry.owner === Group, source.placement.patterns) > 1 ||
+ length(source.paths) > 1
+ end
+ conductor = if nested
+ zone_position = conductor_zone_position(conductor_sources)
+ expected_inner = if first_index > firstindex(regions) &&
+ same_radial_chain(regions[first_index - 1], zone_position)
+ convert(T, r_ex(regions[first_index - 1].primitive))
+ else
+ zero(T)
+ end
+ nested_conductor_zone(
+ conductor_sources,
+ T,
+ expected_inner
+ )
+ else
+ accumulated = nothing
+ for (zone_index, indices) in enumerate(zone_ranges)
+ zone = @view block[indices]
+ source = first(zone)
+ source.source.material.kind === :conductor || throw(ArgumentError(
+ "terminal :$terminal contains a nonconductor physical region"
+ ))
+ all(item -> item.source.material == source.source.material, zone) ||
+ throw(ArgumentError("one conductor zone must use one material"))
+ all(item -> item.source.primitive == source.source.primitive, zone) ||
+ throw(ArgumentError("one conductor zone must use one primitive"))
+ zone_position = conductor_zone_position(zone)
+ expected_inner = if zone_index > 1
+ accumulated.r_ex
+ elseif first_index > firstindex(regions) &&
+ same_radial_chain(regions[first_index - 1], zone_position)
+ convert(T, r_ex(regions[first_index - 1].primitive))
+ else
+ zero(T)
+ end
+ values = conductor_zone(
+ source.source.primitive,
+ source.primitive,
+ zone,
+ T,
+ expected_inner
+ )
+ accumulated = zone_index == 1 ?
+ initialize_conductor(values) :
+ add_conductor_zone(accumulated, values)
+ end
+ accumulated
+ end
+
+ dielectric_sources = cursor > lastindex(block) ?
+ @view(block[1:0]) : @view(block[cursor:lastindex(block)])
+ if first_index > firstindex(regions) &&
+ same_radial_chain(regions[first_index - 1], conductor.position)
+ preceding = regions[first_index - 1]
+ preceding.terminal === nothing || throw(ArgumentError(
+ "a radial terminal must be preceded by a dielectric region"
+ ))
+ preceding_layer = dielectric_layer(
+ preceding.primitive,
+ preceding,
+ T
+ )
+ conductor = merge(conductor, (r_in = preceding_layer.r_ex,))
+ end
+ if !isempty(dielectric_sources)
+ first_dielectric = first(dielectric_sources)
+ first_layer = dielectric_layer(
+ first_dielectric.primitive,
+ first_dielectric,
+ T
+ )
+ conductor = merge(conductor, (r_ex = first_layer.r_in,))
+ end
+
+ dielectric = empty_dielectric(conductor, T)
+ for (layer_index, source) in enumerate(dielectric_sources)
+ source.terminal === nothing || throw(ArgumentError(
+ "a dielectric region cannot own a retained terminal"
+ ))
+ layer = dielectric_layer(
+ source.primitive,
+ source,
+ T
+ )
+ dielectric = layer_index == 1 ?
+ initialize_dielectric(layer, conductor) :
+ add_dielectric_layer(dielectric, layer)
+ end
+
+ conductor_material = equivalent_conductor_material(conductor)
+ push!(effective,
+ (
+ name = terminal,
+ conductor = (
+ r_in = conductor.r_in,
+ r_ex = conductor.r_ex,
+ cross_section = conductor.cross_section,
+ num_wires = conductor.num_wires,
+ turns_per_length = conductor.turns_per_length,
+ resistance = conductor.resistance,
+ alpha = conductor.alpha,
+ gmr = conductor.gmr,
+ position = conductor.position,
+ material = conductor_material
+ ),
+ dielectric = (
+ r_in = dielectric.r_in,
+ r_ex = dielectric.r_ex,
+ cross_section = dielectric.cross_section,
+ layers = dielectric.layers
+ )
+ ))
+ end
+ return effective
+end
+
+function radial_components(design::CableDesign)
+ T = promote_type(
+ (eltype(region.primitive) for region in design.geometry.regions)...,
+ (eltype(region.source.material) for region in design.geometry.regions)...
+ )
+ return radial_components(design, T)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Reduce a completed cable design to equivalent radial conductor and dielectric
+components at one dielectric reference frequency.
+
+This reference-frequency operation serves the scalar ATP and TRALIN export
+adapters. It includes the supplied physical material losses at their reference
+temperature and combines radial layers in series. The resulting scalar
+material reproduces capacitance and conductance at the requested frequency.
+Its validity is restricted to that frequency. Mutual coupling and earth
+return are outside this reduction. Reusable [`homogenize`](@ref) designs retain their
+constituents without calling this frequency-dependent operation.
+
+# Arguments
+
+- `design`: completed physical cable design.
+- `dielectric_frequency`: reference frequency used to match the equivalent
+ dielectric [Hz].
+
+# Returns
+
+- An ordered vector of equivalent radial component records.
+"""
+function flatten(
+ design::CableDesign,
+ dielectric_frequency::Real
+)
+ T = promote_type(
+ typeof(float(dielectric_frequency)),
+ (eltype(region.primitive) for region in design.geometry.regions)...,
+ (eltype(region.source.material) for region in design.geometry.regions)...
+ )
+ return flatten(
+ design,
+ convert(T, float(dielectric_frequency)),
+ T
+ )
+end
+
+function flatten(
+ design::CableDesign,
+ frequency::T,
+ ::Type{T}
+) where {T <: Real}
+ frequency > zero(frequency) || throw(DomainError(
+ frequency,
+ "dielectric reference frequency must be positive"
+ ))
+ components = radial_components(design, T)
+ angular_frequency = 2 * (one(T) * pi) * frequency
+ Layer = NamedTuple{
+ (:r_in, :r_ex, :material),
+ Tuple{T, T, Material{T}}
+ }
+ ConductorInput = typeof(first(components).conductor)
+ DielectricInput = NamedTuple{
+ (:r_in, :r_ex, :cross_section, :shunt_capacitance,
+ :shunt_conductance, :frequency, :layers, :material),
+ Tuple{T, T, T, T, T, T, Vector{Layer}, Material{T}}
+ }
+ ComponentInput = NamedTuple{
+ (:name, :conductor, :dielectric),
+ Tuple{Symbol, ConductorInput, DielectricInput}
+ }
+ effective = ComponentInput[]
+ sizehint!(effective, length(components))
+ for component in components
+ circuit = dielectric_circuit(
+ component.dielectric.layers,
+ angular_frequency,
+ T
+ )
+ dielectric = merge(component.dielectric,
+ (
+ shunt_capacitance = circuit.capacitance,
+ shunt_conductance = circuit.conductance
+ ))
+ material = equivalent_dielectric_material(
+ dielectric,
+ merge(component.conductor, (
+ reference_temperature = component.conductor.material.T0,
+ )),
+ component.name,
+ angular_frequency
+ )
+ push!(effective,
+ (
+ name = component.name,
+ conductor = component.conductor,
+ dielectric = (
+ r_in = dielectric.r_in,
+ r_ex = dielectric.r_ex,
+ cross_section = dielectric.cross_section,
+ shunt_capacitance = circuit.capacitance,
+ shunt_conductance = circuit.conductance,
+ frequency,
+ layers = dielectric.layers,
+ material
+ )
+ ))
+ end
+ return effective
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build a homogeneous cable design from locally reduced radial components.
+
+Each retained terminal becomes one solid or annular conductor followed by one
+homogeneous dielectric interval. The conductor material reproduces the
+terminal's parallel resistance and geometric-mean radius. The dielectric
+material retains the physical constituents and radial weights in a
+[`RadialDielectric`](@ref). Constitutive laws are selected only during computation
+or explicit reference-frequency export. The source design is not modified.
+
+# Arguments
+
+- `original`: completed physical cable design.
+
+# Keywords
+
+- `new_id`: identifier for the returned design. An empty value appends
+ `"_equivalent"` to the source identifier.
+
+# Returns
+
+- A completed homogeneous [`CableDesign`](@ref).
+"""
+function homogenize(
+ original::CableDesign;
+ new_id::AbstractString = ""
+)
+ target_id = isempty(new_id) ? original.cable_id * "_equivalent" : String(new_id)
+ components = radial_components(original)
+ chains = Vector{typeof(components)}()
+ for component in components
+ if isempty(chains) ||
+ !same_radial_position(
+ first(last(chains)).conductor.position,
+ component.conductor.position
+ )
+ any(
+ chain -> same_radial_position(
+ first(chain).conductor.position,
+ component.conductor.position
+ ),
+ chains) && throw(ArgumentError(
+ "a radial assembly member cannot reappear after another member"
+ ))
+ push!(chains, eltype(components)[])
+ end
+ push!(last(chains), component)
+ end
+
+ flattened_members = AbstractCablePart[]
+ member_positions = Tuple[]
+ for chain in chains
+ parts = AbstractCablePart[]
+ for component in chain
+ terminal = component.name
+ conductor = component.conductor
+ conductor_primitive = iszero(conductor.r_in) ?
+ Disk(conductor.r_ex) :
+ Annulus(conductor.r_in, conductor.r_ex)
+ conductor_region = Region(
+ Symbol(terminal, :_equivalent_conductor),
+ conductor_primitive,
+ conductor.material
+ )
+ push!(parts,
+ Group(
+ terminal,
+ Pose2(0, 0, 0),
+ conductor_region,
+ nothing,
+ nothing,
+ nothing
+ ))
+
+ dielectric = component.dielectric
+ if dielectric.r_ex > dielectric.r_in
+ push!(parts,
+ Region(
+ Symbol(terminal, :_equivalent_dielectric),
+ Annulus(dielectric.r_in, dielectric.r_ex),
+ RadialDielectric(
+ [layer.material for layer in dielectric.layers],
+ [log(layer.r_ex / layer.r_in) for layer in dielectric.layers];
+ mu_r = equivalent_dielectric_permeability(dielectric.layers,
+ conductor.turns_per_length, conductor.r_ex, dielectric.r_ex))
+ ))
+ end
+ end
+ push!(flattened_members, Stack(parts))
+ push!(member_positions, first(chain).conductor.position)
+ end
+
+ origin = (zero(first(member_positions)[1]), zero(first(member_positions)[2]))
+ singleton = length(flattened_members) == 1 &&
+ same_radial_position(only(member_positions), origin)
+ root = if singleton
+ only(flattened_members)
+ else
+ members = Tuple(
+ AssemblyMember(
+ member,
+ Pose2(position[1], position[2], 0)
+ )
+ for (member, position) in zip(flattened_members, member_positions)
+ )
+ Assembly(
+ Pose2(0, 0, 0),
+ members,
+ nothing,
+ nothing,
+ nothing,
+ nothing
+ )
+ end
+ return build(CableDesign, target_id, root; nominal_data = original.nominal_data)
+end
diff --git a/src/datamodel/geometry.jl b/src/datamodel/geometry.jl
new file mode 100644
index 000000000..fd2754b06
--- /dev/null
+++ b/src/datamodel/geometry.jl
@@ -0,0 +1,35 @@
+"""
+Return three cable-center coordinates in a trefoil formation.
+"""
+function trefoil_formation(x0::Real, y0::Real, radius::Real)
+ radius > zero(radius) || throw(DomainError(radius, "radius must be positive"))
+ x, y, r = promote(float(x0), float(y0), float(radius))
+ distance = r / cosd(30)
+ return (
+ x,
+ y + distance,
+ x + distance * cosd(210),
+ y + distance * sind(210),
+ x + distance * cosd(330),
+ y + distance * sind(330)
+ )
+end
+
+"""
+Return three cable-center coordinates in a horizontal or vertical flat formation.
+"""
+function flat_formation(x0::Real, y0::Real, spacing::Real; vertical::Bool = false)
+ spacing > zero(spacing) ||
+ throw(DomainError(spacing, "spacing must be positive"))
+ x, y, distance = promote(float(x0), float(y0), float(spacing))
+ return vertical ?
+ (x, y, x, y - distance, x, y - 2 * distance) :
+ (x, y, x + distance, y, x + 2 * distance, y)
+end
+
+"""
+Return the outer radius of a materialized cable design.
+"""
+function outer_radius(design::CableDesign)
+ return support(boundary(design.geometry))
+end
diff --git a/src/datamodel/geometry/ellipse.jl b/src/datamodel/geometry/ellipse.jl
new file mode 100644
index 000000000..0f3c5c710
--- /dev/null
+++ b/src/datamodel/geometry/ellipse.jl
@@ -0,0 +1,115 @@
+"""
+$(TYPEDEF)
+
+Represent the exact outward parallel boundary of an ellipse.
+
+For ellipse support ``h(\\phi)`` and normal offset ``t``, the parallel boundary
+has support
+
+```math
+h_t(\\phi) = h(\\phi) + t.
+```
+
+`EllipseOffset` is resolved geometry produced by applying [`Shell`](@ref).
+
+$(TYPEDFIELDS)
+"""
+struct EllipseOffset{T <: Real, P <: Pose2{T}} <: AbstractShape{T}
+ "Original ellipse semi-axis along the local x-axis \\[m\\]."
+ a::T
+ "Original ellipse semi-axis along the local y-axis \\[m\\]."
+ b::T
+ "Outward normal offset \\[m\\]."
+ t::T
+ "Pose in the completed cable coordinate system."
+ at::P
+
+ function EllipseOffset{T, P}(a::T, b::T, t::T, at::P) where {
+ T <: Real, P <: Pose2{T}
+ }
+ isfinite(a) && a > zero(a) || throw(DomainError(
+ a, "ellipse-offset semi-axis a must be positive and finite"
+ ))
+ isfinite(b) && b > zero(b) || throw(DomainError(
+ b, "ellipse-offset semi-axis b must be positive and finite"
+ ))
+ isfinite(t) && t > zero(t) || throw(DomainError(
+ t, "ellipse offset must be positive and finite"
+ ))
+ return new{T, P}(a, b, t, at)
+ end
+end
+
+function EllipseOffset(ellipse::Ellipse, t::Real)
+ T = promote_type(eltype(ellipse), typeof(t))
+ return EllipseOffset{T, Pose2{T}}(
+ convert(T, ellipse.a),
+ convert(T, ellipse.b),
+ convert(T, t),
+ convert(Pose2{T}, ellipse.at)
+ )
+end
+
+boundary(shape::EllipseOffset) = shape
+area(shape::EllipseOffset) =
+ π * shape.a * shape.b + _ellipse_perimeter(shape.a, shape.b) * shape.t +
+ π * shape.t^2
+perimeter(shape::EllipseOffset) =
+ _ellipse_perimeter(shape.a, shape.b) + 2π * shape.t
+centroid(shape::EllipseOffset) = (shape.at.x, shape.at.y)
+
+function support(shape::EllipseOffset, angle::Real)
+ local_angle = angle - shape.at.φ
+ return shape.at.x * cos(angle) + shape.at.y * sin(angle) +
+ hypot(shape.a * cos(local_angle), shape.b * sin(local_angle)) + shape.t
+end
+
+support(shape::EllipseOffset) =
+ hypot(shape.at.x, shape.at.y) + max(shape.a, shape.b) + shape.t
+
+function resolve(at::Pose2, shape::EllipseOffset)
+ return EllipseOffset(
+ Ellipse(shape.a, shape.b, at * shape.at),
+ shape.t
+ )
+end
+
+function resolve(inner::Ellipse, layer::Shell)
+ return ShellShape(inner, EllipseOffset(inner, layer.t))
+end
+
+function resolve(inner::EllipseOffset, layer::Shell)
+ outer = EllipseOffset(
+ Ellipse(inner.a, inner.b, inner.at),
+ inner.t + layer.t
+ )
+ return ShellShape(inner, outer)
+end
+
+function tessellate(shape::Ellipse; points_per_arc::Integer = 128)
+ points_per_arc >= 8 || throw(ArgumentError(
+ "an ellipse requires at least eight boundary points"
+ ))
+ angles = range(zero(shape.a), oftype(shape.a, 2π); length = Int(points_per_arc))
+ local_points = [
+ (shape.a * cos(angle), shape.b * sin(angle)) for angle in angles
+ ]
+ return [shape.at(point) for point in local_points]
+end
+
+function tessellate(shape::EllipseOffset; points_per_arc::Integer = 128)
+ points_per_arc >= 8 || throw(ArgumentError(
+ "an ellipse offset requires at least eight boundary points"
+ ))
+ angles = range(zero(shape.a), oftype(shape.a, 2π); length = Int(points_per_arc))
+ local_points = map(angles) do angle
+ cosine = cos(angle)
+ sine = sin(angle)
+ normalizer = hypot(shape.b * cosine, shape.a * sine)
+ (
+ shape.a * cosine + shape.t * shape.b * cosine / normalizer,
+ shape.b * sine + shape.t * shape.a * sine / normalizer
+ )
+ end
+ return [shape.at(point) for point in local_points]
+end
diff --git a/src/datamodel/geometry/pose.jl b/src/datamodel/geometry/pose.jl
new file mode 100644
index 000000000..af799b42d
--- /dev/null
+++ b/src/datamodel/geometry/pose.jl
@@ -0,0 +1,71 @@
+"""
+$(TYPEDEF)
+
+Represent planar translation and orientation relative to a parent frame.
+
+$(TYPEDFIELDS)
+"""
+struct Pose2{T <: Real}
+ "Horizontal translation \\[m\\]."
+ x::T
+ "Vertical translation \\[m\\]."
+ y::T
+ "Counter-clockwise orientation \\[rad\\]."
+ φ::T
+
+ function Pose2{T}(x::T, y::T, φ::T) where {T <: Real}
+ all(isfinite, (x, y, φ)) ||
+ throw(ArgumentError("pose coordinates and orientation must be finite"))
+ return new{T}(x, y, φ)
+ end
+end
+
+function _pose2(x, y, φ)
+ values = map(float, promote(x, y, φ))
+ return Pose2{typeof(first(values))}(values...)
+end
+
+function Pose2(x, y, φ; combine::Symbol = :product)
+ return parameterize(Pose2, _pose2, (x, y, φ); combine)
+end
+
+Pose2(x, y; combine::Symbol = :product) = Pose2(x, y, 0; combine)
+
+Base.eltype(::Pose2{T}) where {T} = T
+Base.eltype(::Type{<:Pose2{T}}) where {T} = T
+
+function Base.convert(::Type{<:Pose2{T}}, pose::Pose2) where {T <: Real}
+ return Pose2{T}(convert(T, pose.x), convert(T, pose.y), convert(T, pose.φ))
+end
+
+"""
+Compose `child` relative to `parent`.
+"""
+function Base.:*(parent::Pose2, child::Pose2)
+ values = map(float, promote(
+ parent.x, parent.y, parent.φ, child.x, child.y, child.φ
+ ))
+ px, py, pφ, cx, cy, cφ = values
+ return Pose2(
+ px + cos(pφ) * cx - sin(pφ) * cy,
+ py + sin(pφ) * cx + cos(pφ) * cy,
+ pφ + cφ
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Rotate `point` by the orientation of `pose`, then translate it by its position.
+"""
+function (pose::Pose2)(point)
+ cosine = cos(pose.φ)
+ sine = sin(pose.φ)
+ return (
+ pose.x + cosine * point[1] - sine * point[2],
+ pose.y + sine * point[1] + cosine * point[2]
+ )
+end
+
+Commons.input_fields(::Type{<:Pose2}) = (x=(name="horizontal position",unit="m"),
+ y=(name="vertical position",unit="m"),φ=(name="rotation",unit="rad"))
diff --git a/src/datamodel/geometry/primitives.jl b/src/datamodel/geometry/primitives.jl
new file mode 100644
index 000000000..73c55c3a6
--- /dev/null
+++ b/src/datamodel/geometry/primitives.jl
@@ -0,0 +1,474 @@
+"""
+$(TYPEDEF)
+
+Supertype for exact cross-sectional geometry.
+
+Intrinsic primitives and derived domains such as conformal shells share this
+interface. A shape is independent of any plotting tessellation.
+"""
+abstract type AbstractShape{T <: Real} end
+
+Base.eltype(::AbstractShape{T}) where {T} = T
+Base.eltype(::Type{<:AbstractShape{T}}) where {T} = T
+
+"""
+$(TYPEDEF)
+
+Supertype for intrinsic cross-sectional primitives.
+
+A primitive states material-neutral dimensions. During construction, simple
+primitives also include the `Pose2` produced by coordinate composition. Shapes
+that require additional exact contact geometry use a separate resolved
+`AbstractShape` implementation.
+"""
+abstract type AbstractPrimitive{T <: Real} <: AbstractShape{T} end
+
+Base.eltype(::AbstractPrimitive{T}) where {T} = T
+Base.eltype(::Type{<:AbstractPrimitive{T}}) where {T} = T
+
+"""
+Explicit initial state for resolving the first member of an outward stack.
+"""
+struct EmptyBoundary end
+
+"""
+$(TYPEDEF)
+
+Represent a circular cross-section and its composed pose.
+
+$(TYPEDFIELDS)
+"""
+struct Disk{T <: Real, P <: Pose2{T}} <: AbstractPrimitive{T}
+ "Radius \\[m\\]."
+ r::T
+ "Pose in the completed cable coordinate system."
+ at::P
+
+ function Disk{T, P}(r::T, at::P) where {T <: Real, P <: Pose2{T}}
+ isfinite(r) && r > zero(r) ||
+ throw(DomainError(r, "disk radius must be positive and finite"))
+ return new{T, P}(r, at)
+ end
+end
+
+"""
+$(TYPEDEF)
+
+Represent a rectangular cross-section and its composed pose.
+
+$(TYPEDFIELDS)
+"""
+struct Rectangle{T <: Real, P <: Pose2{T}} <: AbstractPrimitive{T}
+ "Width along the local x-axis \\[m\\]."
+ w::T
+ "Height along the local y-axis \\[m\\]."
+ h::T
+ "Pose in the completed cable coordinate system."
+ at::P
+
+ function Rectangle{T, P}(w::T, h::T, at::P) where {T <: Real, P <: Pose2{T}}
+ isfinite(w) && w > zero(w) ||
+ throw(DomainError(w, "rectangle width must be positive and finite"))
+ isfinite(h) && h > zero(h) ||
+ throw(DomainError(h, "rectangle height must be positive and finite"))
+ return new{T, P}(w, h, at)
+ end
+end
+
+"""
+$(TYPEDEF)
+
+Represent an elliptical cross-section and its composed pose.
+
+$(TYPEDFIELDS)
+"""
+struct Ellipse{T <: Real, P <: Pose2{T}} <: AbstractPrimitive{T}
+ "Semi-axis along the local x-axis \\[m\\]."
+ a::T
+ "Semi-axis along the local y-axis \\[m\\]."
+ b::T
+ "Pose in the completed cable coordinate system."
+ at::P
+
+ function Ellipse{T, P}(a::T, b::T, at::P) where {T <: Real, P <: Pose2{T}}
+ isfinite(a) && a > zero(a) ||
+ throw(DomainError(a, "ellipse semi-axis a must be positive and finite"))
+ isfinite(b) && b > zero(b) ||
+ throw(DomainError(b, "ellipse semi-axis b must be positive and finite"))
+ return new{T, P}(a, b, at)
+ end
+end
+
+"""
+$(TYPEDEF)
+
+Represent an annular cross-section and its composed pose.
+
+$(TYPEDFIELDS)
+"""
+struct Annulus{T <: Real, P <: Pose2{T}} <: AbstractPrimitive{T}
+ "Inner radius \\[m\\]."
+ ri::T
+ "Outer radius \\[m\\]."
+ ro::T
+ "Pose in the completed cable coordinate system."
+ at::P
+
+ function Annulus{T, P}(ri::T, ro::T, at::P) where {T <: Real, P <: Pose2{T}}
+ isfinite(ri) && ri >= zero(ri) || throw(DomainError(
+ ri, "annulus inner radius must be nonnegative and finite"
+ ))
+ isfinite(ro) && ro > ri || throw(DomainError(
+ ro, "annulus outer radius must exceed its inner radius"
+ ))
+ return new{T, P}(ri, ro, at)
+ end
+end
+
+"""
+$(TYPEDEF)
+
+Represent a polygonal cross-section and its composed pose.
+
+$(TYPEDFIELDS)
+"""
+struct Polygon{T <: Real, P <: Pose2{T}} <: AbstractPrimitive{T}
+ "Ordered local `(x, y)` vertices \\[m\\]."
+ points::Vector{Tuple{T, T}}
+ "Pose in the completed cable coordinate system."
+ at::P
+
+ function Polygon{T, P}(
+ points::Vector{Tuple{T, T}}, at::P
+ ) where {T <: Real, P <: Pose2{T}}
+ length(points) >= 3 ||
+ throw(ArgumentError("a polygon requires at least three vertices"))
+ all(point -> length(point) == 2 && all(isfinite, point), points) ||
+ throw(ArgumentError("polygon vertices must be finite coordinate pairs"))
+ twice_area = sum(eachindex(points)) do index
+ next = mod1(index + 1, length(points))
+ points[index][1] * points[next][2] -
+ points[next][1] * points[index][2]
+ end
+ iszero(twice_area) && throw(DomainError(
+ twice_area, "polygon area must be nonzero"
+ ))
+ return new{T, P}(copy(points), at)
+ end
+end
+
+Base.:(==)(left::Polygon, right::Polygon) =
+ left.points == right.points && left.at == right.at
+Base.isequal(left::Polygon, right::Polygon) =
+ isequal(left.points, right.points) && isequal(left.at, right.at)
+Base.hash(value::Polygon, seed::UInt) = hash(value.at, hash(value.points, seed))
+
+"""
+Store one exact outer shape with exact material-free holes removed from it.
+"""
+struct DifferenceShape{
+ T <: Real,
+ O <: AbstractShape,
+ H <: Tuple,
+ P <: Pose2{T}
+} <: AbstractShape{T}
+ outer::O
+ holes::H
+ at::P
+
+ function DifferenceShape(
+ outer::O,
+ holes::H
+ ) where {O <: AbstractShape, H <: Tuple}
+ all(hole -> hole isa AbstractShape, holes) || throw(ArgumentError(
+ "difference holes must be exact resolved shapes"
+ ))
+ T = promote_type(eltype(outer), map(eltype, holes)...)
+ at = convert(Pose2{T}, outer.at)
+ return new{T, O, H, typeof(at)}(outer, holes, at)
+ end
+end
+
+Base.:(==)(left::DifferenceShape, right::DifferenceShape) =
+ left.outer == right.outer && left.holes == right.holes && left.at == right.at
+Base.isequal(left::DifferenceShape, right::DifferenceShape) =
+ isequal(left.outer, right.outer) && isequal(left.holes, right.holes) &&
+ isequal(left.at, right.at)
+Base.hash(value::DifferenceShape, seed::UInt) =
+ hash(value.at, hash(value.holes, hash(value.outer, seed)))
+
+function Disk(r::Real, at::Pose2)
+ T = promote_type(typeof(r), eltype(at))
+ return Disk{T, Pose2{T}}(convert(T, r), convert(Pose2{T}, at))
+end
+function Rectangle(w::Real, h::Real, at::Pose2)
+ T = promote_type(typeof(w), typeof(h), eltype(at))
+ return Rectangle{T, Pose2{T}}(
+ convert(T, w), convert(T, h), convert(Pose2{T}, at))
+end
+function Ellipse(a::Real, b::Real, at::Pose2)
+ T = promote_type(typeof(a), typeof(b), eltype(at))
+ return Ellipse{T, Pose2{T}}(
+ convert(T, a), convert(T, b), convert(Pose2{T}, at))
+end
+function Annulus(ri::Real, ro::Real, at::Pose2)
+ T = promote_type(typeof(ri), typeof(ro), eltype(at))
+ return Annulus{T, Pose2{T}}(
+ convert(T, ri), convert(T, ro), convert(Pose2{T}, at))
+end
+function _polygon(points, at::Pose2)
+ Base.@nospecialize points
+ isempty(points) && throw(ArgumentError("a polygon requires at least three vertices"))
+ T = foldl(points; init = eltype(at)) do current, point
+ promote_type(current, typeof(point[1]), typeof(point[2]))
+ end
+ vertices = Tuple{T, T}[
+ (convert(T, point[1]), convert(T, point[2])) for point in points
+ ]
+ return Polygon{T, Pose2{T}}(
+ vertices, convert(Pose2{T}, at))
+end
+
+_origin(::Type{T}) where {T <: Real} = Pose2(zero(T), zero(T), zero(T))
+
+Disk(r::T) where {T <: Real} = Disk(r, _origin(T))
+function Rectangle(w::Real, h::Real)
+ T = promote_type(typeof(w), typeof(h))
+ return Rectangle(convert(T, w), convert(T, h), _origin(T))
+end
+function Ellipse(a::Real, b::Real)
+ T = promote_type(typeof(a), typeof(b))
+ return Ellipse(convert(T, a), convert(T, b), _origin(T))
+end
+function Annulus(ri::Real, ro::Real)
+ T = promote_type(typeof(ri), typeof(ro))
+ return Annulus(convert(T, ri), convert(T, ro), _origin(T))
+end
+function Polygon(points::Union{Tuple, AbstractVector})
+ Base.@nospecialize points
+ return _polygon(points, _origin(typeof(float(first(points)[1]))))
+end
+
+function Base.convert(
+ ::Type{<:AbstractPrimitive{T}}, value::Disk
+) where {T <: Real}
+ return Disk(convert(T, value.r), convert(Pose2{T}, value.at))
+end
+function Base.convert(
+ ::Type{<:AbstractPrimitive{T}}, value::Rectangle
+) where {T <: Real}
+ return Rectangle(
+ convert(T, value.w), convert(T, value.h), convert(Pose2{T}, value.at)
+ )
+end
+function Base.convert(
+ ::Type{<:AbstractPrimitive{T}}, value::Ellipse
+) where {T <: Real}
+ return Ellipse(
+ convert(T, value.a), convert(T, value.b), convert(Pose2{T}, value.at)
+ )
+end
+function Base.convert(
+ ::Type{<:AbstractPrimitive{T}}, value::Annulus
+) where {T <: Real}
+ return Annulus(
+ convert(T, value.ri), convert(T, value.ro), convert(Pose2{T}, value.at)
+ )
+end
+function Base.convert(
+ ::Type{<:AbstractPrimitive{T}}, value::Polygon
+) where {T <: Real}
+ points = map(point -> (convert(T, point[1]), convert(T, point[2])), value.points)
+ return _polygon(points, convert(Pose2{T}, value.at))
+end
+
+"""
+Return the outer resolved geometric boundary of `primitive`.
+"""
+function boundary end
+
+"""
+Return cross-sectional area \\[m²\\].
+"""
+function area end
+
+"""
+Return exact boundary perimeter in meters.
+"""
+function perimeter end
+
+"""
+Return the absolute cross-sectional centroid as `(x, y)` \\[m\\].
+"""
+function centroid end
+
+"""
+Return the absolute directional support coordinate at angle `φ` \\[m\\].
+"""
+function support end
+
+"""
+Return the inner radius of radial geometry \\[m\\].
+"""
+function r_in end
+
+"""
+Return the outer radius of radial geometry \\[m\\].
+"""
+function r_ex end
+
+"""
+Return the radial thickness of radial geometry \\[m\\].
+"""
+function thickness end
+
+@inline _local_centroid(::Union{Disk, Rectangle, Ellipse, Annulus}) = (0, 0)
+function _local_centroid(primitive::Polygon)
+ signed_twice_area = zero(eltype(primitive))
+ xmoment = zero(eltype(primitive))
+ ymoment = zero(eltype(primitive))
+ for index in eachindex(primitive.points)
+ next = mod1(index + 1, length(primitive.points))
+ left = primitive.points[index]
+ right = primitive.points[next]
+ cross = left[1] * right[2] - right[1] * left[2]
+ signed_twice_area += cross
+ xmoment += (left[1] + right[1]) * cross
+ ymoment += (left[2] + right[2]) * cross
+ end
+ return (
+ xmoment / (3 * signed_twice_area),
+ ymoment / (3 * signed_twice_area)
+ )
+end
+
+function centroid(primitive::AbstractPrimitive)
+ x, y = _local_centroid(primitive)
+ at = primitive.at
+ return (
+ at.x + cos(at.φ) * x - sin(at.φ) * y,
+ at.y + sin(at.φ) * x + cos(at.φ) * y
+ )
+end
+
+_local_support(primitive::Disk, φ::Real) = primitive.r
+_local_support(primitive::Annulus, φ::Real) = primitive.ro
+function _local_support(primitive::Rectangle, φ::Real)
+ abs(cos(φ)) * primitive.w / 2 + abs(sin(φ)) * primitive.h / 2
+end
+function _local_support(primitive::Ellipse, φ::Real)
+ hypot(primitive.a * cos(φ), primitive.b * sin(φ))
+end
+function _local_support(primitive::Polygon, φ::Real)
+ maximum(
+ point[1] * cos(φ) + point[2] * sin(φ) for point in primitive.points
+ )
+end
+
+function support(primitive::AbstractPrimitive, φ::Real)
+ at = primitive.at
+ return at.x * cos(φ) + at.y * sin(φ) +
+ _local_support(primitive, φ - at.φ)
+end
+
+_local_extent(primitive::Disk) = primitive.r
+_local_extent(primitive::Annulus) = primitive.ro
+_local_extent(primitive::Rectangle) = hypot(primitive.w, primitive.h) / 2
+_local_extent(primitive::Ellipse) = max(primitive.a, primitive.b)
+_local_extent(primitive::Polygon) = maximum(hypot(point...) for point in primitive.points)
+function support(primitive::AbstractPrimitive)
+ hypot(primitive.at.x, primitive.at.y) +
+ _local_extent(primitive)
+end
+function support(primitive::Polygon)
+ cosine = cos(primitive.at.φ)
+ sine = sin(primitive.at.φ)
+ return maximum(primitive.points) do point
+ x = primitive.at.x + cosine * point[1] - sine * point[2]
+ y = primitive.at.y + sine * point[1] + cosine * point[2]
+ hypot(x, y)
+ end
+end
+
+boundary(primitive::Union{Disk, Rectangle, Ellipse, Polygon}) = primitive
+boundary(primitive::Annulus) = Disk(primitive.ro, primitive.at)
+
+area(primitive::Disk) = π * primitive.r^2
+area(primitive::Rectangle) = primitive.w * primitive.h
+area(primitive::Ellipse) = π * primitive.a * primitive.b
+area(primitive::Annulus) = π * (primitive.ro^2 - primitive.ri^2)
+function area(primitive::Polygon)
+ twice = sum(eachindex(primitive.points)) do index
+ next = mod1(index + 1, length(primitive.points))
+ primitive.points[index][1] * primitive.points[next][2] -
+ primitive.points[next][1] * primitive.points[index][2]
+ end
+ return abs(twice) / 2
+end
+
+perimeter(primitive::Disk) = 2π * primitive.r
+perimeter(primitive::Rectangle) = 2 * (primitive.w + primitive.h)
+function _ellipse_perimeter(a, b)
+ major = max(a, b)
+ minor = min(a, b)
+ parameter = one(major) - (minor / major)^2
+ return 4major * ellipe(parameter)
+end
+perimeter(primitive::Ellipse) = _ellipse_perimeter(primitive.a, primitive.b)
+perimeter(primitive::Annulus) = 2π * (primitive.ro + primitive.ri)
+function perimeter(primitive::Polygon)
+ return sum(eachindex(primitive.points)) do index
+ next = mod1(index + 1, length(primitive.points))
+ hypot(
+ primitive.points[next][1] - primitive.points[index][1],
+ primitive.points[next][2] - primitive.points[index][2]
+ )
+ end
+end
+
+r_in(primitive::Disk) = zero(primitive.r)
+r_in(primitive::Annulus) = primitive.ri
+r_ex(primitive::Disk) = primitive.r
+r_ex(primitive::Annulus) = primitive.ro
+thickness(primitive::Disk) = primitive.r
+thickness(primitive::Annulus) = primitive.ro - primitive.ri
+
+_with_pose(primitive::Disk, at::Pose2) = Disk(primitive.r, at)
+_with_pose(primitive::Rectangle, at::Pose2) = Rectangle(primitive.w, primitive.h, at)
+_with_pose(primitive::Ellipse, at::Pose2) = Ellipse(primitive.a, primitive.b, at)
+_with_pose(primitive::Annulus, at::Pose2) = Annulus(primitive.ri, primitive.ro, at)
+_with_pose(primitive::Polygon, at::Pose2) = _polygon(primitive.points, at)
+
+boundary(primitive::DifferenceShape) = boundary(primitive.outer)
+support(primitive::DifferenceShape, φ::Real) = support(primitive.outer, φ)
+support(primitive::DifferenceShape) = support(primitive.outer)
+function area(primitive::DifferenceShape)
+ area(primitive.outer) - sum(area, primitive.holes; init = zero(eltype(primitive)))
+end
+function perimeter(primitive::DifferenceShape)
+ perimeter(primitive.outer) +
+ sum(perimeter, primitive.holes; init = zero(eltype(primitive)))
+end
+function centroid(primitive::DifferenceShape)
+ total_area = area(primitive)
+ total_area > zero(total_area) || throw(DomainError(
+ total_area, "difference primitive must have positive area"
+ ))
+ outer_area = area(primitive.outer)
+ outer_centroid = centroid(primitive.outer)
+ xmoment = outer_area * outer_centroid[1]
+ ymoment = outer_area * outer_centroid[2]
+ for hole in primitive.holes
+ hole_area = area(hole)
+ hole_centroid = centroid(hole)
+ xmoment -= hole_area * hole_centroid[1]
+ ymoment -= hole_area * hole_centroid[2]
+ end
+ return (xmoment / total_area, ymoment / total_area)
+end
+
+Commons.input_fields(::Type{<:Disk}) = (r=(name="radius",unit="m"),)
+Commons.input_fields(::Type{<:Rectangle}) = (w=(name="width",unit="m"),h=(name="height",unit="m"))
+Commons.input_fields(::Type{<:Ellipse}) = (a=(name="semi-axis a",unit="m"),b=(name="semi-axis b",unit="m"))
+Commons.input_fields(::Type{<:Annulus}) = (ri=(name="inner radius",unit="m"),ro=(name="outer radius",unit="m"))
+Commons.input_fields(::Type{<:Polygon}) = (points=(name="vertex",unit="m"),)
diff --git a/src/datamodel/geometry/resolve.jl b/src/datamodel/geometry/resolve.jl
new file mode 100644
index 000000000..82e42faed
--- /dev/null
+++ b/src/datamodel/geometry/resolve.jl
@@ -0,0 +1,43 @@
+"""
+Resolve one primitive or contextual layer against a geometric context.
+"""
+function resolve end
+
+resolve(::EmptyBoundary, primitive::AbstractPrimitive) = primitive
+
+function resolve(context::Disk, definition::Shell)
+ values = map(float, promote(context.r, definition.t))
+ inner, layer = values
+ return Annulus(inner, inner + layer, context.at)
+end
+
+function resolve(context::Annulus, definition::Shell)
+ values = map(float, promote(context.ro, definition.t))
+ inner, layer = values
+ return Annulus(inner, inner + layer, context.at)
+end
+
+function resolve(
+ context::Union{Disk, Annulus},
+ definition::Annulus
+)
+ inner = r_ex(context)
+ tolerance = sqrt(eps(float(inner))) * max(one(inner), inner)
+ definition.ri + tolerance >= inner || throw(DomainError(
+ definition.ri,
+ "annulus inner radius must not overlap the current outer radius $inner"
+ ))
+ return Annulus(definition.ri, definition.ro, context.at)
+end
+
+"""
+Compose `at` with the existing absolute primitive pose.
+"""
+resolve(at::Pose2, primitive::AbstractPrimitive) = _with_pose(primitive, at * primitive.at)
+
+function resolve(at::Pose2, primitive::DifferenceShape)
+ return DifferenceShape(
+ resolve(at, primitive.outer),
+ map(hole -> resolve(at, hole), primitive.holes)
+ )
+end
diff --git a/src/datamodel/geometry/sector.jl b/src/datamodel/geometry/sector.jl
new file mode 100644
index 000000000..ec3556aa0
--- /dev/null
+++ b/src/datamodel/geometry/sector.jl
@@ -0,0 +1,438 @@
+"""
+$(TYPEDEF)
+
+Define one material-neutral cable-sector primitive with optional fillets.
+
+The symmetry axis is local `+x`. Translation and rotation belong to
+[`Pose2`](@ref), while material and terminal identity belong to `Region` and
+its containing physical tree.
+
+$(TYPEDFIELDS)
+"""
+struct Sector{T <: Real} <: AbstractPrimitive{T}
+ "Angular opening \\[rad\\]."
+ span::T
+ "Radial coordinate of the innermost midpoint \\[m\\]."
+ r_base::T
+ "Radius of the circular outer back \\[m\\]."
+ r_back::T
+ "Common base and side fillet radius \\[m\\]."
+ fillet::T
+
+ function Sector{T}(
+ span::T,
+ r_base::T,
+ r_back::T,
+ fillet::T
+ ) where {T <: Real}
+ all(isfinite, (span, r_base, r_back, fillet)) || throw(ArgumentError(
+ "sector dimensions must be finite"
+ ))
+ zero(span) < span < oftype(span, π) || throw(DomainError(
+ span, "sector span must lie in (0, π)"
+ ))
+ zero(r_base) <= r_base < r_back || throw(DomainError(
+ r_base, "sector base radius must lie in [0, r_back)"
+ ))
+ zero(fillet) <= fillet < r_back || throw(DomainError(
+ fillet, "sector fillet must lie in [0, r_back)"
+ ))
+ value = new{T}(span, r_base, r_back, fillet)
+ _sector_contacts(value)
+ return value
+ end
+end
+
+function Sector(
+ span::Real,
+ r_base::Real,
+ r_back::Real,
+ fillet::Real = 0
+)
+ values = map(float, promote(span, r_base, r_back, fillet))
+ return Sector{typeof(first(values))}(values...)
+end
+
+function Base.convert(
+ ::Type{<:AbstractPrimitive{T}},
+ value::Sector
+) where {T <: Real}
+ return Sector{T}(
+ convert(T, value.span),
+ convert(T, value.r_base),
+ convert(T, value.r_back),
+ convert(T, value.fillet)
+ )
+end
+
+"""
+$(TYPEDEF)
+
+Store the exact contact geometry of one placed [`Sector`](@ref).
+
+`contacts` contains passive derived arc and segment data. It is rebuilt from
+the authoritative primitive and is never serialized.
+"""
+struct SectorShape{
+ T <: Real,
+ P <: Sector{T},
+ C <: NamedTuple,
+ A <: Pose2{T}
+} <: AbstractShape{T}
+ "Authoritative intrinsic cable-sector primitive."
+ primitive::P
+ "Exact derived arc contacts and straight boundary segments."
+ contacts::C
+ "Pose in the completed cable coordinate system."
+ at::A
+end
+
+"""
+$(TYPEDEF)
+
+Represent the exact material domain between two resolved geometric boundaries.
+"""
+struct ShellShape{
+ T <: Real,
+ I <: AbstractShape{T},
+ O <: AbstractShape{T}
+} <: AbstractShape{T}
+ "Resolved inner boundary."
+ inner::I
+ "Resolved parallel outer boundary."
+ outer::O
+
+ function ShellShape(inner::I, outer::O) where {
+ T <: Real, I <: AbstractShape{T}, O <: AbstractShape{T}
+ }
+ shell_area = area(outer) - area(inner)
+ shell_area > zero(shell_area) || throw(DomainError(
+ shell_area, "a conformal shell must have positive area"
+ ))
+ return new{T, I, O}(inner, outer)
+ end
+end
+
+_geometry_scalar(value) = float(nominal(value))
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the absolute floating-point roundoff allowance used by geometric
+predicates: 64 spacings at the magnitude of the nominal input, with unit scale
+when that magnitude is zero.
+
+# Arguments
+
+- `value`: geometric scale in the SI units of the quantity being compared
+ (such as length \\[m\\] or area \\[m²\\]).
+
+# Returns
+
+- A nonnegative numerical allowance in the same units. Physical uncertainty
+ is not included in this roundoff allowance.
+"""
+function geometry_tolerance(value)
+ scale = abs(_geometry_scalar(value))
+ return 64 * eps(iszero(scale) ? one(scale) : scale)
+end
+
+function _positive_arc(start, stop)
+ period = oftype(start + stop, 2π)
+ while stop < start
+ stop += period
+ end
+ return (start = start, stop = stop)
+end
+
+function _arc(center, radius, first_point, last_point)
+ start = atan(first_point[2] - center[2], first_point[1] - center[1])
+ stop = atan(last_point[2] - center[2], last_point[1] - center[1])
+ interval = _positive_arc(start, stop)
+ return (; center, radius, interval.start, interval.stop)
+end
+
+function _sector_contacts(primitive::Sector)
+ α = primitive.span
+ b = primitive.r_base
+ radius = primitive.r_back
+ fillet = primitive.fillet
+ half_complement = π / 2 - α / 2
+ cosine = cos(half_complement)
+ sine = sin(half_complement)
+ tangent = tan(half_complement)
+
+ base_offset = b - fillet * (inv(cosine) - one(cosine))
+ line_offset = b + fillet
+ qa = one(tangent) + tangent^2
+ qb = 2 * line_offset * tangent
+ qc = line_offset^2 - (radius - fillet)^2
+ discriminant = qb^2 - 4 * qa * qc
+ tolerance = geometry_tolerance(max(abs(qb^2), abs(4 * qa * qc)))
+ discriminant_value = _geometry_scalar(discriminant)
+ discriminant_value < -tolerance && throw(DomainError(
+ discriminant,
+ "sector side fillet has no valid tangent contact"
+ ))
+ discriminant_value < 0 && (discriminant = zero(discriminant))
+
+ old_x = (-qb + sqrt(discriminant)) / (2 * qa)
+ old_y = old_x * tangent + line_offset
+ side_distance = hypot(old_x, old_y)
+ distance_tolerance = geometry_tolerance(radius)
+ abs(_geometry_scalar(side_distance - (radius - fillet))) <=
+ distance_tolerance || throw(DomainError(
+ side_distance,
+ "sector side contact is inconsistent with its outer back"
+ ))
+
+ base_center = (b + fillet, zero(b))
+ base_x = base_offset + fillet * sine * tangent
+ base_lower = (base_x, -fillet * sine)
+ base_upper = (base_x, fillet * sine)
+ lower_center = (old_y, -old_x)
+ upper_center = (old_y, old_x)
+ side_lower = (old_y - fillet * cosine, -(old_x + fillet * sine))
+ side_upper = (side_lower[1], -side_lower[2])
+ back_lower = (old_y * radius / side_distance, -old_x * radius / side_distance)
+ back_upper = (back_lower[1], -back_lower[2])
+
+ lower_direction = (
+ side_lower[1] - base_lower[1],
+ side_lower[2] - base_lower[2]
+ )
+ lower_direction[1] * cosine - lower_direction[2] * sine >=
+ -distance_tolerance || throw(DomainError(
+ lower_direction, "sector straight side has reversed contact order"
+ ))
+
+ arcs = (
+ base = _arc(base_center, fillet, base_upper, base_lower),
+ lower = _arc(lower_center, fillet, side_lower, back_lower),
+ back = _arc((zero(radius), zero(radius)), radius, back_lower, back_upper),
+ upper = _arc(upper_center, fillet, back_upper, side_upper)
+ )
+ all(arc -> begin
+ iszero(arc.radius) && return true
+ extent = _geometry_scalar(arc.stop - arc.start)
+ -distance_tolerance <= extent <= π + distance_tolerance
+ end, values(arcs)) || throw(DomainError(
+ arcs, "sector arc contacts do not form a convex boundary"
+ ))
+ segments = (
+ lower = (base_lower, side_lower),
+ upper = (side_upper, base_upper)
+ )
+ points = (;
+ base_upper,
+ base_lower,
+ side_lower,
+ back_lower,
+ back_upper,
+ side_upper
+ )
+ return (; points, arcs, segments)
+end
+
+function _sector_side_clearance(primitive::Sector)
+ normal_projection = sin(primitive.span / 2)
+ return 2 * (
+ primitive.r_base * normal_projection -
+ primitive.fillet * (one(normal_projection) - normal_projection)
+ )
+end
+
+function SectorShape(primitive::Sector, at::Pose2 = _origin(eltype(primitive)))
+ T = promote_type(eltype(primitive), eltype(at))
+ converted = convert(AbstractPrimitive{T}, primitive)
+ pose = convert(Pose2{T}, at)
+ contacts = _sector_contacts(converted)
+ return SectorShape{T, typeof(converted), typeof(contacts), typeof(pose)}(
+ converted, contacts, pose
+ )
+end
+
+function _line_moments(first_point, last_point)
+ x0, y0 = first_point
+ x1, y1 = last_point
+ return (
+ area = (x0 * y1 - x1 * y0) / 2,
+ xmoment = (y1 - y0) * (x0^2 + x0 * x1 + x1^2) / 6,
+ ymoment = -(x1 - x0) * (y0^2 + y0 * y1 + y1^2) / 6
+ )
+end
+
+function _arc_moments(arc)
+ cx, cy = arc.center
+ radius = arc.radius
+ start = arc.start
+ stop = arc.stop
+ span = stop - start
+ cosine = sin(stop) - sin(start)
+ sine = -cos(stop) + cos(start)
+ cosine_squared = span / 2 + (sin(2 * stop) - sin(2 * start)) / 4
+ sine_squared = span / 2 - (sin(2 * stop) - sin(2 * start)) / 4
+ cosine_cubed =
+ sin(stop) - sin(stop)^3 / 3 - sin(start) + sin(start)^3 / 3
+ sine_cubed =
+ -cos(stop) + cos(stop)^3 / 3 + cos(start) - cos(start)^3 / 3
+ return (
+ area = (
+ radius * cx * cosine + radius * cy * sine + radius^2 * span
+ ) / 2,
+ xmoment = radius * (
+ cx^2 * cosine + 2 * cx * radius * cosine_squared +
+ radius^2 * cosine_cubed
+ ) / 2,
+ ymoment = radius * (
+ cy^2 * sine + 2 * cy * radius * sine_squared +
+ radius^2 * sine_cubed
+ ) / 2
+ )
+end
+
+function _sector_moments(shape::SectorShape)
+ moments = (
+ _arc_moments(shape.contacts.arcs.base),
+ _line_moments(shape.contacts.segments.lower...),
+ _arc_moments(shape.contacts.arcs.lower),
+ _arc_moments(shape.contacts.arcs.back),
+ _arc_moments(shape.contacts.arcs.upper),
+ _line_moments(shape.contacts.segments.upper...)
+ )
+ return (
+ area = sum(getproperty.(moments, :area)),
+ xmoment = sum(getproperty.(moments, :xmoment)),
+ ymoment = sum(getproperty.(moments, :ymoment))
+ )
+end
+
+area(shape::SectorShape) = _sector_moments(shape).area
+function perimeter(shape::SectorShape)
+ arc_length = sum(values(shape.contacts.arcs)) do arc
+ arc.radius * (arc.stop - arc.start)
+ end
+ line_length = sum(values(shape.contacts.segments)) do segment
+ hypot(
+ segment[2][1] - segment[1][1],
+ segment[2][2] - segment[1][2]
+ )
+ end
+ return arc_length + line_length
+end
+
+function centroid(shape::SectorShape)
+ moments = _sector_moments(shape)
+ local_centroid = (
+ moments.xmoment / moments.area,
+ moments.ymoment / moments.area
+ )
+ return shape.at(local_centroid)
+end
+
+function _angle_in_arc(angle, arc)
+ period = oftype(angle, 2π)
+ shifted_angle = angle + ceil((arc.start - angle) / period) * period
+ tolerance = geometry_tolerance(arc.stop - arc.start)
+ return _geometry_scalar(shifted_angle - arc.stop) <= tolerance
+end
+
+function _local_support(shape::SectorShape, angle::Real)
+ cosine = cos(angle)
+ sine = sin(angle)
+ projection(point) = point[1] * cosine + point[2] * sine
+ support_values = [projection(point) for point in values(shape.contacts.points)]
+ for arc in values(shape.contacts.arcs)
+ _angle_in_arc(angle, arc) || continue
+ push!(support_values, projection(arc.center) + arc.radius)
+ end
+ return maximum(support_values)
+end
+
+function support(shape::SectorShape, angle::Real)
+ return shape.at.x * cos(angle) + shape.at.y * sin(angle) +
+ _local_support(shape, angle - shape.at.φ)
+end
+
+support(shape::SectorShape) = hypot(shape.at.x, shape.at.y) + shape.primitive.r_back
+boundary(shape::SectorShape) = shape
+r_in(shape::SectorShape) = shape.primitive.r_base
+r_ex(shape::SectorShape) = shape.primitive.r_back
+thickness(shape::SectorShape) = r_ex(shape) - r_in(shape)
+
+resolve(::EmptyBoundary, primitive::Sector) = SectorShape(primitive)
+resolve(at::Pose2, primitive::Sector) = SectorShape(primitive, at)
+function resolve(at::Pose2, shape::SectorShape)
+ return SectorShape(shape.primitive, at * shape.at)
+end
+
+function resolve(inner::SectorShape, layer::Shell)
+ primitive = inner.primitive
+ outer = Sector(
+ primitive.span,
+ primitive.r_base - layer.t,
+ primitive.r_back + layer.t,
+ primitive.fillet + layer.t
+ )
+ return ShellShape(inner, SectorShape(outer, inner.at))
+end
+
+boundary(shape::ShellShape) = shape.outer
+area(shape::ShellShape) = area(shape.outer) - area(shape.inner)
+perimeter(shape::ShellShape) = perimeter(shape.inner) + perimeter(shape.outer)
+support(shape::ShellShape, angle::Real) = support(shape.outer, angle)
+support(shape::ShellShape) = support(shape.outer)
+function centroid(shape::ShellShape)
+ inner_area = area(shape.inner)
+ outer_area = area(shape.outer)
+ shell_area = outer_area - inner_area
+ inner_centroid = centroid(shape.inner)
+ outer_centroid = centroid(shape.outer)
+ return (
+ (outer_area * outer_centroid[1] - inner_area * inner_centroid[1]) /
+ shell_area,
+ (outer_area * outer_centroid[2] - inner_area * inner_centroid[2]) /
+ shell_area
+ )
+end
+
+function resolve(at::Pose2, shape::ShellShape)
+ return ShellShape(resolve(at, shape.inner), resolve(at, shape.outer))
+end
+
+function _arc_points(arc, count::Int)
+ iszero(arc.radius) && return [arc.center]
+ return [
+ (
+ arc.center[1] + arc.radius * cos(angle),
+ arc.center[2] + arc.radius * sin(angle)
+ )
+ for angle in range(arc.start, arc.stop; length = count)
+ ]
+end
+
+"""
+ tessellate(shape::SectorShape; points_per_arc=32)
+
+Approximate the exact geometric boundary with coordinate tuples for rendering or meshing.
+Geometric properties are computed from the exact sector boundary.
+"""
+function tessellate(shape::SectorShape; points_per_arc::Integer = 32)
+ points_per_arc >= 2 || throw(ArgumentError(
+ "points_per_arc must be at least two"
+ ))
+ contacts = shape.contacts
+ points = collect(_arc_points(contacts.arcs.base, Int(points_per_arc)))
+ push!(points, contacts.points.side_lower)
+ append!(points, _arc_points(contacts.arcs.lower, Int(points_per_arc))[2:end])
+ append!(points, _arc_points(contacts.arcs.back, Int(points_per_arc))[2:end])
+ append!(points, _arc_points(contacts.arcs.upper, Int(points_per_arc))[2:end])
+ return [shape.at(point) for point in points]
+end
+
+function tessellate(shape::ShellShape; points_per_arc::Integer = 128)
+ return (
+ outer = tessellate(shape.outer; points_per_arc),
+ inner = tessellate(shape.inner; points_per_arc)
+ )
+end
diff --git a/src/datamodel/geometry/shell.jl b/src/datamodel/geometry/shell.jl
new file mode 100644
index 000000000..64f60ad07
--- /dev/null
+++ b/src/datamodel/geometry/shell.jl
@@ -0,0 +1,29 @@
+"""
+$(TYPEDEF)
+
+Declare a conformal layer extending outward from an exact resolved geometric boundary.
+
+Calling [`resolve`](@ref) against the preceding geometric boundary produces the exact
+material domain occupied by the layer.
+
+$(TYPEDFIELDS)
+"""
+struct Shell{T <: Real}
+ "Normal layer thickness \\[m\\]."
+ t::T
+
+ function Shell{T}(t::T) where {T <: Real}
+ isfinite(t) && t > zero(t) ||
+ throw(DomainError(t, "shell thickness must be positive and finite"))
+ return new{T}(t)
+ end
+end
+
+Shell(t::Real) = Shell{typeof(float(t))}(float(t))
+
+Base.eltype(::Shell{T}) where {T} = T
+Base.eltype(::Type{<:Shell{T}}) where {T} = T
+
+function Base.convert(::Type{<:Shell{T}}, value::Shell) where {T <: Real}
+ return Shell{T}(convert(T, value.t))
+end
diff --git a/src/datamodel/helpers.jl b/src/datamodel/helpers.jl
deleted file mode 100644
index ab6e88027..000000000
--- a/src/datamodel/helpers.jl
+++ /dev/null
@@ -1,98 +0,0 @@
-"""
-$(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
-
-# Outermost radius of a fully-built cable design
-@inline function get_outer_radius(des::CableDesign)
- last_comp = des.components[end]
- r_c = to_nominal(last_comp.conductor_group.r_ex)
- r_i = to_nominal(last_comp.insulator_group.r_ex)
- return max(r_c, r_i)
-end
\ No newline at end of file
diff --git a/src/datamodel/insulator.jl b/src/datamodel/insulator.jl
deleted file mode 100644
index fdaf822b9..000000000
--- a/src/datamodel/insulator.jl
+++ /dev/null
@@ -1,112 +0,0 @@
-"""
-$(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\\]."
- r_in::T
- "External radius of the insulating layer \\[m\\]."
- r_ex::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
-
-- `r_in`: Internal radius of the insulating layer \\[m\\].
-- `r_ex`: 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(
- r_in::T,
- r_ex::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 = π * (r_ex^2 - r_in^2)
-
- resistance =
- calc_tubular_resistance(r_in, r_ex, rho, alpha, T0, temperature)
- gmr = calc_tubular_gmr(r_ex, r_in, material_props.mu_r)
- shunt_capacitance = calc_shunt_capacitance(r_in, r_ex, epsr_r)
- shunt_conductance = calc_shunt_conductance(r_in, r_ex, rho)
-
- # Initialize object
- return Insulator(
- r_in,
- r_ex,
- material_props,
- temperature,
- cross_section,
- resistance,
- gmr,
- shunt_capacitance,
- shunt_conductance,
- )
-end
-
-const _REQ_INSULATOR = (:r_in, :r_ex, :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{:r_in}, x::AbstractCablePart) =
- true
-Validation.is_radius_input(::Type{Insulator}, ::Val{:r_in}, x::Thickness) = true
-Validation.is_radius_input(::Type{Insulator}, ::Val{:r_ex}, x::Thickness) = true
-Validation.is_radius_input(::Type{Insulator}, ::Val{:r_ex}, 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.r_in, nt.r_ex)
- (; nt..., r_in = rin, r_ex = 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
deleted file mode 100644
index aa57d36b1..000000000
--- a/src/datamodel/insulatorgroup.jl
+++ /dev/null
@@ -1,203 +0,0 @@
-
-"""
-$(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\\]."
- r_in::T
- "Outer radius of the insulator group \\[m\\]."
- r_ex::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}(
- r_in::T,
- r_ex::T,
- cross_section::T,
- shunt_capacitance::T,
- shunt_conductance::T,
- layers::Vector{AbstractInsulatorPart{T}},
- ) where {T}
- return new{T}(r_in, r_ex, cross_section,
- shunt_capacitance, shunt_conductance, layers)
- end
-
- function InsulatorGroup{T}(initial_insulator::AbstractInsulatorPart{T}) where {T}
- return new{T}(
- initial_insulator.r_in,
- initial_insulator.r_ex,
- 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 `r_in` to `group.r_ex` if absent.
-3. Compute `Tnew = resolve_T(group, r_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`, `r_ex`, and `cross_section` to account for the new part.
-- The `r_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 `r_in`, the constructor retrieves its `r_ex` value, allowing the new cable part to be placed directly over the existing part in a layered cable design.
- - For uncertain geometries, the preceding part's outer-radius derivative graph
- is retained. Adjacent layers therefore share one physical boundary and
- cumulative-radius covariance is preserved across different part types.
-
-# 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, :r_in, group.r_ex)
- kwv = haskey(kwv, :r_in) ? kwv : merge(kwv, (; r_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.r_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.r_ex += new_part.r_ex - new_part.r_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
deleted file mode 100644
index f4bf7185e..000000000
--- a/src/datamodel/insulatorgroup/base.jl
+++ /dev/null
@@ -1,3 +0,0 @@
-
-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/interfaces.jl b/src/datamodel/interfaces.jl
new file mode 100644
index 000000000..8ee7dbe6e
--- /dev/null
+++ b/src/datamodel/interfaces.jl
@@ -0,0 +1,19 @@
+"""
+Return the number of cable positions in a line-cable system.
+"""
+function ncables end
+
+"""
+Return the number of distinct active phases in a line-cable system.
+"""
+function nphases end
+
+"""
+Return detached preview geometry for one owned cable layer.
+"""
+function preview_shapes end
+
+"""
+Return the materials represented by one owned cable layer.
+"""
+function preview_materials end
diff --git a/src/datamodel/io.jl b/src/datamodel/io.jl
deleted file mode 100644
index 7b45456a0..000000000
--- a/src/datamodel/io.jl
+++ /dev/null
@@ -1,193 +0,0 @@
-# Full inheritance over composition madness
-function Base.getproperty(part::AbstractCablePart, sym::Symbol)
- # Fast path: Is it a real field on the top-level struct? (r_in, gmr, shape)
- if hasfield(typeof(part), sym)
- return getfield(part, sym) # MUST use getfield here to prevent infinite recursion
- end
-
- # Fallback path: Route it to the shape payload (if the struct has a shape)
- if hasfield(typeof(part), :shape)
- shape_payload = getfield(part, :shape)
- # We use hasproperty on the shape just in case the shape itself has custom routing
- if hasproperty(shape_payload, sym)
- return getproperty(shape_payload, sym)
- end
- end
-
- # If it doesn't exist anywhere, fallback to standard getfield to throw the normal error
- return getfield(part, sym)
-end
-
-function Base.hasproperty(part::AbstractCablePart, sym::Symbol)
- # 1. Does it exist at the top level?
- if hasfield(typeof(part), sym)
- return true
- end
-
- # 2. Does it exist in the shape payload?
- if hasfield(typeof(part), :shape)
- return hasproperty(getfield(part, :shape), sym)
- end
-
- return false
-end
-
-function Base.propertynames(part::AbstractCablePart, private::Bool = false)
- top_fields = fieldnames(typeof(part))
-
- if hasfield(typeof(part), :shape)
- shape_payload = getfield(part, :shape)
- shape_fields = propertynames(shape_payload, private)
-
- # Merge and deduplicate the fields
- return Tuple(unique((top_fields..., shape_fields...)))
- end
-
- return top_fields
-end
-
-"""
-$(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,
- [
- :r_in,
- :r_ex,
- :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,
- [
- :r_in,
- :r_ex,
- :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,
- [
- :r_in,
- :r_ex,
- :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 = getproperty(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
diff --git a/src/datamodel/linecablesystem.jl b/src/datamodel/linecablesystem.jl
deleted file mode 100644
index 481a0aa88..000000000
--- a/src/datamodel/linecablesystem.jl
+++ /dev/null
@@ -1,372 +0,0 @@
-
-"""
-$(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.r_ex
- r_ins = last_comp.insulator_group.r_ex
- 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.r_ex, comp.insulator_group.r_ex)
-# 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(get_outer_radius(a.design_data), T)
- x2 = coerce_to_T(b.horz, T)
- y2 = coerce_to_T(b.vert, T)
- r2 = coerce_to_T(get_outer_radius(b.design_data), T)
- d = hypot(x1 - x2, y1 - y2)
- tol = 1e-8 * max(r1 + r2, 1.0)
- overlaps = d+tol < (r1 + r2)
- if overlaps
- @warn "Cable positions overlap: distance $d < sum of radii $(r1 + r2)"
- end
- return overlaps # strict overlap; grazing contact (within tolerance) 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
deleted file mode 100644
index 327c4d387..000000000
--- a/src/datamodel/linecablesystem/base.jl
+++ /dev/null
@@ -1,35 +0,0 @@
-
-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/clearance.jl b/src/datamodel/linecablesystem/clearance.jl
new file mode 100644
index 000000000..a98975ee1
--- /dev/null
+++ b/src/datamodel/linecablesystem/clearance.jl
@@ -0,0 +1,323 @@
+"Minimum engineering separation of cable exteriors \\[m\\]."
+const _CABLE_CLEARANCE = 1.0e-6
+
+# A scoped value, not process-global mutable state: nested user builders can
+# participate without acquiring an extra public argument. Each sampling task
+# maintains its records and counters.
+const _CLEARANCE_CONTEXT = Base.ScopedValues.ScopedValue{Any}(nothing)
+
+function _clearance_exterior(design, pose)
+ shape = boundary(design.geometry)
+ if shape isa Disk
+ # Keep eccentric or uncertain local @at coordinates in the center, not
+ # inside a norm about the declaration origin (undefined derivative at 0).
+ reserve = uncertainty(outer_radius(design))
+ if !isfinite(reserve)
+ # At the origin, `norm(x,y)` has no derivative. Its RMS offset
+ # bounds the standard deviation. Retain that conservative reserve
+ # without manufacturing an independent Measurement.
+ reserve = uncertainty(shape.r) + hypot(uncertainty(shape.at.x), uncertainty(shape.at.y))
+ end
+ reserve = max(reserve, uncertainty(shape.r))
+ return (radius = shape.r, center = pose * shape.at, reserve = reserve)
+ end
+ radius = outer_radius(design)
+ return (radius = radius, center = pose, reserve = uncertainty(radius))
+end
+
+"""
+Floating-point allowance for subtraction of placement coordinates \\[m\\].
+"""
+function _clearance_roundoff(radii, poses)
+ scale = maximum(abs ∘ nominal, radii)
+ for pose in poses
+ scale = max(scale, abs(nominal(pose.x)), abs(nominal(pose.y)))
+ end
+ return 8eps(float(scale))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Resolve exterior-circle clearance by expanding relative cable placements,
+without changing cable dimensions or rotations. Shared translations and
+formation symmetry are retained. If `interface` is true, each half-space
+group is translated away from the air-earth interface when necessary.
+
+The required pair gap is:
+
+```math
+c_{ij} = 10^{-6} + \\max\\left(u_{R,\\max}, u(d_{ij}-r_i-r_j)\\right)
+```
+
+where all lengths and standard uncertainties are in \\[m\\]. Diagonal entries
+retain the corresponding interface clearance. Supplied `required` values
+retain the original uncertainty budget after stochastic sampling.
+
+Circular boundaries use their composed centers and physical radii. Other
+geometric boundaries use their containing circles. A nominally zero uncertain circular
+offset uses its RMS displacement as a conservative radius-uncertainty reserve.
+
+Nominal overlaps are rejected. `sampling=true` permits construction of a
+feasible realization from an already checked declaration. With `adjust=false`,
+only validate the supplied geometry and clearance requirements.
+
+# Arguments
+
+- `designs`: completed cable designs in placement order.
+- `poses`: cable translations \\[m\\] and rotations \\[rad\\] in the system frame.
+
+# Keywords
+
+- `required=nothing`: retained pairwise and interface clearance matrix \\[m\\].
+- `reference=poses`: declared poses used to distinguish nominal overlaps from sampled ones.
+- `interface=false`: enforce clearance from the air-earth interface.
+- `sampling=false`: permit correction of overlaps produced by a sampled declaration.
+- `adjust=true`: resolve placements. `false` performs validation only.
+- `reference_centers=nothing`: composed exterior-center poses retained before sampling.
+
+# Returns
+
+- Resolved poses, retained clearance matrix \\[m\\], and maximum translation
+ magnitude \\[m\\]. Input arrays are not modified.
+"""
+function clearance_geometry(designs, poses;
+ required = nothing, reference = poses, interface::Bool = false,
+ sampling::Bool = false, adjust::Bool = true, reference_centers = nothing)
+ exteriors = _clearance_exterior.(designs, poses)
+ radii = getproperty.(exteriors, :radius)
+ centers = getproperty.(exteriors, :center)
+ reference_centers === nothing &&
+ (reference_centers = getproperty.(_clearance_exterior.(designs, reference), :center))
+ n = length(radii)
+ length(poses) == length(reference) == n || throw(DimensionMismatch(
+ "clearance requires one pose per cable"))
+ T = eltype(eltype(poses))
+ roundoff = _clearance_roundoff(radii, centers)
+ if required === nothing
+ minimum_gap = oftype(nominal(zero(T)), _CABLE_CLEARANCE)
+ minimum_gap < _CABLE_CLEARANCE && (minimum_gap = nextfloat(minimum_gap))
+ radius_uncertainty = maximum(exterior -> exterior.reserve, exteriors)
+ required = zeros(T, n, n)
+ for i in 1:n
+ required[i, i] = minimum_gap + max(radius_uncertainty,
+ uncertainty(abs(centers[i].y) - radii[i]))
+ for j in 1:(i - 1)
+ distance = hypot(centers[i].x - centers[j].x, centers[i].y - centers[j].y)
+ clearance = minimum_gap + max(radius_uncertainty,
+ uncertainty(distance - radii[i] - radii[j]))
+ required[i, j] = required[j, i] = clearance
+ end
+ end
+ end
+ size(required) == (n, n) || throw(DimensionMismatch(
+ "clearance matrix must match the cable count"))
+ all(value -> isfinite(value) && nominal(value) >= oftype(nominal(value), _CABLE_CLEARANCE) &&
+ iszero(uncertainty(value)), required) || throw(ArgumentError(
+ "retained clearances must be finite deterministic lengths of at least 1 μm"))
+ required == transpose(required) || throw(ArgumentError(
+ "retained cable clearances must be symmetric"))
+
+ result = copy(centers)
+ if sampling && any(((i, j),) ->
+ iszero(nominal(result[i].x - result[j].x)) &&
+ iszero(nominal(result[i].y - result[j].y)),
+ ((i, j) for i in 1:n for j in 1:(i - 1)))
+ # A coincident draw has no direction. Reuse the declared relative
+ # layout at the sampled center, then apply the same clearance rule.
+ cx = sum(p -> p.x, result) / n
+ cy = sum(p -> p.y, result) / n
+ rx = sum(p -> nominal(p.x), reference_centers) / n
+ ry = sum(p -> nominal(p.y), reference_centers) / n
+ result = [Pose2(cx + nominal(reference_centers[i].x) - rx,
+ cy + nominal(reference_centers[i].y) - ry, centers[i].φ) for i in 1:n]
+ end
+ factor = one(T)
+ for i in 1:n, j in 1:(i - 1)
+ distance = hypot(result[i].x - result[j].x, result[i].y - result[j].y)
+ gap = distance - radii[i] - radii[j]
+ if !adjust
+ nominal(gap) > 0 && nominal(gap) + roundoff >= nominal(required[i, j]) ||
+ throw(DomainError((i, j), "cable exterior clearance is below its retained minimum"))
+ continue
+ end
+ !sampling && nominal(gap) < -roundoff && throw(DomainError((i, j),
+ "cable cross-sections overlap; only touching or insufficient positive clearance can be adjusted"))
+ nominal(distance) > 0 || throw(DomainError((i, j),
+ "coincident cable centers do not define a separation direction"))
+ if nominal(gap) + roundoff < nominal(required[i, j])
+ required_scale = (radii[i] + radii[j] + required[i, j] + 2roundoff) / distance
+ nominal(required_scale) > nominal(factor) && (factor = required_scale)
+ end
+ end
+ if nominal(factor) > 1
+ xcenter = sum(pose -> pose.x, result) / n
+ ycenter = sum(pose -> pose.y, result) / n
+ result = [Pose2(xcenter + factor * (pose.x - xcenter),
+ ycenter + factor * (pose.y - ycenter), pose.φ) for pose in result]
+ end
+
+ if interface
+ # A common translation within each half-space leaves distances unchanged
+ # within that group and increases separation from the other group.
+ for side in (-1, 1)
+ indices = findall(pose -> sign(nominal(pose.y)) == side, reference_centers)
+ shift = zero(T)
+ for i in indices
+ original_gap = side * reference_centers[i].y - radii[i]
+ gap = side * result[i].y - radii[i]
+ adjust && !sampling && nominal(gap) + roundoff < nominal(required[i, i]) &&
+ nominal(original_gap) < -roundoff && throw(DomainError(i,
+ "cable cross-section crosses the air-earth interface"))
+ if !adjust
+ nominal(gap) > 0 && nominal(gap) + roundoff >= nominal(required[i, i]) ||
+ throw(DomainError(i, "cable clearance from the air-earth interface is below its retained minimum"))
+ elseif nominal(gap) + roundoff < nominal(required[i, i])
+ required_shift = required[i, i] + 2roundoff - gap
+ nominal(required_shift) > nominal(shift) && (shift = required_shift)
+ end
+ end
+ if nominal(shift) > 0
+ for i in indices
+ pose = result[i]
+ result[i] = Pose2(pose.x, pose.y + side * shift, pose.φ)
+ end
+ end
+ end
+ any(pose -> iszero(nominal(pose.y)), reference_centers) && throw(DomainError(
+ reference, "a declared cable center cannot lie on the air-earth interface"))
+ if adjust
+ # Extreme draws can exchange half-spaces before projection. If
+ # restoring their declared sides shortened a cross-interface
+ # distance, expansion about y=0 preserves the restored sides.
+ factor = one(T)
+ for i in 1:n, j in 1:(i - 1)
+ distance = hypot(result[i].x - result[j].x, result[i].y - result[j].y)
+ limit = radii[i] + radii[j] + required[i, j]
+ if nominal(distance) + roundoff < nominal(limit)
+ required_scale = (limit + 2roundoff) / distance
+ nominal(required_scale) > nominal(factor) && (factor = required_scale)
+ end
+ end
+ if nominal(factor) > 1
+ xcenter = sum(p -> p.x, result) / n
+ result = [Pose2(xcenter + factor * (pose.x - xcenter),
+ factor * pose.y, pose.φ) for pose in result]
+ end
+ end
+ end
+ displacement = maximum(eachindex(result)) do i
+ hypot(nominal(result[i].x - centers[i].x), nominal(result[i].y - centers[i].y))
+ end
+ resolved = iszero(displacement) ? copy(poses) :
+ [Pose2(poses[i].x + (result[i].x - centers[i].x),
+ poses[i].y + (result[i].y - centers[i].y), poses[i].φ) for i in 1:n]
+ if adjust && displacement > 0
+ clearance_geometry(designs, resolved; required, reference, reference_centers,
+ interface, sampling, adjust = false)
+ end
+ return resolved, Matrix{T}(required), displacement
+end
+
+function _record_clearance_adjustment(system_id, displacement)
+ displacement > 0 || return nothing
+ clearance = _CLEARANCE_CONTEXT[]
+ if clearance === nothing
+ @warn "Cable placements adjusted to preserve exterior clearance" system_id max_displacement_m=displacement
+ else
+ clearance.adjustments[] += 1
+ clearance.max_displacement[] = max(clearance.max_displacement[], displacement)
+ end
+ return nothing
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Materialize one unresolved line-system point to retain its uncertainty-bearing
+clearance requirements before sampling. Return task-scoped construction records
+and adjustment counters. Do not consume the sampling RNG.
+"""
+function collect_clearance_requirements(point)
+ clearance = (records = Any[], references = IdDict{Any, Any}(),
+ cursor = Ref(0), sampling = Ref(false),
+ adjustments = Ref(0), max_displacement = Ref(0.0), declaration=Ref{Any}(nothing))
+ clearance.declaration[] = Base.ScopedValues.with(_CLEARANCE_CONTEXT => clearance) do
+ if Base.get_extension(parentmodule(@__MODULE__), :LineCableModelsMeasurementsExt) === nothing
+ # Monte Carlo remains usable without Measurements. The geometric
+ # constraint still applies to every draw. Propagated uncertainty
+ # reserves are available when Measurements is loaded.
+ realize(point, realize_arguments(Random.Xoshiro(0), point,
+ (_rng, mean, _sigma) -> mean))
+ else
+ materialize(point)
+ end
+ end
+ clearance.sampling[] = true
+ clearance.adjustments[] = 0
+ clearance.max_displacement[] = 0.0
+ return clearance
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Execute `f` while reconstructing one realization with recorded clearance
+requirements. Return the result of `f` and restore the previous scope on exit.
+"""
+function with_clearance(f, clearance)
+ clearance === nothing && return f()
+ return Base.ScopedValues.with(_CLEARANCE_CONTEXT => clearance) do
+ clearance.cursor[] = 0
+ empty!(clearance.references)
+ value = f()
+ clearance.cursor[] == length(clearance.records) || throw(ArgumentError(
+ "sampled construction produced fewer cable systems than its declaration"))
+ return value
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the number of adjusted builds and maximum translation \\[m\\] recorded
+in the sampling record. A missing clearance returns zero counts and displacement.
+"""
+function clearance_summary(clearance)
+ clearance === nothing && return (adjustments = 0, max_displacement_m = 0.0)
+ return (adjustments = clearance.adjustments[],
+ max_displacement_m = clearance.max_displacement[])
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Emit one aggregate warning if any sampled placements required adjustment.
+Return `nothing`. No warning is emitted for an unchanged geometry.
+"""
+function warn_clearance_summary(clearance)
+ summary = clearance_summary(clearance)
+ summary.adjustments > 0 &&
+ @warn "Sampled cable placements adjusted to preserve exterior clearance" summary...
+ return nothing
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Sample and build one line-system point with its retained clearance requirements.
+Reuse enclosing clearance requirements or collect them for a standalone draw.
+Return the completed system or problem.
+"""
+function realize_clearance(rng, point, distribution)
+ _CLEARANCE_CONTEXT[] === nothing ||
+ return realize(point, realize_arguments(rng, point, distribution))
+ clearance = collect_clearance_requirements(point)
+ try
+ return with_clearance(clearance) do
+ realize(point, realize_arguments(rng, point, distribution))
+ end
+ finally
+ warn_clearance_summary(clearance)
+ end
+end
diff --git a/src/datamodel/linecablesystem/dataframe.jl b/src/datamodel/linecablesystem/dataframe.jl
deleted file mode 100644
index c51cef710..000000000
--- a/src/datamodel/linecablesystem/dataframe.jl
+++ /dev/null
@@ -1,62 +0,0 @@
-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/linecablesystem/linecablesystem.jl b/src/datamodel/linecablesystem/linecablesystem.jl
new file mode 100644
index 000000000..59113cec6
--- /dev/null
+++ b/src/datamodel/linecablesystem/linecablesystem.jl
@@ -0,0 +1,459 @@
+"""
+$(TYPEDEF)
+
+Store completed cable placements and their global terminal state.
+
+`designs`, `input_positions`, `connections`, and `environment` are declarations.
+`positions` contains the resolved poses after automatic exterior-clearance
+adjustment. Touching cables are separated by at least 1 μm plus the propagated
+uncertainty reserve. Nominal designs overlapping beyond the roundoff allowance are rejected.
+Global geometry, terminal order, terminal indices, and the flattened
+connection order are derived by the constructor.
+
+$(TYPEDFIELDS)
+"""
+struct LineCableSystem{
+ T <: Real,
+ D <: AbstractVector,
+ P <: AbstractVector{<:Pose2{T}},
+ C <: AbstractVector,
+ E,
+ G <: AbstractVector
+}
+ "Stable system identifier."
+ system_id::String
+ "Physical line length [m]."
+ line_length::T
+ "Completed cable designs in placement order."
+ designs::D
+ "Cable poses in the system frame."
+ positions::P
+ "Cable poses before automatic clearance adjustment \\[m, m, rad\\]."
+ input_positions::P
+ "Retained pairwise clearance requirements. Diagonal entries concern the interface \\[m\\]."
+ clearances::Matrix{T}
+ "Per-cable terminal connection declarations in terminal order."
+ connections::C
+ "Optional physical environment declaration."
+ environment::E
+ "Resolved cable regions placed in the system frame."
+ geometry::G
+ "Global `(cable, terminal)` records in deterministic order."
+ terminal_order::Vector{NamedTuple{(:cable, :terminal), Tuple{Int, Symbol}}}
+ "Global terminal index for every resolved system region."
+ terminal_map::Vector{Int}
+ "Active phase IDs aligned with `terminal_order`. Zero selects a conductor for elimination."
+ connection_order::Vector{Int}
+
+ function LineCableSystem{T, D, P, C, E, G}(
+ system_id::String,
+ line_length::T,
+ designs::D,
+ positions::P,
+ connections::C,
+ environment::E,
+ geometry::G,
+ terminal_order::Vector{
+ NamedTuple{(:cable, :terminal), Tuple{Int, Symbol}}
+ },
+ terminal_map::Vector{Int},
+ connection_order::Vector{Int},
+ input_positions::P,
+ clearances::Matrix{T}
+ ) where {
+ T <: Real,
+ D <: AbstractVector,
+ P <: AbstractVector{<:Pose2{T}},
+ C <: AbstractVector,
+ E,
+ G <: AbstractVector
+ }
+ return validate(new{T, D, P, C, E, G}(
+ system_id,
+ line_length,
+ designs,
+ positions,
+ input_positions,
+ clearances,
+ connections,
+ environment,
+ geometry,
+ terminal_order,
+ terminal_map,
+ connection_order
+ ))
+ end
+end
+
+line_length(system::LineCableSystem) = system.line_length
+
+"""
+$(TYPEDSIGNATURES)
+
+Return a system whose exterior circles satisfy their retained clearance from
+the air-earth interface. Reuse the input when no adjustment is necessary.
+This construction step is called when a line problem supplies the earth model.
+"""
+function interface_clearance(system::LineCableSystem)
+ reference = system.input_positions
+ context = _CLEARANCE_CONTEXT[]
+ sampling = context !== nothing && context.sampling[]
+ reference_centers = sampling ? get(context.references, system.clearances, nothing) :
+ nothing
+ positions, _, displacement = clearance_geometry(system.designs, system.positions;
+ required = system.clearances, reference, reference_centers, interface = true, sampling)
+ iszero(displacement) && return system
+ result = build(LineCableSystem, system.designs, positions, system.connections,
+ system.environment, system.system_id, system.line_length;
+ _clearances = system.clearances, _input_positions = system.input_positions,
+ _interface = true, _rebuild = true)
+ sampling && reference_centers !== nothing &&
+ (context.references[result.clearances] = reference_centers)
+ _record_clearance_adjustment(system.system_id, displacement)
+ return result
+end
+
+Base.eltype(::LineCableSystem{T}) where {T} = T
+Base.eltype(::Type{<:LineCableSystem{T}}) where {T} = T
+
+function realize(rng::Random.AbstractRNG, point::Gridpoint{LineCableSystem}, distribution)
+ return realize_clearance(rng, point, distribution)
+end
+
+ncables(system::LineCableSystem) = length(system.designs)
+nphases(system::LineCableSystem) = length(unique(filter(>(0), system.connection_order)))
+
+function validate(system::LineCableSystem)
+ isempty(system.system_id) && throw(ArgumentError(
+ "LineCableSystem.system_id cannot be empty"
+ ))
+ isfinite(system.line_length) && system.line_length > zero(system.line_length) ||
+ throw(DomainError(
+ system.line_length,
+ "LineCableSystem.line_length must be positive and finite"
+ ))
+ isempty(system.designs) && throw(ArgumentError(
+ "LineCableSystem.designs must contain at least one CableDesign"
+ ))
+ length(system.positions) == length(system.designs) || throw(DimensionMismatch(
+ "LineCableSystem.positions must contain one Pose2 per design; received " *
+ "$(length(system.positions)) positions for $(length(system.designs)) designs"
+ ))
+ length(system.input_positions) == length(system.designs) || throw(DimensionMismatch(
+ "LineCableSystem.input_positions must contain one pose per cable"))
+ length(system.connections) == length(system.designs) || throw(DimensionMismatch(
+ "LineCableSystem.connections must contain one declaration per design; " *
+ "received $(length(system.connections)) for $(length(system.designs)) designs"
+ ))
+ expected_terminals = sum(length(design.terminal_order) for design in system.designs)
+ length(system.terminal_order) == expected_terminals || throw(DimensionMismatch(
+ "LineCableSystem.terminal_order must contain $expected_terminals entries; " *
+ "received $(length(system.terminal_order))"
+ ))
+ length(system.connection_order) == expected_terminals || throw(DimensionMismatch(
+ "LineCableSystem.connection_order must contain $expected_terminals entries; " *
+ "received $(length(system.connection_order))"
+ ))
+ expected_regions = sum(length(design.geometry.regions) for design in system.designs)
+ length(system.geometry) == expected_regions || throw(DimensionMismatch(
+ "LineCableSystem.geometry must contain $expected_regions resolved regions; " *
+ "received $(length(system.geometry))"
+ ))
+ length(system.terminal_map) == expected_regions || throw(DimensionMismatch(
+ "LineCableSystem.terminal_map must contain $expected_regions entries; " *
+ "received $(length(system.terminal_map))"
+ ))
+
+ terminal_index = 0
+ region_index = 0
+ terminal_offset = 0
+ for (cable_index, design) in pairs(system.designs)
+ validate(design)
+ pose = system.positions[cable_index]
+ all(isfinite, (pose.x, pose.y, pose.φ)) || throw(DomainError(
+ (pose.x, pose.y, pose.φ),
+ "LineCableSystem.positions[$cable_index] must contain finite coordinates"
+ ))
+ connection = system.connections[cable_index]
+ connection isa AbstractVector{<:Integer} || throw(ArgumentError(
+ "LineCableSystem.connections[$cable_index] must be an integer vector; " *
+ "received $(typeof(connection))"
+ ))
+ length(connection) == length(design.terminal_order) ||
+ throw(DimensionMismatch(
+ "LineCableSystem.connections[$cable_index] must contain " *
+ "$(length(design.terminal_order)) terminal assignments; received " *
+ "$(length(connection))"
+ ))
+ all(>=(0), connection) || throw(DomainError(
+ connection,
+ "LineCableSystem.connections[$cable_index] must contain nonnegative " *
+ "active phase IDs or zero"
+ ))
+ for (local_terminal, name) in pairs(design.terminal_order)
+ terminal_index += 1
+ expected = (cable = cable_index, terminal = name)
+ system.terminal_order[terminal_index] == expected ||
+ throw(DimensionMismatch(
+ "LineCableSystem.terminal_order[$terminal_index] must be " *
+ "$(repr(expected)); received " *
+ "$(repr(system.terminal_order[terminal_index]))"
+ ))
+ system.connection_order[terminal_index] == connection[local_terminal] ||
+ throw(DimensionMismatch(
+ "LineCableSystem.connection_order[$terminal_index] must match " *
+ "connections[$cable_index][$local_terminal]"
+ ))
+ end
+ for (local_region, placed) in pairs(design.geometry.regions)
+ region_index += 1
+ resolved = system.geometry[region_index]
+ resolved.source == placed.source || throw(DimensionMismatch(
+ "LineCableSystem.geometry[$region_index].source does not match " *
+ "designs[$cable_index].geometry.regions[$local_region].source"
+ ))
+ resolved.terminal === placed.terminal || throw(DimensionMismatch(
+ "LineCableSystem.geometry[$region_index].terminal does not match " *
+ "designs[$cable_index].geometry.regions[$local_region].terminal"
+ ))
+ local_index = design.terminal_map[local_region]
+ expected = iszero(local_index) ? 0 : terminal_offset + local_index
+ system.terminal_map[region_index] == expected || throw(DimensionMismatch(
+ "LineCableSystem.terminal_map[$region_index] must be $expected; " *
+ "received $(system.terminal_map[region_index])"
+ ))
+ end
+ terminal_offset += length(design.terminal_order)
+ end
+
+ clearance_geometry(system.designs, system.positions;
+ required = system.clearances, interface = system.environment !== nothing,
+ adjust = false)
+ return system
+end
+
+function build(
+ ::Type{LineCableSystem},
+ designs,
+ placements,
+ connections,
+ environment,
+ system_id::AbstractString,
+ line_length::Real;
+ combine::Symbol = :product,
+ _clearances = nothing,
+ _input_positions = nothing,
+ _interface::Bool = environment !== nothing,
+ _rebuild::Bool = false
+)
+ combine in (:product, :zip) || throw(ArgumentError(
+ "combine must be :product or :zip"
+ ))
+ declared_input = if designs isa CableDesign
+ typeof(designs)[designs]
+ elseif designs isa AbstractVector || designs isa Tuple
+ all(design -> design isa CableDesign, designs) || throw(ArgumentError(
+ "designs must contain completed CableDesign objects"
+ ))
+ collect(designs)
+ else
+ throw(ArgumentError("designs must be a CableDesign or a design collection"))
+ end
+
+ # 1. Place every completed design in the system frame. Coordinate tuples
+ # are construction shorthand. The stored declaration is always Pose2.
+ isempty(declared_input) && throw(ArgumentError("a line system requires one cable"))
+ identifier = String(system_id)
+ isempty(identifier) && throw(ArgumentError("system_id cannot be empty"))
+ isfinite(line_length) && line_length > zero(line_length) || throw(DomainError(
+ line_length,
+ "line length must be positive"
+ ))
+ position_values = if placements isa Pose2
+ Pose2[placements]
+ elseif placements isa Tuple && length(placements) in (2, 3) &&
+ all(value -> value isa Real, placements)
+ Pose2[Pose2(
+ placements[1],
+ placements[2],
+ length(placements) == 3 ? placements[3] : 0
+ )]
+ elseif placements isa AbstractVector || placements isa Tuple
+ Pose2[position isa Pose2 ? position :
+ position isa Tuple && length(position) in (2, 3) &&
+ all(value -> value isa Real, position) ?
+ Pose2(
+ position[1],
+ position[2],
+ length(position) == 3 ? position[3] : 0
+ ) :
+ throw(ArgumentError(
+ "placements must contain Pose2 values or `(x, y[, φ])` tuples"
+ ))
+ for position in placements]
+ else
+ throw(ArgumentError(
+ "placements must be a Pose2, coordinate tuple, or placement collection"
+ ))
+ end
+ declared_designs = if length(declared_input) == 1 && length(position_values) > 1
+ fill(only(declared_input), length(position_values))
+ else
+ declared_input
+ end
+ length(position_values) == length(declared_designs) || throw(DimensionMismatch(
+ "placement count must match the cable-design count"
+ ))
+
+ T = promote_type(
+ typeof(float(line_length)),
+ (eltype(design) for design in declared_designs)...,
+ (eltype(position) for position in position_values)...
+ )
+ poses = Pose2{T}[convert(Pose2{T}, position) for position in position_values]
+ original_poses = _input_positions === nothing ? copy(poses) :
+ Pose2{T}[convert(Pose2{T}, position) for position in _input_positions]
+ context = _CLEARANCE_CONTEXT[]
+ sampling = context !== nothing && context.sampling[]
+ reference = _rebuild ? poses : original_poses
+ reference_centers = nothing
+ if context !== nothing && !_rebuild && sampling
+ context.cursor[] += 1
+ context.cursor[] <= length(context.records) || throw(ArgumentError(
+ "sampled construction produced more cable systems than its declaration"))
+ record = context.records[context.cursor[]]
+ record.system_id == identifier &&
+ record.cables == getproperty.(declared_designs, :cable_id) ||
+ throw(ArgumentError("sampled cable-system layout differs from its declaration"))
+ _clearances = record.clearances
+ reference = record.positions
+ reference_centers = record.centers
+ end
+ poses, clearances, displacement = clearance_geometry(declared_designs, poses;
+ required = _clearances, reference, reference_centers, interface = _interface, sampling)
+ if context !== nothing && !_rebuild && !sampling
+ push!(context.records,
+ (system_id = identifier,
+ cables = getproperty.(declared_designs, :cable_id),
+ clearances = nominal.(clearances), positions = original_poses,
+ centers = getproperty.(_clearance_exterior.(declared_designs, original_poses), :center)))
+ end
+
+ # 2. Establish global primitive and terminal order while retaining cable
+ # and local terminal order verbatim.
+ terminal_order = NamedTuple{(:cable, :terminal), Tuple{Int, Symbol}}[]
+ terminal_offsets = Int[]
+ offset = 0
+ for (cable_index, design) in enumerate(declared_designs)
+ push!(terminal_offsets, offset)
+ for terminal in design.terminal_order
+ push!(terminal_order, (cable = cable_index, terminal = terminal))
+ offset += 1
+ end
+ end
+
+ # 3. Resolve connection definitions independently for every design.
+ declarations = if connections === nothing
+ fill(nothing, length(declared_designs))
+ elseif length(declared_designs) == 1 &&
+ (connections isa AbstractDict || connections isa NamedTuple ||
+ connections isa AbstractVector{<:Integer})
+ Any[connections]
+ elseif connections isa AbstractVector || connections isa Tuple
+ length(connections) == length(declared_designs) || throw(DimensionMismatch(
+ "connection declaration count must match the cable-design count"
+ ))
+ collect(connections)
+ else
+ throw(ArgumentError(
+ "connections must be one declaration per cable design"
+ ))
+ end
+ normalized_connections = Vector{Int}[]
+ for (cable_index, (design, declaration)) in enumerate(zip(declared_designs, declarations))
+ names = design.terminal_order
+ values = if declaration === nothing
+ [index == 1 ? cable_index : 0 for index in eachindex(names)]
+ elseif declaration isa AbstractVector{<:Integer}
+ length(declaration) == length(names) || throw(DimensionMismatch(
+ "connection vector for cable $cable_index must match its terminal count"
+ ))
+ Int[declaration...]
+ elseif declaration isa NamedTuple
+ unknown = setdiff(collect(keys(declaration)), names)
+ isempty(unknown) || throw(KeyError(first(unknown)))
+ Int[Int(get(declaration, name, 0)) for name in names]
+ elseif declaration isa AbstractDict
+ normalized = Dict{Symbol, Int}()
+ for (name, value) in declaration
+ key = Symbol(name)
+ key in names || throw(KeyError(key))
+ value isa Integer || throw(ArgumentError(
+ "connection assignments must be integers"
+ ))
+ normalized[key] = Int(value)
+ end
+ Int[get(normalized, name, 0) for name in names]
+ else
+ throw(ArgumentError(
+ "each connection declaration must be a mapping or integer vector"
+ ))
+ end
+ all(>=(0), values) || throw(DomainError(
+ values,
+ "connection assignments must contain nonnegative active phase IDs or zero"
+ ))
+ push!(normalized_connections, values)
+ end
+ connection_order = collect(Iterators.flatten(normalized_connections))
+
+ # 4. Place the resolved cable geometry. An explicit environment owns any
+ # interface constraint. Formulation-specific media checks occur in the
+ # problem that consumes the system.
+ global_geometry = PlacedRegion[]
+ terminal_map = Int[]
+ for (cable_index, (design, pose)) in enumerate(zip(declared_designs, poses))
+ for (source, local_terminal) in zip(
+ design.geometry.regions,
+ design.terminal_map
+ )
+ push!(global_geometry, resolve(pose, source))
+ push!(
+ terminal_map,
+ iszero(local_terminal) ? 0 :
+ terminal_offsets[cable_index] + local_terminal
+ )
+ end
+ end
+ # 5. Freeze declarations and their completed ordering together.
+ system = LineCableSystem{
+ T,
+ typeof(declared_designs),
+ typeof(poses),
+ typeof(normalized_connections),
+ typeof(environment),
+ typeof(global_geometry)
+ }(
+ identifier,
+ convert(T, float(line_length)),
+ declared_designs,
+ poses,
+ normalized_connections,
+ environment,
+ global_geometry,
+ terminal_order,
+ terminal_map,
+ connection_order,
+ original_poses,
+ clearances
+ )
+ if sampling && !_rebuild
+ context.references[system.clearances] = reference_centers
+ end
+ _record_clearance_adjustment(identifier, displacement)
+ return system
+end
+
+Commons.input_fields(::Type{<:LineCableSystem}) = (line_length=(name="line length",unit="m"),
+ positions=(name="position",unit="m"),input_positions=(name="input position",unit="m"),
+ clearances=(name="clearance",unit="m"))
diff --git a/src/datamodel/macros.jl b/src/datamodel/macros.jl
deleted file mode 100644
index 6e90425c0..000000000
--- a/src/datamodel/macros.jl
+++ /dev/null
@@ -1,184 +0,0 @@
-"""
-$(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, (r_in=0.01, r_ex=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, (:r_in, :r_ex, :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 = (:r_in, :r_ex, :material_props)
-const _OPT_TUBULAR = (:temperature,)
-const _DEFS_TUBULAR = (T₀,)
-
-@construct Tubular _REQ_TUBULAR _OPT_TUBULAR _DEFS_TUBULAR
-
-# Expands roughly to:
-# function Tubular(r_in, r_ex, material_props; temperature=T₀)
-# ntv = validate!(Tubular, r_in, r_ex, material_props; temperature=temperature)
-# Tp = _promotion_T(Tubular, ntv, (:r_in, :r_ex, :material_props, :temperature))
-# args = _coerced_args(Tubular, ntv, Tp, (:r_in, :r_ex, :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
deleted file mode 100644
index 4e75ece08..000000000
--- a/src/datamodel/nominaldata.jl
+++ /dev/null
@@ -1,96 +0,0 @@
-
-"""
-$(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
deleted file mode 100644
index 035a933e6..000000000
--- a/src/datamodel/nominaldata/base.jl
+++ /dev/null
@@ -1,4 +0,0 @@
-
-# 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/placement/bounded.jl b/src/datamodel/placement/bounded.jl
new file mode 100644
index 000000000..6b9a94bc2
--- /dev/null
+++ b/src/datamodel/placement/bounded.jl
@@ -0,0 +1,853 @@
+"""
+$(TYPEDEF)
+
+Record the formation boundary and radial course of one resolved member.
+
+The geometric boundary identifies the complete formation while `course` records the
+inferred radial course. Course zero identifies the center strand of a circular
+bundle or of one sector segment.
+
+$(TYPEDFIELDS)
+"""
+struct BoundedPlacement{B <: AbstractShape}
+ "Resolved geometric boundary shared by every member of the formation."
+ boundary::B
+ "Inferred radial course. Zero denotes a center member."
+ course::Int
+
+ function BoundedPlacement(boundary::B, course::Integer) where {
+ B <: AbstractShape
+ }
+ course >= 0 || throw(DomainError(
+ course, "bounded-placement course must be nonnegative"
+ ))
+ return new{B}(boundary, Int(course))
+ end
+end
+
+function resolve(at::Pose2, region::PlacedRegion)
+ patterns = map(region.placement.patterns) do entry
+ pattern = entry.pattern isa BoundedPlacement ?
+ BoundedPlacement(
+ resolve(at, entry.pattern.boundary), entry.pattern.course
+ ) : entry.pattern
+ merge(entry, (pattern = pattern,))
+ end
+ return PlacedRegion(
+ region.source,
+ resolve(at, region.primitive),
+ region.terminal,
+ (patterns = patterns,),
+ region.paths
+ )
+end
+
+"""
+$(TYPEDEF)
+
+Store the exact annular deformation of one rectangular strip.
+
+Resolve a [`Rectangle`](@ref) onto a circular course, preserving its area and
+bending its radial faces to follow that course.
+
+For angular coverage ``\\Delta\\phi`` and radial limits ``r_i`` and ``r_o``,
+
+```math
+A = \\frac{\\Delta\\phi}{2}\\left(r_o^2-r_i^2\\right) = w h,
+```
+
+where ``w h`` is the area of the source rectangle.
+
+$(TYPEDFIELDS)
+"""
+struct BentStrip{T <: Real, P <: Pose2{T}} <: AbstractShape{T}
+ "Inner course radius \\[m\\]."
+ ri::T
+ "Outer course radius \\[m\\]."
+ ro::T
+ "Angular coverage \\[rad\\]."
+ span::T
+ "Resolved pose of the strip symmetry axis."
+ at::P
+
+ function BentStrip{T, P}(ri::T, ro::T, span::T, at::P) where {
+ T <: Real, P <: Pose2{T}
+ }
+ isfinite(ri) && ri >= zero(ri) || throw(DomainError(
+ ri, "bent-strip inner radius must be nonnegative and finite"
+ ))
+ isfinite(ro) && ro > ri || throw(DomainError(
+ ro, "bent-strip outer radius must exceed its inner radius"
+ ))
+ isfinite(span) && zero(span) < span <= oftype(span, 2pi) ||
+ throw(DomainError(span, "bent-strip span must lie in (0, 2pi]"))
+ return new{T, P}(ri, ro, span, at)
+ end
+end
+
+function BentStrip(ri::Real, ro::Real, span::Real, at::Pose2 = Pose2(0, 0, 0))
+ values = map(float, promote(ri, ro, span, at.x, at.y, at.φ))
+ inner, outer, angle, x, y, rotation = values
+ pose = Pose2(x, y, rotation)
+ return BentStrip{typeof(inner), typeof(pose)}(inner, outer, angle, pose)
+end
+
+area(shape::BentStrip) = shape.span * (shape.ro^2 - shape.ri^2) / 2
+perimeter(shape::BentStrip) =
+ shape.span * (shape.ro + shape.ri) + 2(shape.ro - shape.ri)
+r_in(shape::BentStrip) = shape.ri
+r_ex(shape::BentStrip) = shape.ro
+thickness(shape::BentStrip) = shape.ro - shape.ri
+boundary(shape::BentStrip) = shape
+
+function centroid(shape::BentStrip)
+ radius = isapprox(shape.span, oftype(shape.span, 2pi)) ? zero(shape.span) :
+ 4sin(shape.span / 2) * (shape.ro^3 - shape.ri^3) /
+ (3shape.span * (shape.ro^2 - shape.ri^2))
+ return (
+ shape.at.x + radius * cos(shape.at.φ),
+ shape.at.y + radius * sin(shape.at.φ)
+ )
+end
+
+function support(shape::BentStrip, φ::Real)
+ direction = φ - shape.at.φ
+ half = shape.span / 2
+ endpoint_projections = (
+ shape.ro * cos(direction - half),
+ shape.ro * cos(direction + half),
+ shape.ri * cos(direction - half),
+ shape.ri * cos(direction + half)
+ )
+ period = oftype(direction, 2pi)
+ wrapped = mod(direction + pi, period) - pi
+ radial = abs(wrapped) <= half ? shape.ro : maximum(endpoint_projections)
+ return shape.at.x * cos(φ) + shape.at.y * sin(φ) + radial
+end
+
+support(shape::BentStrip) = hypot(shape.at.x, shape.at.y) + shape.ro
+
+function resolve(at::Pose2, shape::BentStrip)
+ return BentStrip(shape.ri, shape.ro, shape.span, at * shape.at)
+end
+
+function tessellate(shape::BentStrip; points_per_arc::Integer = 32)
+ points_per_arc >= 2 || throw(ArgumentError(
+ "points_per_arc must be at least two"
+ ))
+ outer = range(-shape.span / 2, shape.span / 2; length = Int(points_per_arc))
+ inner = reverse(outer)
+ local_points = [
+ (shape.ro * cos(angle), shape.ro * sin(angle)) for angle in outer
+ ]
+ append!(local_points,
+ [(shape.ri * cos(angle), shape.ri * sin(angle)) for angle in inner])
+ cosine = cos(shape.at.φ)
+ sine = sin(shape.at.φ)
+ return [
+ (
+ shape.at.x + cosine * point[1] - sine * point[2],
+ shape.at.y + sine * point[1] + cosine * point[2]
+ ) for point in local_points
+ ]
+end
+
+"""
+Return a counter-clockwise polygonal approximation of a resolved geometric boundary.
+"""
+function boundary_polygon(shape::Disk; points::Integer = 512)
+ points >= 16 || throw(ArgumentError(
+ "a disk boundary polygon requires at least 16 points"
+ ))
+ center = (shape.at.x, shape.at.y)
+ angles = range(zero(shape.r), oftype(shape.r, 2π); length = Int(points) + 1)
+ polygon = [(center[1] + shape.r * cos(angle), center[2] + shape.r * sin(angle))
+ for angle in Iterators.drop(angles, 1)]
+ return polygon
+end
+
+function boundary_polygon(shape::SectorShape; points::Integer = 64)
+ polygon = collect(tessellate(shape; points_per_arc = points))
+ length(polygon) > 1 &&
+ isapprox(first(polygon)[1], last(polygon)[1]) &&
+ isapprox(first(polygon)[2], last(polygon)[2]) && pop!(polygon)
+ return signed_polygon_area(polygon) < 0 ? reverse(polygon) : polygon
+end
+
+function signed_polygon_area(points)
+ return sum(eachindex(points); init = zero(first(points)[1])) do index
+ next = mod1(index + 1, length(points))
+ points[index][1] * points[next][2] - points[next][1] * points[index][2]
+ end / 2
+end
+
+function polygon_centroid(points)
+ signed_area = signed_polygon_area(points)
+ abs(signed_area) > eps(float(max(abs(signed_area), one(signed_area)))) ||
+ throw(ArgumentError("a compaction polygon must have positive area"))
+ xmoment = zero(signed_area)
+ ymoment = zero(signed_area)
+ for index in eachindex(points)
+ next = mod1(index + 1, length(points))
+ cross = points[index][1] * points[next][2] -
+ points[next][1] * points[index][2]
+ xmoment += (points[index][1] + points[next][1]) * cross
+ ymoment += (points[index][2] + points[next][2]) * cross
+ end
+ return (xmoment / (6signed_area), ymoment / (6signed_area))
+end
+
+function normalize_polygon_area(points, target_area::Real, center)
+ polygon = signed_polygon_area(points) < 0 ? reverse(points) : points
+ current_area = signed_polygon_area(polygon)
+ current_area > zero(current_area) || throw(ArgumentError(
+ "a bounded formation requires a counter-clockwise positive-area boundary"
+ ))
+ factor = sqrt(target_area / current_area)
+ return [
+ (
+ center[1] + factor * (point[1] - center[1]),
+ center[2] + factor * (point[2] - center[2])
+ ) for point in polygon
+ ]
+end
+
+function clip_halfplane(points, normal, offset)
+ isempty(points) && return copy(points)
+ T = promote_type(typeof(first(points)[1]), typeof(first(points)[2]),
+ typeof(normal[1]), typeof(normal[2]), typeof(offset))
+ return clip_halfplane!(Tuple{T,T}[], points, normal, offset)
+end
+
+function clip_halfplane!(output, points, normal, offset)
+ empty!(output)
+ isempty(points) && return output
+ scale = max(abs(offset), one(offset))
+ tolerance = 128eps(float(scale)) * scale
+ previous = last(points)
+ previous_value = normal[1] * previous[1] + normal[2] * previous[2] - offset
+ previous_inside = previous_value <= tolerance
+ for current in points
+ current_value = normal[1] * current[1] + normal[2] * current[2] - offset
+ current_inside = current_value <= tolerance
+ if current_inside != previous_inside
+ denominator = previous_value - current_value
+ fraction = iszero(denominator) ? zero(denominator) :
+ clamp(previous_value / denominator,
+ zero(denominator), one(denominator))
+ push!(output,
+ (
+ previous[1] + fraction * (current[1] - previous[1]),
+ previous[2] + fraction * (current[2] - previous[2])
+ ))
+ end
+ current_inside && push!(output, current)
+ previous = current
+ previous_value = current_value
+ previous_inside = current_inside
+ end
+ return output
+end
+
+function power_cell(boundary, sites, weights, index::Int)
+ cell = copy(boundary)
+ site = sites[index]
+ for neighbour in eachindex(sites)
+ neighbour == index && continue
+ other = sites[neighbour]
+ dx = other[1] - site[1]
+ dy = other[2] - site[2]
+ iszero(dx) && iszero(dy) && throw(ArgumentError(
+ "bounded compaction requires distinct initial sites"
+ ))
+ offset = other[1]^2 + other[2]^2 - site[1]^2 - site[2]^2 +
+ weights[index] - weights[neighbour]
+ cell = clip_halfplane(cell, (2dx, 2dy), offset)
+ isempty(cell) && break
+ end
+ return cell
+end
+
+power_cells(boundary, sites, weights) =
+ [power_cell(boundary, sites, weights, index) for index in eachindex(sites)]
+
+function shared_face_length(cell, left, right, left_weight, right_weight, scale)
+ dx = right[1] - left[1]
+ dy = right[2] - left[2]
+ distance = hypot(dx, dy)
+ iszero(distance) && return zero(distance)
+ offset = right[1]^2 + right[2]^2 - left[1]^2 - left[2]^2 +
+ left_weight - right_weight
+ tolerance = 2048eps(float(scale)) * max(scale, one(scale))
+ projections = typeof(float(scale))[]
+ for point in cell
+ residual = abs(2dx * point[1] + 2dy * point[2] - offset) / (2distance)
+ residual <= tolerance || continue
+ push!(projections, (-dy * point[1] + dx * point[2]) / distance)
+ end
+ length(projections) >= 2 || return zero(distance)
+ return maximum(projections) - minimum(projections)
+end
+
+function weight_hessian(cells, sites, weights, scale)
+ count = length(sites)
+ matrix = zeros(typeof(float(scale)), count, count)
+ for left in 1:(count - 1), right in (left + 1):count
+ distance = hypot(
+ sites[right][1] - sites[left][1],
+ sites[right][2] - sites[left][2]
+ )
+ face = shared_face_length(
+ cells[left], sites[left], sites[right],
+ weights[left], weights[right], scale
+ )
+ iszero(face) && continue
+ derivative = face / (2distance)
+ matrix[left, left] += derivative
+ matrix[right, right] += derivative
+ matrix[left, right] -= derivative
+ matrix[right, left] -= derivative
+ end
+ return matrix
+end
+
+function balance_power_cells(boundary, sites, targets; maxiter::Integer = 50,
+ rtol::Real = 1.0e-11)
+ count = length(sites)
+ count == length(targets) || throw(DimensionMismatch(
+ "power-cell sites and target areas must have equal lengths"
+ ))
+ count == 1 && return ([copy(boundary)], zeros(typeof(first(targets)), 1))
+ origin = polygon_centroid(boundary)
+ length_scale = sqrt(sum(targets))
+ boundary = [((point[1] - origin[1]) / length_scale,
+ (point[2] - origin[2]) / length_scale) for point in boundary]
+ sites = [((point[1] - origin[1]) / length_scale,
+ (point[2] - origin[2]) / length_scale) for point in sites]
+ targets = targets ./ length_scale^2
+ nominal_boundary = [nominal.(point) for point in boundary]
+ nominal_sites = [nominal.(point) for point in sites]
+ nominal_targets = nominal.(targets)
+ cells, weights = _balance_power_cells_nominal(
+ nominal_boundary, nominal_sites, nominal_targets; maxiter, rtol
+ )
+ uncertain = any(point -> any(x -> !iszero(uncertainty(x)), point), boundary) ||
+ any(point -> any(x -> !iszero(uncertainty(x)), point), sites) ||
+ any(x -> !iszero(uncertainty(x)), targets)
+ if uncertain
+ # The area constraint, not Newton's iteration history, defines the
+ # sensitivity. Fix the last weight to zero and use the true Jacobian.
+ scale = maximum(point -> hypot(point...), nominal_boundary)
+ hessian = weight_hessian(cells, nominal_sites, weights, scale)
+ fixed_cells = power_cells(boundary, sites, weights)
+ residual = targets .- signed_polygon_area.(fixed_cells)
+ correction = LinearAlgebra.lu(hessian[1:(end - 1), 1:(end - 1)]) \
+ (residual[1:(end - 1)] .- nominal.(residual[1:(end - 1)]))
+ weights = weights .+ [correction; zero(first(correction))]
+ cells = power_cells(boundary, sites, weights)
+ end
+ resolved = [[(origin[1] + length_scale * point[1],
+ origin[2] + length_scale * point[2]) for point in cell]
+ for cell in cells]
+ return (resolved, weights .* length_scale^2)
+end
+
+function _balance_power_cells_nominal(boundary, sites, targets;
+ maxiter::Integer = 50, rtol::Real = 1.0e-11)
+ count = length(sites)
+ scale = maximum(point -> hypot(point...), boundary)
+ weights = zeros(typeof(float(first(targets))), count)
+ best_error = oftype(first(targets), Inf)
+ for _ in 1:Int(maxiter)
+ cells = power_cells(boundary, sites, weights)
+ any(cell -> length(cell) < 3, cells) && throw(ArgumentError(
+ "bounded compaction produced an empty power cell"
+ ))
+ areas = signed_polygon_area.(cells)
+ residual = targets .- areas
+ relative_error = maximum(abs.(residual) ./ targets)
+ if relative_error <= rtol
+ return (cells, weights)
+ end
+ if relative_error < best_error
+ best_error = relative_error
+ end
+
+ hessian = weight_hessian(cells, sites, weights, scale)
+ reduced = hessian[1:(end - 1), 1:(end - 1)]
+ regularization = eps(float(scale^2)) / max(scale^2, eps(float(scale^2)))
+ for index in axes(reduced, 1)
+ reduced[index, index] += regularization
+ end
+ step = try
+ LinearAlgebra.qr(reduced) \ residual[1:(end - 1)]
+ catch
+ (scale^2 / sum(targets)) .* residual[1:(end - 1)]
+ end
+
+ accepted = false
+ damping = one(eltype(weights))
+ for _ in 1:10
+ candidate = copy(weights)
+ candidate[1:(end - 1)] .+= damping .* step
+ candidate .-= candidate[end]
+ candidate_cells = power_cells(boundary, sites, candidate)
+ if all(cell -> length(cell) >= 3, candidate_cells)
+ candidate_areas = signed_polygon_area.(candidate_cells)
+ candidate_error = maximum(abs.((targets .- candidate_areas) ./ targets))
+ if candidate_error < relative_error
+ weights = candidate
+ accepted = true
+ break
+ end
+ end
+ damping /= 2
+ end
+ if !accepted
+ weights .+= (scale^2 / sum(targets)) .* residual ./ 4
+ weights .-= weights[end]
+ end
+ end
+ throw(ArgumentError(
+ "bounded compaction did not converge; maximum relative cell-area error " *
+ "was $(best_error), target is $rtol"
+ ))
+end
+
+function compact_power_cells(boundary, sites, targets; relaxations::Integer = 1)
+ current_sites = copy(sites)
+ cells = Vector{eltype(boundary)}[]
+ for pass in 1:Int(relaxations)
+ cells, _ = balance_power_cells(boundary, current_sites, targets)
+ pass == relaxations && break
+ current_sites = polygon_centroid.(cells)
+ end
+ return cells
+end
+
+function course_count(available_area, strand_area)
+ ratio = nominal(available_area / strand_area)
+ capacity = ratio + 64eps(float(ratio))
+ capacity >= 6 || throw(DomainError(
+ available_area,
+ "a stranded formation requires enough area for its first six-wire course"
+ ))
+ courses = floor(Int, (sqrt(1 + 4ratio / 3) - 1) / 2)
+ # The inverse can round below an integer at exact full occupancy. Check
+ # the actual inventory in both directions, using the same area tolerance.
+ while 3(courses + 1) * (courses + 2) <= capacity
+ courses += 1
+ end
+ while 3courses * (courses + 1) > capacity
+ courses -= 1
+ end
+ return courses
+end
+
+function circular_courses(shape::Disk, center::Disk, wire::Disk, compact::Bool)
+ courses = if compact
+ course_count(area(shape) - area(center), area(wire))
+ else
+ ratio = nominal((shape.r - center.r) / (2wire.r))
+ floor(Int, ratio + 64eps(float(ratio)))
+ end
+ courses >= 1 || throw(DomainError(
+ wire.r, "the disk boundary admits no complete six-wire course"
+ ))
+ source_area = area(center) + 3courses * (courses + 1) * area(wire)
+ sites = NamedTuple[]
+ for course in 1:courses
+ count = 6course
+ radius = if compact
+ area_before = area(center) + 3(course - 1) * course * area(wire)
+ area_middle = area_before + 3course * area(wire)
+ shape.r * sqrt(area_middle / source_area)
+ else
+ center.r + (2course - 1) * wire.r
+ end
+ phase = isodd(course) ? zero(radius) : π / count
+ for member in 1:count
+ angle = phase + 2π * (member - 1) / count
+ push!(sites, (
+ site = (
+ shape.at.x + radius * cos(angle),
+ shape.at.y + radius * sin(angle)
+ ),
+ course,
+ member,
+ angle
+ ))
+ end
+ end
+ return sites
+end
+
+function rectangular_strands(shape::Disk, center::Disk, strand::Rectangle)
+ remaining_area = area(shape) - area(center)
+ remaining_area > zero(remaining_area) || throw(DomainError(
+ center.r, "the center wire leaves no area for rectangular strands"
+ ))
+ count = floor(Int, nominal(remaining_area / area(strand)) +
+ 64eps(float(nominal(remaining_area / area(strand)))))
+ count > 0 || throw(DomainError(
+ area(strand), "one rectangular strand does not fit outside the center wire"
+ ))
+ result = NamedTuple[]
+ inner = center.r
+ remaining = count
+ course = 0
+ while remaining > 0
+ course += 1
+ mean_radius = inner + strand.h / 2
+ nominal_count = max(1, floor(Int, nominal(2pi * mean_radius / strand.w)))
+ member_count = min(remaining, nominal_count)
+ outer = sqrt(inner^2 + member_count * area(strand) / pi)
+ if outer > shape.r
+ member_count = min(
+ remaining,
+ floor(Int, nominal(pi * (shape.r^2 - inner^2) / area(strand)))
+ )
+ member_count > 0 || break
+ outer = sqrt(inner^2 + member_count * area(strand) / pi)
+ end
+ span = 2pi / member_count
+ for member in 1:member_count
+ angle = (member - 1) * span
+ pose = Pose2(shape.at.x, shape.at.y, shape.at.φ + angle)
+ primitive = member_count == 1 ? Annulus(inner, outer, pose) :
+ BentStrip(inner, outer, span, pose)
+ push!(result, (
+ primitive,
+ site = centroid(primitive),
+ course,
+ member
+ ))
+ end
+ inner = outer
+ remaining -= member_count
+ end
+ return result
+end
+
+function clearance(shape::SectorShape, center)
+ cosine = cos(shape.at.φ)
+ sine = sin(shape.at.φ)
+ dx = center[1] - shape.at.x
+ dy = center[2] - shape.at.y
+ point = (cosine * dx + sine * dy, -sine * dx + cosine * dy)
+ distance = oftype(shape.primitive.r_back, Inf)
+
+ for segment in values(shape.contacts.segments)
+ first_point, last_point = segment
+ edge = (
+ last_point[1] - first_point[1],
+ last_point[2] - first_point[2]
+ )
+ edge_length = hypot(edge...)
+ outward = (edge[2] / edge_length, -edge[1] / edge_length)
+ clearance =
+ first_point[1] * outward[1] + first_point[2] * outward[2] -
+ point[1] * outward[1] - point[2] * outward[2]
+ distance = min(distance, clearance)
+ end
+
+ for arc in values(shape.contacts.arcs)
+ iszero(arc.radius) && continue
+ offset = (point[1] - arc.center[1], point[2] - arc.center[2])
+ direction = atan(offset[2], offset[1])
+ projection = if _angle_in_arc(direction, arc)
+ hypot(offset...)
+ else
+ max(
+ offset[1] * cos(arc.start) + offset[2] * sin(arc.start),
+ offset[1] * cos(arc.stop) + offset[2] * sin(arc.stop)
+ )
+ end
+ distance = min(distance, arc.radius - projection)
+ end
+ return distance
+end
+
+function accommodates(shape::SectorShape, center, radius::Real)
+ tolerance = 256 * geometry_tolerance(
+ max(shape.primitive.r_back, radius)
+ )
+ return _geometry_scalar(clearance(shape, center) + tolerance - radius) >= 0
+end
+
+function sector_polygon(shape::SectorShape; points::Integer = 17)
+ polygon = boundary_polygon(shape; points)
+ cosine = cos(shape.at.φ)
+ sine = sin(shape.at.φ)
+ first_index = findmin(polygon) do point
+ dx = point[1] - shape.at.x
+ dy = point[2] - shape.at.y
+ cosine * dx + sine * dy
+ end[2]
+ return [polygon[mod1(first_index + index - 1, length(polygon))]
+ for index in eachindex(polygon)]
+end
+
+function fan_measure(polygon, center)
+ areas = map(eachindex(polygon)) do index
+ next = mod1(index + 1, length(polygon))
+ first_point = polygon[index]
+ last_point = polygon[next]
+ abs(
+ (first_point[1] - center[1]) * (last_point[2] - center[2]) -
+ (last_point[1] - center[1]) * (first_point[2] - center[2])
+ ) / 2
+ end
+ total = sum(areas)
+ total > zero(total) || throw(ArgumentError(
+ "a sector course requires a positive-area boundary"
+ ))
+ return [zero(total); cumsum(areas) ./ total]
+end
+
+function fan_point(polygon, measure, fraction)
+ wrapped = mod(fraction, one(fraction))
+ index = min(searchsortedlast(measure, wrapped), length(polygon))
+ width = measure[index + 1] - measure[index]
+ local_fraction = iszero(width) ? zero(width) :
+ (wrapped - measure[index]) / width
+ next = mod1(index + 1, length(polygon))
+ return (
+ polygon[index][1] + local_fraction *
+ (polygon[next][1] - polygon[index][1]),
+ polygon[index][2] + local_fraction *
+ (polygon[next][2] - polygon[index][2])
+ )
+end
+"""
+$(TYPEDSIGNATURES)
+
+Map one center strand and complete circular `6k` courses into a sector.
+The mapped sites retain their course identities while a prescribed-area power
+diagram allocates space. Clipped disks supply the actual conductor shapes.
+
+For `L` courses and `N = 1 + 3L(L+1)` wire strands, the course sites are
+
+```math
+p_0=c,\\qquad
+p_{k,m}=c+\\sqrt{\\frac{1+3k^2}{N}}\\,[q(t_{k,m})-c],
+\\qquad t_{k,m}=\\frac{m+\\delta_k}{6k}.
+```
+
+Here `c` is the polygonal sector centroid and `q` traverses the geometric boundary
+by normalized swept area. Sites remain fixed during power-cell balancing.
+
+# Arguments
+
+- `shape`: resolved sector boundary, with coordinates in meters.
+- `wire`: circular source strand. Its area is preserved in every member.
+
+# Returns
+
+- Mapped members, resolved conductor polygons, and the number of outer courses.
+"""
+function sector_courses(shape::SectorShape, wire::Disk)
+ source_area = area(wire)
+ courses = course_count(area(shape) - source_area, source_area)
+ total_count = 1 + 3courses * (courses + 1)
+ points = 17
+ polygon = sector_polygon(shape; points)
+ while total_count * nominal(source_area) >
+ signed_polygon_area([nominal.(point) for point in polygon]) * (1 + 2.0e-6)
+ points = 2points - 1
+ points <= 16_385 || throw(ArgumentError(
+ "sector course tessellation could not preserve its source inventory"
+ ))
+ polygon = sector_polygon(shape; points)
+ end
+ center = polygon_centroid(polygon)
+ measure = fan_measure(polygon, center)
+ members = [(site = center, course = 0, member = 1, angle = zero(shape.at.φ))]
+ for course in 1:courses
+ count = 6course
+ scale = sqrt((1 + 3course^2) / total_count)
+ phase = isodd(course) ? 0.0 : 0.5
+ for member in 1:count
+ point = fan_point(polygon, measure, (member - 1 + phase) / count)
+ site = (
+ center[1] + scale * (point[1] - center[1]),
+ center[2] + scale * (point[2] - center[2])
+ )
+ push!(members, (
+ site,
+ course,
+ member,
+ angle = atan(site[2] - center[2], site[1] - center[1])
+ ))
+ end
+ end
+ targets = fill(signed_polygon_area(polygon) / total_count, total_count)
+ cells, _ = balance_power_cells(polygon, getproperty.(members, :site), targets)
+ primitives = [
+ resolved_polygon(
+ area_preserving_strand(cell, source_area; angle = shape.at.φ),
+ zero(shape.at.φ)
+ ) for cell in cells
+ ]
+ return members, primitives, courses
+end
+
+function clipped_disk!(points, clipped, cell, radius, directions)
+ empty!(points)
+ for point in directions
+ push!(points, (radius * point[1], radius * point[2]))
+ end
+ for index in eachindex(cell)
+ next = mod1(index + 1, length(cell))
+ first_point, last_point = cell[index], cell[next]
+ normal = (last_point[2] - first_point[2], first_point[1] - last_point[1])
+ offset = normal[1] * first_point[1] + normal[2] * first_point[2]
+ clip_halfplane!(clipped, points, normal, offset)
+ points, clipped = clipped, points
+ end
+ return points
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Grow a disk about a convex cell's centroid until its intersection with the cell
+has the prescribed strand area:
+
+```math
+W_i=P_i\\cap D(c_i,\\rho_i),\\qquad |W_i|=A_i,
+\\qquad c_i=\\operatorname{centroid}(P_i).
+```
+
+The disk is approximated by a regular polygon. Bisection solves its radius
+against the area of the clipped polygon, so the area tolerance is independent
+of the arc tessellation. Cell faces bound contact regions. Free geometric boundaries
+follow the disk. Full occupancy returns the cell within the geometric boundary's
+geometric tolerance.
+
+# Arguments
+
+- `cell`: counter-clockwise vertices of an allocated convex cell \\[m\\].
+- `source_area`: prescribed transverse conductor area \\[m²\\].
+
+# Keywords
+
+- `angle=0`: orientation of the disk tessellation \\[rad\\].
+- `points=128`: vertices in the disk approximation.
+
+# Returns
+
+- Vertices of the strand polygon, contained in its cell to numerical tolerance.
+"""
+function area_preserving_strand(cell, source_area; angle = 0, points::Integer = 128)
+ points >= 16 || throw(ArgumentError("a clipped disk requires at least 16 vertices"))
+ target = nominal(source_area)
+ cell_area = nominal(signed_polygon_area(cell))
+ target > 0 || throw(DomainError(source_area, "strand area must be positive"))
+ target <= cell_area * (1 + 4.0e-6) || throw(DomainError(
+ source_area, "a source strand does not fit inside its allocated cell"
+ ))
+ center = polygon_centroid(cell)
+ if target >= cell_area * (1 - 64eps(float(cell_area)))
+ return normalize_polygon_area(cell, source_area, center)
+ end
+
+ # Solve at unit area to avoid coordinate-scale tolerances and cancellation.
+ scale = sqrt(source_area)
+ local_cell = [((point[1] - center[1]) / scale,
+ (point[2] - center[2]) / scale) for point in cell]
+ directions = [(cos(angle + 2pi * index / points),
+ sin(angle + 2pi * index / points)) for index in 0:(points - 1)]
+ lower = inv(sqrt(pi))
+ nominal_cell = [nominal.(point) for point in local_cell]
+ nominal_directions = [nominal.(point) for point in directions]
+ upper = maximum(point -> hypot(point...), nominal_cell) / cos(pi / points)
+ radius = lower
+ buffer = similar(nominal_directions, 0)
+ clipped = similar(buffer)
+ sizehint!(buffer, points + length(cell))
+ sizehint!(clipped, points + length(cell))
+ strand = buffer
+ for _ in 1:64
+ radius = (lower + upper) / 2
+ strand = clipped_disk!(buffer, clipped, nominal_cell, radius, nominal_directions)
+ value = signed_polygon_area(strand)
+ abs(value - 1) <= 64eps(float(value)) && break
+ value < 1 ? (lower = radius) : (upper = radius)
+ end
+
+ # Differentiate the scalar area constraint for uncertainty-bearing inputs.
+ # The nominal cell topology is fixed, as in the existing compaction model.
+ if any(point -> any(x -> !iszero(uncertainty(x)), point), local_cell) ||
+ !iszero(uncertainty(angle))
+ step = cbrt(eps(float(radius))) * radius
+ area_plus = signed_polygon_area(
+ clipped_disk!(buffer, clipped, nominal_cell, radius + step, nominal_directions)
+ )
+ area_minus = signed_polygon_area(
+ clipped_disk!(buffer, clipped, nominal_cell, radius - step, nominal_directions)
+ )
+ derivative = (area_plus - area_minus) / (2step)
+ isfinite(derivative) && derivative > 0 || throw(DomainError(
+ derivative, "clipped-strand area has no positive local radius derivative"
+ ))
+ T = promote_type(typeof(first(local_cell)[1]), typeof(first(local_cell)[2]),
+ typeof(angle), typeof(radius))
+ buffer_t, clipped_t = Tuple{T,T}[], Tuple{T,T}[]
+ fixed = clipped_disk!(buffer_t, clipped_t, local_cell, radius, directions)
+ residual = 1 - signed_polygon_area(fixed)
+ radius += (residual - nominal(residual)) / derivative
+ strand = clipped_disk!(buffer_t, clipped_t, local_cell, radius, directions)
+ end
+ return [(center[1] + scale * point[1], center[2] + scale * point[2])
+ for point in strand]
+end
+
+function resolved_polygon(points, angle)
+ center = polygon_centroid(points)
+ cosine = cos(angle)
+ sine = sin(angle)
+ local_points = map(points) do point
+ dx = point[1] - center[1]
+ dy = point[2] - center[2]
+ return (cosine * dx + sine * dy, -sine * dx + cosine * dy)
+ end
+ return _polygon(local_points, Pose2(center[1], center[2], angle))
+end
+
+function deform_disk_members(boundary_shape, members)
+ all(member -> member.source.primitive isa Disk, members) || throw(ArgumentError(
+ "boundary-constrained compaction currently requires circular source strands"
+ ))
+ source_areas = [area(member.source.primitive) for member in members]
+ total_source_area = sum(source_areas)
+ nominal_source_areas = nominal.(source_areas)
+ nominal_total_source_area = sum(nominal_source_areas)
+ nominal_boundary_area = nominal(area(boundary_shape))
+ nominal_total_source_area <= nominal_boundary_area * (1 + 2.0e-6) ||
+ throw(DomainError(
+ total_source_area,
+ "the compacted strand inventory exceeds its authoritative boundary area"
+ ))
+ points = boundary_shape isa Disk ? 256 : 64
+ polygon = boundary_polygon(boundary_shape; points)
+ while nominal_total_source_area > nominal(signed_polygon_area(polygon)) * (1 + 2.0e-6)
+ points *= 2
+ points <= 16_384 || throw(ArgumentError(
+ "bounded compaction could not resolve the declared fill factor " *
+ "within its geometric tolerance"
+ ))
+ polygon = boundary_polygon(boundary_shape; points)
+ end
+ targets = signed_polygon_area(polygon) .* source_areas ./ total_source_area
+ sites = [member.site for member in members]
+ cells = compact_power_cells(polygon, sites, targets)
+ return [
+ resolved_polygon(
+ area_preserving_strand(
+ cells[index], source_areas[index]; angle = boundary_shape.at.φ
+ ),
+ members[index].angle
+ ) for index in eachindex(members)
+ ]
+end
diff --git a/src/datamodel/placement/compaction.jl b/src/datamodel/placement/compaction.jl
new file mode 100644
index 000000000..b25a5116f
--- /dev/null
+++ b/src/datamodel/placement/compaction.jl
@@ -0,0 +1,105 @@
+"""
+$(TYPEDEF)
+
+Set the ratio of member material area to the resolved course-envelope area.
+
+For member areas ``A_i`` inside an envelope of area ``A_B``, the declared
+dimensionless factor is
+
+```math
+\\eta = \\frac{\\sum_i A_i}{A_B}.
+```
+
+$(TYPEDFIELDS)
+"""
+struct FillFactor{T <: Real}
+ "Material-area fraction \\[dimensionless\\]."
+ η::T
+ function FillFactor{T}(η::T) where {T <: Real}
+ isfinite(η) && zero(η) < η <= one(η) || throw(DomainError(
+ η, "fill factor must lie in (0, 1]"
+ ))
+ return new{T}(η)
+ end
+end
+
+_fill_factor(η) = FillFactor{typeof(float(η))}(float(η))
+
+"""
+$(TYPEDSIGNATURES)
+
+Declare one material-area fill factor or a homogeneous schedule of fill
+factors.
+
+# Arguments
+
+- `η`: one material-area fraction \\[dimensionless\\].
+- `values`: two or more factors, or one tuple or vector of factors. Specify one factor per repeated course.
+
+# Keywords
+
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A `FillFactor`, a tuple of `FillFactor` values, or the corresponding
+ `Gridspace` when an explicit finite source is supplied.
+"""
+function FillFactor(η; combine::Symbol = :product)
+ parameterize(FillFactor, _fill_factor, (η,); combine)
+end
+function FillFactor(values::Union{Tuple, AbstractVector}; combine::Symbol = :product)
+ _normalize_schedule(FillFactor, values; combine)
+end
+function FillFactor(first, second, remaining...; combine::Symbol = :product)
+ _normalize_schedule(FillFactor, (first, second, remaining...); combine)
+end
+
+function placements(
+ pattern::Ring,
+ item::Rectangle,
+ factor::FillFactor
+)
+ pattern.n isa Int || throw(ArgumentError(
+ "capacity() requires contextual placement"
+ ))
+ pattern.r === nothing && throw(ArgumentError(
+ "FillFactor requires contextual placement or a resolved inner radius"
+ ))
+ inner = max(zero(pattern.r), pattern.r - item.h / 2)
+ outer = sqrt(
+ inner^2 + 2pattern.n * area(item) / (pattern.span * factor.η)
+ )
+ angular_width = factor.η * pattern.span / pattern.n
+ definition = BentStrip(inner, outer, angular_width)
+ step = pattern.n == 1 ? zero(pattern.span) : pattern.span / pattern.n
+ return _ResolvedPlacement[
+ _ResolvedPlacement(
+ Pose2(0, 0, pattern.φ0 + index * step),
+ definition
+ ) for index in 0:(pattern.n - 1)
+ ]
+end
+
+function _fillfactor_capacity(
+ pattern::Ring,
+ member_area::Real,
+ radial_half_extent::Real,
+ factor::FillFactor
+)
+ radius = something(pattern.r, radial_half_extent)
+ radius > zero(radius) || return 0
+ factor.η <= inv(one(pattern.gap_frac) + pattern.gap_frac) || throw(DomainError(
+ (factor.η, pattern.gap_frac),
+ "fill factor cannot provide the requested member gap"
+ ))
+ inner = max(zero(radius), radius - radial_half_extent)
+ outer = radius + radial_half_extent
+ envelope_area = pattern.span * (outer^2 - inner^2) / 2
+ count = factor.η * envelope_area / member_area
+ return max(0, floor(Int, count + 8eps(float(count))))
+end
+
+function capacity(pattern::Ring, item::Rectangle, factor::FillFactor)
+ _fillfactor_capacity(pattern, item.w * item.h, item.h / 2, factor)
+end
diff --git a/src/datamodel/placement/paths.jl b/src/datamodel/placement/paths.jl
new file mode 100644
index 000000000..cc0b8241b
--- /dev/null
+++ b/src/datamodel/placement/paths.jl
@@ -0,0 +1,202 @@
+"""
+Store a helical lay-length to mean-diameter ratio.
+"""
+struct LayRatio{T <: Real}
+ q::T
+ function LayRatio{T}(q::T) where {T <: Real}
+ isfinite(q) && q > zero(q) ||
+ throw(DomainError(q, "lay ratio must be positive and finite"))
+ return new{T}(q)
+ end
+end
+
+_lay_ratio(q) = LayRatio{typeof(float(q))}(float(q))
+
+function _normalize_schedule(wrapper, values; combine::Symbol)
+ return map(Tuple(values)) do value
+ value isa wrapper ? value : wrapper(value; combine)
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Declare one lay-length ratio or a homogeneous schedule of lay-length ratios.
+
+# Arguments
+
+- `q`: one positive lay-length to mean-diameter ratio \\[dimensionless\\].
+- `values`: two or more ratios, or one tuple or vector of ratios. Specify one ratio per repeated course.
+
+# Keywords
+
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A `LayRatio`, a tuple of `LayRatio` values, or the corresponding `Gridspace`
+ when an explicit finite source is supplied.
+"""
+LayRatio(q; combine::Symbol = :product) = parameterize(LayRatio, _lay_ratio, (q,); combine)
+function LayRatio(values::Union{Tuple, AbstractVector}; combine::Symbol = :product)
+ _normalize_schedule(LayRatio, values; combine)
+end
+function LayRatio(first, second, remaining...; combine::Symbol = :product)
+ _normalize_schedule(LayRatio, (first, second, remaining...); combine)
+end
+
+"""
+Store an authoritative helical pitch length \\[m\\].
+"""
+struct Pitch{T <: Real}
+ p::T
+ function Pitch{T}(p::T) where {T <: Real}
+ isfinite(p) && p > zero(p) ||
+ throw(DomainError(p, "pitch must be positive and finite"))
+ return new{T}(p)
+ end
+end
+
+_pitch(p) = Pitch{typeof(float(p))}(float(p))
+
+"""
+$(TYPEDSIGNATURES)
+
+Declare one helical pitch or a homogeneous schedule of helical pitches.
+
+# Arguments
+
+- `p`: one positive helical pitch \\[m\\].
+- `values`: two or more pitches, or one tuple or vector of pitches. Specify one pitch per repeated course \\[m\\].
+
+# Keywords
+
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A `Pitch`, a tuple of `Pitch` values, or the corresponding `Gridspace` when
+ an explicit finite source is supplied.
+"""
+Pitch(p; combine::Symbol = :product) = parameterize(Pitch, _pitch, (p,); combine)
+function Pitch(values::Union{Tuple, AbstractVector}; combine::Symbol = :product)
+ _normalize_schedule(Pitch, values; combine)
+end
+function Pitch(first, second, remaining...; combine::Symbol = :product)
+ _normalize_schedule(Pitch, (first, second, remaining...); combine)
+end
+
+"""
+Store an authoritative helical lay angle relative to the cable axis \\[rad\\].
+"""
+struct LayAngle{T <: Real}
+ α::T
+ function LayAngle{T}(α::T) where {T <: Real}
+ isfinite(α) && zero(α) < α < oftype(α, π / 2) ||
+ throw(DomainError(α, "lay angle must lie in (0, π/2)"))
+ return new{T}(α)
+ end
+end
+
+_lay_angle(α) = LayAngle{typeof(float(α))}(float(α))
+
+"""
+$(TYPEDSIGNATURES)
+
+Declare one helical lay angle or a homogeneous schedule of lay angles.
+
+# Arguments
+
+- `α`: one lay angle relative to the cable axis \\[rad\\].
+- `values`: two or more angles, or one tuple or vector of angles. Specify one angle per repeated course \\[rad\\].
+
+# Keywords
+
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A `LayAngle`, a tuple of `LayAngle` values, or the corresponding `Gridspace`
+ when an explicit finite source is supplied.
+"""
+LayAngle(α; combine::Symbol = :product) = parameterize(LayAngle, _lay_angle, (α,); combine)
+function LayAngle(values::Union{Tuple, AbstractVector}; combine::Symbol = :product)
+ _normalize_schedule(LayAngle, values; combine)
+end
+function LayAngle(first, second, remaining...; combine::Symbol = :product)
+ _normalize_schedule(LayAngle, (first, second, remaining...); combine)
+end
+
+"""
+$(TYPEDEF)
+
+Represent one helical path from one authoritative lay definition.
+
+$(TYPEDFIELDS)
+"""
+struct Helix{L, T <: Real}
+ "Lay definition."
+ lay::L
+ "Handedness: `1` or `-1` \\[dimensionless\\]."
+ dir::Int
+ "Initial angular position \\[rad\\]."
+ φ0::T
+
+ function Helix{L, T}(lay::L, dir::Int, φ0::T) where {L, T <: Real}
+ lay isa Union{LayRatio, Pitch, LayAngle} ||
+ throw(ArgumentError("helix lay must be LayRatio, Pitch, or LayAngle"))
+ dir in (-1, 1) || throw(ArgumentError("helix direction must be -1 or 1"))
+ isfinite(φ0) || throw(DomainError(φ0, "helix initial angle must be finite"))
+ return new{L, T}(lay, dir, φ0)
+ end
+end
+
+function _helix(lay, dir, φ0)
+ dir isa Integer && !(dir isa Bool) || throw(ArgumentError(
+ "helix direction must be an integer"
+ ))
+ angle = float(φ0)
+ return Helix{typeof(lay), typeof(angle)}(lay, Int(dir), angle)
+end
+
+function Helix(
+ lay;
+ dir = 1,
+ φ0 = 0,
+ combine::Symbol = :product
+)
+ return parameterize(Helix, _helix, (lay, dir, φ0); combine)
+end
+
+"""
+Return helical pitch at local radius `radius` \\[m\\].
+"""
+function pitch end
+pitch(path::Helix{<:LayRatio}, radius::Real) = path.lay.q * (2 * radius)
+pitch(path::Helix{<:Pitch}, radius::Real) = path.lay.p
+pitch(path::Helix{<:LayAngle}, radius::Real) = 2π * radius / tan(path.lay.α)
+
+"""
+Return helical lay angle at local radius `radius` \\[rad\\].
+"""
+function angle(path::Helix, radius::Real)
+ radius >= zero(radius) || throw(DomainError(radius, "helix radius must be nonnegative"))
+ return atan(2π * radius, pitch(path, radius))
+end
+
+"""
+Return the helical path-length ratio at local radius `radius`.
+"""
+function overlength(path::Helix, radius::Real)
+ value = angle(path, radius)
+ return inv(cos(value))
+end
+
+function overlength(path::Helix{<:LayRatio}, radius::Real)
+ radius >= zero(radius) || throw(DomainError(
+ radius,
+ "helix radius must be nonnegative"
+ ))
+ ratio = path.lay.q
+ return sqrt(one(ratio) + ((one(ratio) * pi) / ratio)^2)
+end
diff --git a/src/datamodel/placement/patterns.jl b/src/datamodel/placement/patterns.jl
new file mode 100644
index 000000000..9264369eb
--- /dev/null
+++ b/src/datamodel/placement/patterns.jl
@@ -0,0 +1,580 @@
+"""
+$(TYPEDEF)
+
+Place `n` members on one circular locus.
+
+$(TYPEDFIELDS)
+"""
+struct _DeferredCardinality end
+
+"""
+Return the deferred maximum wire count used by placement patterns.
+"""
+capacity() = _DeferredCardinality()
+
+"""
+Return the maximum admissible member count for a placement declaration.
+"""
+function capacity end
+
+struct Ring{N, T <: Real, R}
+ "Number of members \\[dimensionless\\]."
+ n::N
+ "Radius of the member-center locus \\[m\\]."
+ r::R
+ "Starting angle \\[rad\\]."
+ φ0::T
+ "Counter-clockwise angular span \\[rad\\]."
+ span::T
+ "Fractional clearance between adjacent members \\[dimensionless\\]."
+ gap_frac::T
+
+ function Ring{N, T, R}(
+ n::N, r::R, φ0::T, span::T, gap_frac::T
+ ) where {N, T <: Real, R}
+ n isa _DeferredCardinality ||
+ (n isa Int && n > 0) ||
+ throw(ArgumentError("ring cardinality must be positive or capacity()"))
+ r === nothing ||
+ (r isa Real && isfinite(r) && r >= zero(r)) ||
+ throw(DomainError(r, "ring radius must be nonnegative, finite, or contextual"))
+ isfinite(φ0) || throw(DomainError(φ0, "ring start angle must be finite"))
+ isfinite(span) && zero(span) < span <= oftype(span, 2π) ||
+ throw(DomainError(span, "ring span must lie in (0, 2π]"))
+ isfinite(gap_frac) && gap_frac >= zero(gap_frac) || throw(DomainError(
+ gap_frac, "ring gap fraction must be nonnegative and finite"
+ ))
+ return new{N, T, R}(n, r, φ0, span, gap_frac)
+ end
+end
+
+"""
+Maximum circular-wire count on a ring with fractional adjacent clearance.
+"""
+function capacity(
+ ::Type{Ring},
+ lay_radius::Real,
+ item_radius::Real;
+ gap_frac::Real = 0
+)
+ lay_radius > zero(lay_radius) || throw(DomainError(
+ lay_radius, "lay radius must be positive"
+ ))
+ item_radius > zero(item_radius) || throw(DomainError(
+ item_radius, "item radius must be positive"
+ ))
+ gap_frac >= zero(gap_frac) || throw(DomainError(
+ gap_frac, "gap fraction must be nonnegative"
+ ))
+ ratio = item_radius * (one(gap_frac) + gap_frac) / lay_radius
+ zero(ratio) < ratio < one(ratio) || return 0
+ count = pi / asin(ratio)
+ return max(0, floor(Int, count + 8eps(float(count))))
+end
+
+function _ring(n, r, φ0, span, gap_frac)
+ n isa Union{Integer, _DeferredCardinality} || throw(ArgumentError(
+ "ring cardinality must be a positive integer or capacity()"
+ ))
+ numeric = r === nothing ? promote(φ0, span, gap_frac) :
+ promote(r, φ0, span, gap_frac)
+ values = map(float, numeric)
+ if r === nothing
+ angle, angular_span, gap = values
+ return Ring{typeof(n), typeof(angle), Nothing}(
+ n isa Integer ? Int(n) : n,
+ nothing,
+ angle,
+ angular_span,
+ gap
+ )
+ end
+ radius, angle, angular_span, gap = values
+ return Ring{typeof(n isa Integer ? Int(n) : n), typeof(radius), typeof(radius)}(
+ n isa Integer ? Int(n) : n,
+ radius,
+ angle,
+ angular_span,
+ gap
+ )
+end
+
+function Ring(
+ n;
+ r = nothing,
+ φ0 = 0,
+ span = 2π,
+ gap_frac = 0,
+ combine::Symbol = :product
+)
+ return parameterize(
+ Ring, _ring, (n, r, φ0, span, gap_frac); combine
+ )
+end
+
+"""
+$(TYPEDEF)
+
+Place members on `nr` concentric loci with `nφ` angular positions per locus.
+
+$(TYPEDFIELDS)
+"""
+struct Polar{T <: Real}
+ "Number of radial loci \\[dimensionless\\]."
+ nr::Int
+ "Angular positions per nonzero locus \\[dimensionless\\]."
+ nφ::Int
+ "First locus radius \\[m\\]."
+ r0::T
+ "Radial increment \\[m\\]."
+ dr::T
+ "Starting angle \\[rad\\]."
+ φ0::T
+ "Counter-clockwise angular span \\[rad\\]."
+ span::T
+
+ function Polar{T}(
+ nr::Int, nφ::Int, r0::T, dr::T, φ0::T, span::T
+ ) where {T <: Real}
+ nr > 0 || throw(ArgumentError("polar radial count must be positive"))
+ nφ > 0 || throw(ArgumentError("polar angular count must be positive"))
+ isfinite(r0) && r0 >= zero(r0) ||
+ throw(DomainError(r0, "polar initial radius must be nonnegative and finite"))
+ isfinite(dr) && dr >= zero(dr) ||
+ throw(DomainError(dr, "polar radial increment must be nonnegative and finite"))
+ isfinite(φ0) || throw(DomainError(φ0, "polar start angle must be finite"))
+ isfinite(span) && zero(span) < span <= oftype(span, 2π) ||
+ throw(DomainError(span, "polar span must lie in (0, 2π]"))
+ return new{T}(nr, nφ, r0, dr, φ0, span)
+ end
+end
+
+function _polar(nr, nφ, r0, dr, φ0, span)
+ nr isa Integer && !(nr isa Bool) || throw(ArgumentError(
+ "polar radial count must be an integer"
+ ))
+ nφ isa Integer && !(nφ isa Bool) || throw(ArgumentError(
+ "polar angular count must be an integer"
+ ))
+ values = map(float, promote(r0, dr, φ0, span))
+ return Polar{typeof(first(values))}(Int(nr), Int(nφ), values...)
+end
+
+function Polar(;
+ nr,
+ nφ,
+ r0 = 0,
+ dr,
+ φ0 = 0,
+ span = 2π,
+ combine::Symbol = :product
+)
+ return parameterize(Polar, _polar, (nr, nφ, r0, dr, φ0, span); combine)
+end
+
+"""
+$(TYPEDEF)
+
+Fill a circular domain with one central member and concentric tangent courses.
+
+$(TYPEDFIELDS)
+"""
+struct Fill{T <: Real}
+ "Outer radius available to the filled members \\[m\\]."
+ r::T
+ "Angular offset applied between consecutive courses \\[rad\\]."
+ φ::T
+ "Starting angle of the first outer course \\[rad\\]."
+ φ0::T
+ "Counter-clockwise angular span of each outer course \\[rad\\]."
+ span::T
+
+ function Fill{T}(r::T, φ::T, φ0::T, span::T) where {T <: Real}
+ isfinite(r) && r > zero(r) || throw(DomainError(
+ r, "fill radius must be positive and finite"
+ ))
+ isfinite(φ) || throw(DomainError(φ, "fill course offset must be finite"))
+ isfinite(φ0) || throw(DomainError(φ0, "fill start angle must be finite"))
+ isfinite(span) && zero(span) < span <= oftype(span, 2π) || throw(
+ DomainError(span, "fill span must lie in (0, 2π]")
+ )
+ return new{T}(r, φ, φ0, span)
+ end
+end
+
+function _fill(r, φ, φ0, span)
+ values = map(float, promote(r, φ, φ0, span))
+ return Fill{typeof(first(values))}(values...)
+end
+
+function Fill(;
+ r,
+ φ,
+ φ0 = 0,
+ span = 2π,
+ combine::Symbol = :product
+)
+ return parameterize(Fill, _fill, (r, φ, φ0, span); combine)
+end
+
+"""
+$(TYPEDEF)
+
+Place members on a centered rectangular lattice.
+
+$(TYPEDFIELDS)
+"""
+struct Lattice{T <: Real}
+ "Number of columns \\[dimensionless\\]."
+ nx::Int
+ "Number of rows \\[dimensionless\\]."
+ ny::Int
+ "Column spacing \\[m\\]."
+ dx::T
+ "Row spacing \\[m\\]."
+ dy::T
+
+ function Lattice{T}(nx::Int, ny::Int, dx::T, dy::T) where {T <: Real}
+ nx > 0 || throw(ArgumentError("lattice column count must be positive"))
+ ny > 0 || throw(ArgumentError("lattice row count must be positive"))
+ isfinite(dx) && (nx == 1 ? dx >= zero(dx) : dx > zero(dx)) ||
+ throw(DomainError(dx, "lattice column spacing is invalid"))
+ isfinite(dy) && (ny == 1 ? dy >= zero(dy) : dy > zero(dy)) ||
+ throw(DomainError(dy, "lattice row spacing is invalid"))
+ return new{T}(nx, ny, dx, dy)
+ end
+end
+
+function _lattice(nx, ny, dx, dy)
+ nx isa Integer && !(nx isa Bool) || throw(ArgumentError(
+ "lattice column count must be an integer"
+ ))
+ ny isa Integer && !(ny isa Bool) || throw(ArgumentError(
+ "lattice row count must be an integer"
+ ))
+ values = map(float, promote(dx, dy))
+ return Lattice{typeof(first(values))}(Int(nx), Int(ny), values...)
+end
+
+function Lattice(;
+ nx,
+ ny,
+ dx,
+ dy,
+ combine::Symbol = :product
+)
+ return parameterize(Lattice, _lattice, (nx, ny, dx, dy); combine)
+end
+
+"""
+Return local member poses prescribed by a placement pattern.
+"""
+function placements end
+
+struct _ResolvedPlacement{P <: Pose2, D <: AbstractShape}
+ pose::P
+ primitive::D
+end
+
+_placement_pose(value::Pose2) = value
+_placement_pose(value::_ResolvedPlacement) = value.pose
+_placement_definition(value::Pose2, definition::AbstractPrimitive) = definition
+_placement_definition(value::_ResolvedPlacement, ::AbstractPrimitive) = value.primitive
+
+function _ring_step(pattern::Ring, count::Int)
+ return count == 1 ? zero(pattern.span) :
+ isapprox(pattern.span, oftype(pattern.span, 2π)) ?
+ pattern.span / count : pattern.span / (count - 1)
+end
+
+function _ring_poses(pattern::Ring, count::Int, radius::Real)
+ step = _ring_step(pattern, count)
+ return Pose2[Pose2(
+ radius * cos(pattern.φ0 + index * step),
+ radius * sin(pattern.φ0 + index * step),
+ pattern.φ0 + index * step
+ )
+ for index in 0:(count - 1)]
+end
+
+placements(::Nothing, item, ::Nothing) = Pose2[Pose2(0, 0, 0)]
+
+# Ring members of this tangential width keep the requested gap along the ring.
+function validate(pattern::Ring, tangential_width::Real)
+ pattern.n == 1 && return pattern
+ interval_count = isapprox(pattern.span, oftype(pattern.span, 2π)) ?
+ pattern.n : pattern.n - 1
+ chord = 2pattern.r * sin(pattern.span / interval_count / 2)
+ required = tangential_width * (one(pattern.gap_frac) + pattern.gap_frac)
+ tolerance = sqrt(eps(float(chord))) * max(one(chord), chord)
+ chord + tolerance >= required || throw(DomainError(
+ pattern.n,
+ "ring members overlap or violate the requested gap: " *
+ "count=$(pattern.n), radius=$(nominal(pattern.r)) m, " *
+ "member width=$(nominal(tangential_width)) m, " *
+ "gap fraction=$(nominal(pattern.gap_frac)), " *
+ "required chord=$(nominal(required)) m, " *
+ "available chord=$(nominal(chord)) m, " *
+ "deficit=$(nominal(required - chord)) m"
+ ))
+ return pattern
+end
+
+function _tangential_width(definition::AbstractPrimitive)
+ primitive = resolve(EmptyBoundary(), definition)
+ return support(primitive, pi / 2) + support(primitive, -pi / 2)
+end
+
+function placements(
+ pattern::Ring,
+ item::AbstractPrimitive,
+ ::Nothing
+)
+ pattern.n isa Int || throw(ArgumentError(
+ "capacity() requires contextual placement"
+ ))
+ pattern.r === nothing && throw(ArgumentError(
+ "a contextual ring radius requires placement inside a physical tree"
+ ))
+ validate(pattern, _tangential_width(item))
+ return _ring_poses(pattern, pattern.n, pattern.r)
+end
+
+function placements(pattern::Ring, item::Disk, ::Nothing)
+ pattern.n isa Int || throw(ArgumentError(
+ "capacity() requires contextual placement"
+ ))
+ pattern.r === nothing && throw(ArgumentError(
+ "a contextual ring radius requires placement inside a physical tree"
+ ))
+ validate(pattern, 2item.r)
+ return _ring_poses(pattern, pattern.n, pattern.r)
+end
+
+function placements(pattern::Ring, item::Rectangle, ::Nothing)
+ pattern.n isa Int || throw(ArgumentError(
+ "capacity() requires contextual placement"
+ ))
+ pattern.r === nothing && throw(ArgumentError(
+ "a contextual ring radius requires placement inside a physical tree"
+ ))
+ occupied = pattern.n * item.w * (one(pattern.gap_frac) + pattern.gap_frac)
+ available = pattern.r * pattern.span
+ tolerance = sqrt(eps(float(available))) * max(one(available), available)
+ occupied <= available + tolerance || throw(DomainError(
+ pattern.n,
+ "rectangular members exceed the available angular span"
+ ))
+ inner = max(zero(pattern.r), pattern.r - item.h / 2)
+ outer = inner + item.h
+ angular_width = 2area(item) / (outer^2 - inner^2)
+ definition = BentStrip(inner, outer, angular_width)
+ step = pattern.n == 1 ? zero(pattern.span) : pattern.span / pattern.n
+ return _ResolvedPlacement[
+ _ResolvedPlacement(
+ Pose2(0, 0, pattern.φ0 + index * step),
+ definition
+ ) for index in 0:(pattern.n - 1)
+ ]
+end
+
+function placements(pattern::Ring, item::Sector, ::Nothing)
+ pattern.n isa Int || throw(ArgumentError(
+ "capacity() requires contextual placement"
+ ))
+ pattern.r === nothing && throw(ArgumentError(
+ "a contextual ring radius requires placement inside a physical tree"
+ ))
+ if iszero(pattern.r)
+ return _origin_ring_poses(
+ pattern,
+ resolve(EmptyBoundary(), item)
+ )
+ end
+ validate(pattern, _tangential_width(item))
+ return _ring_poses(pattern, pattern.n, pattern.r)
+end
+
+function placements(pattern::Ring, item::CableGeometry, ::Nothing)
+ pattern.n isa Int || throw(ArgumentError(
+ "capacity() requires contextual placement"
+ ))
+ pattern.r === nothing && throw(ArgumentError(
+ "a contextual ring radius requires placement inside a physical tree"
+ ))
+ iszero(pattern.r) && return _origin_ring_poses(pattern, item)
+ validate(pattern, 2support(boundary(item)))
+ return _ring_poses(pattern, pattern.n, pattern.r)
+end
+
+function _sector_ring_poses(pattern::Ring, shape::SectorShape, allow_contact::Bool)
+ pattern.n == 1 && return _ring_poses(pattern, pattern.n, pattern.r)
+ iszero(pattern.gap_frac) || throw(DomainError(
+ pattern.gap_frac,
+ "origin-centered sector spacing is set by its physical side clearance, " *
+ "not Ring gap_frac"
+ ))
+ pitch = _ring_step(pattern, pattern.n)
+ angle_tolerance = sqrt(eps(float(_geometry_scalar(pitch)))) *
+ max(one(_geometry_scalar(pitch)), abs(_geometry_scalar(pitch)))
+ isapprox(
+ _geometry_scalar(shape.primitive.span),
+ _geometry_scalar(pitch);
+ atol = angle_tolerance,
+ rtol = sqrt(eps(float(_geometry_scalar(pitch))))
+ ) || throw(DomainError(
+ shape.primitive.span,
+ "an origin-centered sector span must equal its angular pitch $pitch so " *
+ "neighboring wedge sides remain parallel"
+ ))
+ clearance = _sector_side_clearance(shape.primitive)
+ clearance_tolerance = sqrt(eps(float(_geometry_scalar(shape.primitive.r_back)))) *
+ max(
+ one(_geometry_scalar(shape.primitive.r_back)),
+ abs(_geometry_scalar(shape.primitive.r_back))
+ )
+ valid_clearance = allow_contact ?
+ _geometry_scalar(clearance) >= -clearance_tolerance :
+ _geometry_scalar(clearance) > clearance_tolerance
+ valid_clearance || throw(DomainError(
+ clearance,
+ allow_contact ?
+ "sector insulation boundaries overlap" :
+ "bare origin-centered sectors require a positive physical side clearance"
+ ))
+ return _ring_poses(pattern, pattern.n, pattern.r)
+end
+
+_origin_ring_poses(pattern::Ring, shape::SectorShape) =
+ _sector_ring_poses(pattern, shape, false)
+
+function _origin_ring_poses(pattern::Ring, item::CableGeometry)
+ shape = boundary(item)
+ shape isa SectorShape || throw(ArgumentError(
+ "only sector-shaped members can share an origin"
+ ))
+ outer_region = findlast(
+ region -> boundary(region.primitive) === shape,
+ item.regions
+ )
+ allow_contact = outer_region !== nothing &&
+ item.regions[outer_region].source.material.kind === :insulator
+ return _sector_ring_poses(pattern, shape, allow_contact)
+end
+
+function placements(pattern::Polar, item, ::Nothing)
+ poses = Pose2[]
+ for radial_index in 0:(pattern.nr - 1)
+ radius = pattern.r0 + radial_index * pattern.dr
+ if iszero(radius)
+ push!(poses, Pose2(0, 0, pattern.φ0))
+ continue
+ end
+ append!(poses,
+ placements(
+ Ring(pattern.nφ; r = radius, φ0 = pattern.φ0, span = pattern.span),
+ item,
+ nothing
+ ))
+ end
+ return poses
+end
+
+function _ring_capacity(pattern::Ring, ratio::Real)
+ zero(ratio) < ratio < one(ratio) || return 0
+ half_angle = asin(ratio)
+ if isapprox(pattern.span, oftype(pattern.span, 2π))
+ count = pi / half_angle
+ return max(0, floor(Int, count + 8eps(float(count))))
+ end
+ count = pattern.span / (2half_angle)
+ return max(0, floor(Int, count + 8eps(float(count))) + 1)
+end
+
+function capacity(pattern::Ring, item::Disk, ::Nothing)
+ radius = something(
+ pattern.r,
+ item.r * (one(item.r) + pattern.gap_frac)
+ )
+ ratio = item.r * (one(pattern.gap_frac) + pattern.gap_frac) / radius
+ return _ring_capacity(pattern, ratio)
+end
+
+function capacity(pattern::Ring, item::Rectangle, ::Nothing)
+ radius = something(pattern.r, item.h / 2)
+ radius > zero(radius) || return 0
+ ratio = item.w * (one(pattern.gap_frac) + pattern.gap_frac) / (2radius)
+ return _ring_capacity(pattern, ratio)
+end
+
+function capacity(pattern::Ring, item::Sector, ::Nothing)
+ if pattern.r !== nothing && !iszero(pattern.r)
+ width = _tangential_width(item)
+ ratio = width * (one(pattern.gap_frac) + pattern.gap_frac) /
+ (2pattern.r)
+ return _ring_capacity(pattern, ratio)
+ end
+ occupied = item.span * (one(pattern.gap_frac) + pattern.gap_frac)
+ return max(0, floor(Int, pattern.span / occupied + 8eps(float(pattern.span))))
+end
+
+function capacity(pattern::Ring, item::CableGeometry, ::Nothing)
+ member_radius = support(boundary(item))
+ radius = something(
+ pattern.r,
+ member_radius * (one(member_radius) + pattern.gap_frac)
+ )
+ ratio = member_radius * (one(pattern.gap_frac) + pattern.gap_frac) / radius
+ return _ring_capacity(pattern, ratio)
+end
+
+function placements(pattern::Lattice, item, ::Nothing)
+ x0 = -(pattern.nx - 1) * pattern.dx / 2
+ y0 = -(pattern.ny - 1) * pattern.dy / 2
+ return Pose2[Pose2(x0 + ix * pattern.dx, y0 + iy * pattern.dy, 0)
+ for iy in 0:(pattern.ny - 1) for ix in 0:(pattern.nx - 1)]
+end
+
+_fill_member_extent(item::AbstractPrimitive) = support(resolve(EmptyBoundary(), item))
+_fill_member_extent(item::CableGeometry) = support(boundary(item))
+
+function _fill_placements(pattern::Fill, item, compact)
+ extent = _fill_member_extent(item)
+ extent > zero(extent) || throw(DomainError(
+ extent, "fill members must have positive radial extent"
+ ))
+ tolerance = sqrt(eps(float(pattern.r))) * max(one(pattern.r), pattern.r)
+ extent <= pattern.r + tolerance || throw(DomainError(
+ extent, "one member does not fit inside the fill radius"
+ ))
+
+ poses = Pose2[Pose2(0, 0, pattern.φ0)]
+ course = 1
+ radius = 2extent
+ while radius + extent <= pattern.r + tolerance
+ ring = Ring(
+ capacity();
+ r = radius,
+ φ0 = pattern.φ0 + (course - 1) * pattern.φ,
+ span = pattern.span
+ )
+ count = capacity(ring, item, compact)
+ count > 0 || break
+ append!(poses, placements(
+ Ring(count; r = ring.r, φ0 = ring.φ0, span = ring.span),
+ item,
+ compact
+ ))
+ course += 1
+ radius = 2course * extent
+ end
+ return poses
+end
+
+placements(pattern::Fill, item, ::Nothing) = _fill_placements(pattern, item, nothing)
+capacity(pattern::Fill, item, ::Nothing) = length(_fill_placements(pattern, item, nothing))
+
+function placements(poses::AbstractVector{<:Pose2}, item, ::Nothing)
+ isempty(poses) && throw(ArgumentError("explicit placements cannot be empty"))
+ return copy(poses)
+end
diff --git a/src/datamodel/preview.jl b/src/datamodel/preview.jl
deleted file mode 100644
index b722b03e0..000000000
--- a/src/datamodel/preview.jl
+++ /dev/null
@@ -1,1284 +0,0 @@
-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
-
-function _annular_sector_poly(
- rin::Real,
- rex::Real,
- θ_start::Real,
- θ_end::Real,
- x0::Real,
- y0::Real;
- N_pts::Int = 32,
-)
- # Generate points along the outer arc, then reverse back along the inner arc
- θ_out = range(θ_start, θ_end; length = N_pts)
- θ_in = reverse(θ_out)
-
- xo = x0 .+ rex .* cos.(θ_out)
- yo = y0 .+ rex .* sin.(θ_out)
-
- xi = x0 .+ rin .* cos.(θ_in)
- yi = y0 .+ rin .* sin.(θ_in)
-
- # Close the polygon
- 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 _bent_rect_poly(
- rin::Real,
- rex::Real,
- w::Real,
- θ_c::Real,
- x0::Real,
- y0::Real;
- N_pts::Int = 32,
-)
- # Outer arc: arc length is exactly w, so the angular span is w / rex
- dθ_out = rex > 0 ? (w / rex) : 0.0
- θ_out = range(θ_c - dθ_out/2, θ_c + dθ_out/2; length = N_pts)
- xo = x0 .+ rex .* cos.(θ_out)
- yo = y0 .+ rex .* sin.(θ_out)
-
- # Inner arc: arc length is exactly w, so the angular span is w / rin
- dθ_in = rin > 0 ? (w / rin) : 0.0
- θ_in = reverse(range(θ_c - dθ_in/2, θ_c + dθ_in/2; length = N_pts))
- xi = x0 .+ rin .* cos.(θ_in)
- yi = y0 .+ rin .* sin.(θ_in)
-
- # Connect the arcs. The straight side walls will now perfectly
- # preserve the Cartesian width of the strand.
- 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 _radial_wedge_poly(
- rin::Real,
- rex::Real,
- w::Real,
- θ_c::Real,
- x0::Real,
- y0::Real;
- N_pts::Int = 32,
-)
- # The true angular width is dictated entirely by the inner arc length (w)
- dθ = rin > 0 ? (w / rin) : 0.0
-
- # Both inner and outer arcs share this exact same angular range.
- # This guarantees perfectly radial side walls (a true sector).
- θ_out = range(θ_c - dθ/2, θ_c + dθ/2; length = N_pts)
- θ_in = reverse(θ_out)
-
- xo = x0 .+ rex .* cos.(θ_out)
- yo = y0 .+ rex .* sin.(θ_out)
-
- xi = x0 .+ rin .* cos.(θ_in)
- yi = y0 .+ rin .* sin.(θ_in)
-
- # Close the polygon
- 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
-
-#############################
-# 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 CircStrands
- rwire = to_nominal(layer.radius_wire)
- nW = layer.num_wires
- lay_r = nW == 1 ? 0.0 : to_nominal(layer.r_in)
- color = get_material_color_makie(layer.material_props)
-
- coords = calc_circstrands_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 RectStrands
- rin = to_nominal(layer.r_in)
- rex = to_nominal(layer.r_ex)
- w = to_nominal(layer.width)
- nW = layer.num_wires
- color = get_material_color_makie(layer.material_props)
-
- plots = Any[]
- handle = nothing
- for i in 1:nW
- # Distribute the center points symmetrically around the circle
- θ_c = (i - 1) * 2π / nW
-
- # Draw the constant-width bent rectangle
- poly = Makie.poly!(ax, _radial_wedge_poly(rin, rex, w, θ_c, x0, y0);
- 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.r_in)
- rex = to_nominal(layer.r_ex)
- 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 circstrands
- 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
-
- if layer isa Sector
- vertices = layer.vertices
- # Convert vertices to Makie.Point2f format with offset
- makie_points = [Makie.Point2f(v[1] + x0, v[2] + y0) for v in vertices]
- # Ensure polygon is closed by adding first point at the end if needed
- if length(makie_points) > 0 && makie_points[1] != makie_points[end]
- push!(makie_points, makie_points[1])
- end
-
- color = get_material_color_makie(layer.material_props)
-
- poly = Makie.poly!(ax, makie_points;
- color = color,
- strokecolor = :black,
- strokewidth = 0.5,
- 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 SectorInsulator
- outer_vertices = [(v[1] + x0, v[2] + y0) for v in layer.outer_vertices]
- # Convert to Makie.Point2f format
- outer_points = [Makie.Point2f(v[1], v[2]) for v in outer_vertices]
- # Ensure polygon is closed
- if length(outer_points) > 0 && outer_points[1] != outer_points[end]
- push!(outer_points, outer_points[1])
- end
-
- # (Not used for now) The inner boundary is the conductor's vertices. It must be reversed for the hole to be drawn correctly.
- inner_vertices = [(v[1] + x0, v[2] + y0) for v in layer.inner_sector.vertices]
- inner_points = [Makie.Point2f(v[1], v[2]) for v in inner_vertices]
- # Ensure inner polygon is closed
- if length(inner_points) > 0 && inner_points[1] != inner_points[end]
- push!(inner_points, inner_points[1])
- end
- color = get_material_color_makie(layer.material_props)
- # Create a shape with a hole by passing the outer boundary and holes as a vector of vectors
- polygon_with_hole = Makie.Polygon(outer_points, [inner_points])
- poly = Makie.poly!(ax, polygon_with_hole;
- color = color,
- strokecolor = :black,
- strokewidth = 0.5,
- 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
-
- @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.r_ex)
- 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 entries that share the exact same label and material 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]
-
- # We merge if the rho is finite (i.e., it's a conductor)
- if isfinite(ρ)
- j = i + 1
- merged_g = Vector{Any}(g)
-
- # Look ahead: merge as long as the label and rho match the current group
- while j <= length(handles) &&
- labels[j] == l &&
- 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 the shared label
- push!(merged_groups, merged_g) # all sub-elements across merged layers
- i = j
- else
- # Non-finite rho (e.g., insulators where you pushed NaN) do not merge
- 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.r_ex),
- to_nominal(comp.insulator_group.r_ex)))
- 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) && getproperty(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/preview/geometry.jl b/src/datamodel/preview/geometry.jl
new file mode 100644
index 000000000..c158b97e6
--- /dev/null
+++ b/src/datamodel/preview/geometry.jl
@@ -0,0 +1,44 @@
+"""
+$(TYPEDEF)
+
+Detached geometry and material identity for one physical cable region.
+
+Renderers choose colors, strokes, labels, and legend grouping for each region.
+
+$(TYPEDFIELDS)
+"""
+struct PreviewShape{G, M}
+ "Closed two-dimensional region geometry in meters."
+ geometry::G
+ "Physical material represented by the region."
+ material::M
+ "Stable construction tag carried by the source region."
+ tag::Symbol
+ function PreviewShape(geometry::G, material::M, tag::Symbol) where {G, M}
+ return validate(new{G, M}(geometry, material, tag))
+ end
+end
+
+function validate(shape::PreviewShape)
+ points = shape.geometry isa GeometryBasics.Polygon ?
+ Iterators.flatten((shape.geometry.exterior, shape.geometry.interiors...)) :
+ shape.geometry
+ applicable(iterate, points) || throw(ArgumentError(
+ "PreviewShape.geometry must be a collection of points",
+ ))
+ found = false
+ for point in points
+ found = true
+ coordinates = collect(point)
+ length(coordinates) >= 2 || throw(ArgumentError(
+ "PreviewShape.geometry points require at least two coordinates",
+ ))
+ all(value -> value isa Real && isfinite(value), coordinates) ||
+ throw(ArgumentError(
+ "PreviewShape.geometry coordinates must be finite real numbers",
+ ))
+ end
+ found || throw(ArgumentError("PreviewShape.geometry cannot be empty"))
+ isempty(String(shape.tag)) && throw(ArgumentError("PreviewShape.tag cannot be empty"))
+ return shape
+end
diff --git a/src/datamodel/preview/materials.jl b/src/datamodel/preview/materials.jl
new file mode 100644
index 000000000..f668934a7
--- /dev/null
+++ b/src/datamodel/preview/materials.jl
@@ -0,0 +1,156 @@
+const _RHO_MIN = 1.0e-9
+const _RHO_MAX = 1.0e10
+
+_finite_point(point) = isfinite(point[1]) && isfinite(point[2])
+
+function _circle_points(radius, xcenter, ycenter; count::Int = 128)
+ angles = range(0, 2π; length = count)
+ return filter(
+ _finite_point,
+ Point2f.(xcenter .+ radius .* cos.(angles), ycenter .+ radius .* sin.(angles))
+ )
+end
+
+function _annulus_polygon(inner_radius, outer_radius, xcenter, ycenter; count::Int = 256)
+ outer = _circle_points(outer_radius, xcenter, ycenter; count)
+ iszero(inner_radius) && return GeometryBasics.Polygon(outer)
+ inner = reverse(_circle_points(inner_radius, xcenter, ycenter; count))
+ return GeometryBasics.Polygon(outer, [inner])
+end
+
+function _transform_preview_points(points, pose::Pose2)
+ cosine = cos(nominal(pose.φ))
+ sine = sin(nominal(pose.φ))
+ x = nominal(pose.x)
+ y = nominal(pose.y)
+ return Point2f[(
+ x + cosine * nominal(point[1]) - sine * nominal(point[2]),
+ y + sine * nominal(point[1]) + cosine * nominal(point[2])
+ ) for point in points]
+end
+
+function _primitive_geometry(primitive::Disk)
+ return GeometryBasics.Polygon(_circle_points(nominal(primitive.r), 0.0, 0.0))
+end
+
+function _primitive_geometry(primitive::Annulus)
+ return _annulus_polygon(
+ nominal(primitive.ri), nominal(primitive.ro), 0.0, 0.0
+ )
+end
+
+function _primitive_geometry(primitive::Rectangle)
+ x = nominal(primitive.w) / 2
+ y = nominal(primitive.h) / 2
+ return GeometryBasics.Polygon(Point2f[(-x, -y), (x, -y), (x, y), (-x, y)])
+end
+
+function _primitive_geometry(primitive::Ellipse)
+ angles = range(0, 2pi; length = 128)
+ return GeometryBasics.Polygon(Point2f[(nominal(primitive.a) * cos(angle),
+ nominal(primitive.b) * sin(angle))
+ for angle in angles])
+end
+
+function _primitive_geometry(primitive::Polygon)
+ return GeometryBasics.Polygon(Point2f[point for point in primitive.points])
+end
+
+function _shape_geometry(primitive::AbstractPrimitive)
+ geometry = _primitive_geometry(primitive)
+ exterior = _transform_preview_points(geometry.exterior, primitive.at)
+ interiors = [_transform_preview_points(interior, primitive.at)
+ for interior in geometry.interiors]
+ return GeometryBasics.Polygon(exterior, interiors)
+end
+
+function _shape_geometry(primitive::DifferenceShape)
+ outer = _shape_geometry(primitive.outer)
+ interiors = copy(outer.interiors)
+ for hole in primitive.holes
+ push!(interiors, reverse(_shape_geometry(hole).exterior))
+ end
+ return GeometryBasics.Polygon(outer.exterior, interiors)
+end
+
+function _shape_geometry(shape::SectorShape)
+ return GeometryBasics.Polygon(Point2f.(tessellate(shape)))
+end
+
+function _shape_geometry(shape::EllipseOffset)
+ return GeometryBasics.Polygon(Point2f.(tessellate(shape)))
+end
+
+function _shape_geometry(shape::BentStrip)
+ return GeometryBasics.Polygon(Point2f.(tessellate(shape)))
+end
+
+function _shape_geometry(shape::ShellShape)
+ coordinates = tessellate(shape)
+ outer = Point2f.(coordinates.outer)
+ inner = reverse(Point2f.(coordinates.inner))
+ return GeometryBasics.Polygon(outer, [inner])
+end
+
+preview_materials(region::PlacedRegion) = (region.source.material,)
+
+function preview_shapes(region::PlacedRegion)
+ return PreviewShape[
+ PreviewShape(
+ _shape_geometry(region.primitive),
+ region.source.material,
+ region.source.tag
+ ),
+ ]
+end
+
+function _each_material(callback, design::CableDesign)
+ for source in design.geometry.regions
+ foreach(callback, preview_materials(source))
+ end
+ return nothing
+end
+
+function _each_material(callback, designs::AbstractVector{<:CableDesign})
+ for design in designs
+ _each_material(callback, design)
+ end
+ return nothing
+end
+
+function _property_ranges(design)
+ resistivities = Float64[]
+ permeabilities = Float64[]
+ permittivities = Float64[]
+ _each_material(design) do material
+ resistivity = nominal(material.rho)
+ permeability = nominal(material.mu_r)
+ permittivity = nominal(material.eps_r)
+ isfinite(resistivity) && push!(resistivities, resistivity)
+ isfinite(permeability) && push!(permeabilities, permeability)
+ isfinite(permittivity) && push!(permittivities, permittivity)
+ end
+ return (;
+ rho = isempty(resistivities) ?
+ (_RHO_MIN, _RHO_MAX) : extrema(resistivities),
+ mu_r = isempty(permeabilities) ?
+ (1.0, 300.0) : extrema(permeabilities),
+ eps_r = isempty(permittivities) ?
+ (1.0, 1000.0) : extrema(permittivities)
+ )
+end
+
+"""
+ material_property_ranges()
+ material_property_ranges(design::CableDesign)
+ material_property_ranges(designs::AbstractVector{<:CableDesign})
+
+Return physical resistivity, relative permeability, and relative permittivity
+ranges without assigning any presentation color scheme.
+"""
+function material_property_ranges()
+ return (; rho = (_RHO_MIN, _RHO_MAX), mu_r = (1.0, 300.0), eps_r = (1.0, 1000.0))
+end
+
+material_property_ranges(design::CableDesign) = _property_ranges(design)
+material_property_ranges(designs::AbstractVector{<:CableDesign}) = _property_ranges(designs)
diff --git a/src/datamodel/radii.jl b/src/datamodel/radii.jl
deleted file mode 100644
index 963e7c414..000000000
--- a/src/datamodel/radii.jl
+++ /dev/null
@@ -1,130 +0,0 @@
-"""
-$(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
-
-- `r_in`: Normalized inner radius \\[m\\].
-- `r_ex`: Normalized outer radius \\[m\\].
-- `thickness`: Computed thickness or specialized dimension depending on the method \\[m\\].
- For [`CircStrands`](@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
-# A part proxy denotes the exact physical interface shared by two adjacent
-# layers. Preserve the complete Measurement derivative graph of that boundary;
-# stripping it here makes the same radius statistically different on each side
-# of the interface and breaks cumulative-geometry covariance.
-@inline _parse_radius_operand(p::AbstractCablePart, ::Type{T}) where {T} =
- getproperty(p, :r_ex)
-@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(
- r_in::Number,
- r_ex::Number,
- ::Type{T},
-) where {T}
- return r_in, r_ex
-end
-
-@inline function _do_normalize_radii(
- r_in::Number,
- thickness::Thickness,
- ::Type{T},
-) where {T}
- return r_in, (r_in + thickness.value)
-end
-
-@inline function _do_normalize_radii(
- r_in::Number,
- radius_wire::Number,
- ::Type{AbstractStrandsLayer},
-)
- return r_in, r_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
diff --git a/src/datamodel/rectstrands.jl b/src/datamodel/rectstrands.jl
deleted file mode 100644
index 2f3a373aa..000000000
--- a/src/datamodel/rectstrands.jl
+++ /dev/null
@@ -1,211 +0,0 @@
-"""
-$(TYPEDEF)
-
-Holds the pure geometric layout for a concentric layer of rectangular/flat strands.
-"""
-struct RectStrandsShape{T <: REALSCALAR, U <: Int} <: AbstractShapeGeometry
- "Thickness of the individual rectangular strip \\[m\\]."
- thickness::T
- "Width of the individual rectangular strip \\[m\\]."
- width::T
- "Number of wires in the layer \\[dimensionless\\]."
- num_wires::U
- "Ratio defining the lay length of the wires \\[dimensionless\\]."
- lay_ratio::T
- "Twisting direction of the strands (1 = unilay, -1 = contralay) \\[dimensionless\\]."
- lay_direction::U
- "Mean diameter of the wire layer \\[m\\]."
- mean_diameter::T
- "Pitch length of the wire layer \\[m\\]."
- pitch_length::T
- "Total cross-sectional area of the conductive metal in this layer \\[m²\\]."
- cross_section::T
-end
-
-"""
-$(TYPEDEF)
-
-Represents a concentric layer of rectangular strands with defined geometric, material, and electrical properties:
-
-$(TYPEDFIELDS)
-"""
-struct RectStrands{T <: REALSCALAR, S <: RectStrandsShape} <: AbstractStrandsLayer{T}
- "Internal radial boundary \\[m\\]."
- r_in::T
- "External radial boundary \\[m\\]."
- r_ex::T
- "Material properties of the conductive strands."
- material_props::Material{T}
- "Operating temperature of the layer \\[°C\\]."
- temperature::T
- "Equivalent electrical resistance of the layer \\[Ω/m\\]."
- resistance::T
- "Geometric mean radius (GMR) of the layer \\[m\\]."
- gmr::T
- "Shape payload defining the internal geometric layout."
- shape::S
-end
-
-# struct SectorCore{T <: REALSCALAR, S <: SectorShape} <: AbstractStrandsLayer{T}
-# "Internal radial boundary \\[m\\]."
-# r_in::T
-# "External radial boundary \\[m\\]."
-# r_ex::T
-# "Material properties of the conductive strands."
-# material_props::Material{T}
-# "Operating temperature of the layer \\[°C\\]."
-# temperature::T
-# "Equivalent electrical resistance of the layer \\[Ω/m\\]."
-# resistance::T
-# "Geometric mean radius (GMR) of the layer \\[m\\]."
-# gmr::T
-# "Shape payload defining the internal geometric layout."
-# shape::S
-# end
-
-# struct CircCore{T <: REALSCALAR, S <: Concentric} <: AbstractStrandsLayer{T}
-# "Internal radial boundary \\[m\\]."
-# r_in::T
-# "External radial boundary \\[m\\]."
-# r_ex::T
-# "Material properties of the conductive strands."
-# material_props::Material{T}
-# "Operating temperature of the layer \\[°C\\]."
-# temperature::T
-# "Equivalent electrical resistance of the layer \\[Ω/m\\]."
-# resistance::T
-# "Geometric mean radius (GMR) of the layer \\[m\\]."
-# gmr::T
-# "Shape payload defining the internal geometric layout."
-# shape::S
-# end
-
-"""
-$(TYPEDSIGNATURES)
-
-Constructs a [`RectStrands`](@ref) instance.
-
-# Arguments
-
-- `r_in`: Internal radius of the layer \\[m\\].
-- `thickness`: Radial thickness of the strands \\[m\\].
-- `width`: Width of the individual rectangular strip \\[m\\].
-- `num_wires`: Number of strands in the layer \\[dimensionless\\].
-- `lay_ratio`: Ratio defining the lay length of the strands \\[dimensionless\\].
-- `material_props`: A [`Material`](@ref) object containing physical properties.
-- `temperature`: Operating temperature \\[°C\\].
-- `lay_direction`: Twisting direction (1 = unilay, -1 = contralay) \\[dimensionless\\].
-
-# Returns
-
-- A [`RectStrands`](@ref) object with calculated geometric and electrical properties.
-
-# Examples
-
-```julia
-material = Material(1.724e-8, 1.0, 1.0, 20.0, 0.00393)
-layer = $(FUNCTIONNAME)(0.01, 0.002, 0.005, 10, 12.0, material, 25.0, 1)
-```
-"""
-function RectStrands(
- r_in::T,
- thickness::T,
- width::T,
- num_wires::U,
- lay_ratio::T,
- material_props::Material{T},
- temperature::T,
- lay_direction::U,
-) where {T <: REALSCALAR, U <: Int}
-
-
- # Target Area (the 'invariant' property)
- A0 = width * thickness
-
- # Area-Preserving Radial Expansion
- # This solves A_total = pi * (r_ext^2 - r_in^2)
- r_ex =
- num_wires == 1 ? Base.error("num_wires must be > 1") :
- sqrt(r_in^2 + (num_wires * A0) / T(π))
- thickness_effective=r_ex-r_in
- @info "Calculating outer radius to preserve total cross-sectional area of strands." r_ex radius_ext_without_correction=r_in+thickness thickness_effective thickness num_wires
- mean_diameter, pitch_length, overlength =
- calc_helical_params(r_in, r_ex, lay_ratio)
-
- cross_section = num_wires * A0
-
- shape_payload = RectStrandsShape(
- thickness_effective, width, num_wires, lay_ratio, lay_direction,
- mean_diameter, pitch_length, cross_section,
- )
-
- # Electrical properties
- rho = material_props.rho
- T0 = material_props.T0
- alpha = material_props.alpha
-
- R_wire =
- calc_strip_resistance(thickness, width, rho, alpha, T0, temperature) * overlength
- R_layer = R_wire / num_wires
-
- gmr_layer = calc_tubular_gmr(r_ex, r_in, material_props.mu_r)
-
- # 3. Instantiate the concrete struct
- return RectStrands(
- r_in,
- r_ex,
- material_props,
- temperature,
- R_layer,
- gmr_layer,
- shape_payload,
- )
-end
-
-const _REQ_RECTSTRANDS =
- (:r_in, :thickness, :width, :num_wires, :lay_ratio, :material_props)
-const _OPT_RECTSTRANDS = (:temperature, :lay_direction)
-const _DEFS_RECTSTRANDS = (T₀, 1)
-
-Validation.has_radii(::Type{RectStrands}) = false
-Validation.has_temperature(::Type{RectStrands}) = true
-Validation.required_fields(::Type{RectStrands}) = _REQ_RECTSTRANDS
-Validation.keyword_fields(::Type{RectStrands}) = _OPT_RECTSTRANDS
-Validation.keyword_defaults(::Type{RectStrands}) = _DEFS_RECTSTRANDS
-
-Validation.coercive_fields(::Type{RectStrands}) =
- (:r_in, :thickness, :width, :lay_ratio, :material_props, :temperature)
-
-Validation.is_radius_input(
- ::Type{RectStrands},
- ::Val{:r_in},
- x::AbstractCablePart,
-) = true
-
-Validation.is_radius_input(::Type{RectStrands}, ::Val{:r_in}, x::Thickness) = true
-
-# Specific for rectangular strands
-Validation.maxfill(::Type{RectStrands}, rin::Real, w::Real) =
- floor(Int, 2 * π * rin / w)
-
-Validation.extra_rules(::Type{RectStrands}) = (
- Normalized(:r_in), Finite(:r_in), Nonneg(:r_in),
- Normalized(:thickness), Finite(:thickness), Positive(:thickness),
- IntegerField(:num_wires), Positive(:num_wires),
- Finite(:lay_ratio), Nonneg(:lay_ratio),
- IsA{Material}(:material_props),
- OneOf(:lay_direction, (-1, 1)), Finite(:width),
- Positive(:width),
- PhysicalFillLimit(:num_wires, (:r_in, :width)), # THE BOUNCER
-)
-
-Validation.parse(::Type{RectStrands}, nt) = begin
- rin, rw = _normalize_radii(RectStrands, nt.r_in, nt.thickness)
-
- # Resolves to Int using the generic interface
- n_wires = _resolve_strands(nt.num_wires, RectStrands, rin, nt.width)
-
- return (; nt..., r_in = rin, thickness = rw, num_wires = n_wires)
-end
-
-@construct RectStrands _REQ_RECTSTRANDS _OPT_RECTSTRANDS _DEFS_RECTSTRANDS
\ No newline at end of file
diff --git a/src/datamodel/sector.jl b/src/datamodel/sector.jl
deleted file mode 100644
index c76255dba..000000000
--- a/src/datamodel/sector.jl
+++ /dev/null
@@ -1,397 +0,0 @@
-"""
-$(TYPEDEF)
-
-Holds the geometric parameters that define the shape of a sector conductor.
-
-$(TYPEDFIELDS)
-"""
-struct SectorParams{T<:REALSCALAR}
- "Number of sectors in the full cable (e.g., 3 or 4)."
- n_sectors::Int
- "Back radius of the sector (outermost curve) \\[m\\]."
- r_back::T
- "Depth of the sector from the back to the base \\[m\\]."
- d_sector::T
- "Corner radius for rounding sharp edges \\[m\\]."
- r_corner::T
- "Angular width of the conductor's flat base/sides [degrees]."
- theta_cond_deg::T
- "Insulation thickness \\[m\\]."
- d_insulation::T # needed to correct for the offset
-
- function SectorParams(n_sectors::Int, r_back::T, d_sector::T, r_corner::T, theta_cond_deg::T, d_insulation::T) where {T<:REALSCALAR}
- # validation for the sector geometry constraints (angle limits, no overlap, discriminant check)
- _validate_sector_params(n_sectors, r_back, d_sector, r_corner, theta_cond_deg, d_insulation)
- new{T}(n_sectors, r_back, d_sector, r_corner, theta_cond_deg, d_insulation)
- end
-end
-
-const _REQ_SECTOR_PARAMS = (:n_sectors, :r_back, :d_sector, :r_corner, :theta_cond_deg, :d_insulation)
-
-Validation.has_radii(::Type{SectorParams}) = false
-Validation.required_fields(::Type{SectorParams}) = _REQ_SECTOR_PARAMS
-Validation.coercive_fields(::Type{SectorParams}) = (:r_back, :d_sector, :r_corner, :theta_cond_deg, :d_insulation)
-
-# --- Geometry Logic Storage ---
-function _validate_sector_params(n_sectors, r_back, d_sector, r_corner, theta_cond_deg, d_insulation)
- # 1. Basic Non-negativity (redundant with rules but kept for Core safety)
- if r_back < 0 || d_sector < 0 || r_corner < 0 || theta_cond_deg < 0 || d_insulation < 0
- throw(ArgumentError("Sector geometry parameters must be non-negative."))
- end
-
- # 2. Basic constraints
- if theta_cond_deg >= 360.0
- throw(ArgumentError("[SectorParams] theta_cond_deg ($theta_cond_deg) must be < 360"))
- end
- allowed_angle = 360.0 / n_sectors
- if theta_cond_deg > allowed_angle + 1e-4
- throw(ArgumentError("[SectorParams] theta_cond_deg ($theta_cond_deg) exceeds allowed 360/n ($allowed_angle)"))
- end
-
- if d_sector >= r_back
- throw(ArgumentError("[SectorParams] d_sector ($d_sector) must be less than r_back ($r_back)"))
- end
-
- # 3. Geometric feasibility (Discriminant check)
- phi_deg = 90.0 - theta_cond_deg / 2.0
- phi_rad = deg2rad(phi_deg)
-
- if abs(cos(phi_rad)) < 1e-9
- throw(ArgumentError("[SectorParams] theta_cond_deg too close to 180 (phi ~ 90), valid sector cannot be formed."))
- end
-
- d_base_corner = r_corner * (1.0 / cos(phi_rad) - 1.0)
- d_offset = r_back - d_sector - d_base_corner
-
- k = r_corner / cos(phi_rad) + d_offset
- qa = 1.0 + tan(phi_rad)^2
- qb = 2.0 * k * tan(phi_rad)
- qc = k^2 - (r_back - r_corner)^2
-
- discriminant = qb^2 - 4.0 * qa * qc
- if discriminant < 0
- throw(ArgumentError("[SectorParams] Geometric constraints violated (negative discriminant). The combination of radii, depth and angle does not form a closed sector."))
- end
-end
-
-"""
-$(TYPEDEF)
-
-Rule that enforces specific Sector geometry constraints (discriminant, angle limits, no overlap).
-"""
-struct SectorGeometryValid <: Validation.Rule
- name::Symbol
-end
-
-function Validation._apply(r::SectorGeometryValid, nt, ::Type{SectorParams})
- # Delegate to the shared validation function
- # Note: Non-neg rules from Validation framework handle the basic checks,
- # but _validate_sector_params repeats them safely.
- _validate_sector_params(nt.n_sectors, nt.r_back, nt.d_sector, nt.r_corner, nt.theta_cond_deg, nt.d_insulation)
-end
-
-Validation.extra_rules(::Type{SectorParams}) = (
- Validation.IntegerField(:n_sectors),
- Validation.Positive(:n_sectors),
- Validation.Nonneg(:r_back),
- Validation.Nonneg(:d_sector),
- Validation.Nonneg(:r_corner),
- Validation.Nonneg(:theta_cond_deg),
- Validation.Nonneg(:d_insulation),
- Validation.Less(:d_sector, :r_back),
- SectorGeometryValid(:sector_geometry),
-)
-
-@construct SectorParams _REQ_SECTOR_PARAMS
-
-"""
-$(TYPEDEF)
-
-Represents a single sector-shaped conductor with defined geometric and material properties.
-
-$(TYPEDFIELDS)
-"""
-struct Sector{T<:REALSCALAR} <: AbstractConductorPart{T}
- "Inner radius (not applicable, typically 0 for the central point) \\[m\\]."
- r_in::T
- "Outer radius (equivalent back radius) \\[m\\]."
- r_ex::T
- "Geometric parameters defining the sector's shape."
- params::SectorParams{T}
- "Rotation angle of this specific sector around the cable's center [degrees]."
- rotation_angle_deg::T
- "Material properties of the conductor."
- material_props::Material{T}
- "Operating temperature of the conductor \\[°C\\]."
- temperature::T
- "Cross-sectional area of the sector \\[m²\\]."
- cross_section::T
- "Electrical resistance of the sector \\[Ω/m\\]."
- resistance::T
- "Geometric mean radius (GMR) of the sector (approximated) \\[m\\]."
- gmr::T
- "Calculated vertices defining the polygon shape."
- vertices::Vector{Point{2,T}}
- "Geometric centroid of the sector shape."
- centroid::Point{2,T}
-end
-
-
-function Sector(
- params::SectorParams{T},
- rotation_angle_deg::T,
- material_props::Material{T};
- temperature::T=T₀,
-) where {T<:REALSCALAR}
-
- # 1. Calculate the geometry and vertices for a base (unrotated) sector
- base_vertices = _calculate_sector_polygon_points(params)
-
- # 2. Rotate the vertices to the specified angle
- rotation_angle_rad = deg2rad(rotation_angle_deg)
- rotated_vertices = [_rotate_point(p, rotation_angle_rad) for p in base_vertices]
-
- # Ensure the polygon is closed: first point == last point (within tolerance)
- # tol = 1e-9
- # if !isempty(rotated_vertices)
- # firstp = rotated_vertices[1]
- # lastp = rotated_vertices[end]
- # if !(isapprox(firstp[1], lastp[1]; atol=tol, rtol=0.0) &&
- # isapprox(firstp[2], lastp[2]; atol=tol, rtol=0.0))
- # push!(rotated_vertices, firstp)
- # @debug "(Sector) rotated_vertices not closed — appended first point to close polygon."
- # end
- # end
- # 3. Calculate cross-sectional area using the Shoelace formula
- #cross_section = PolygonOps.area(rotated_vertices)
- cross_section = _shoelace_area(rotated_vertices)
- #@debug "Sector cross-sectional area: $(cross_section*1e6) mm²"
- @debug "Sector cross-sectional area (Shoelace): $(cross_section*1e6) mm²"
-
- # Calculate centroid
- centroid = _calculate_polygon_centroid(rotated_vertices)
- @debug "Sector numerically calculated centroid point is: $(centroid)"
- # 4. Calculate DC resistance
- rho_eff = calc_temperature_correction(material_props.alpha, temperature, material_props.T0) * material_props.rho
- resistance = rho_eff / cross_section
-
- # 5. Approximate GMR based on a circle of equivalent area
- r_equiv = sqrt(cross_section / π)
- gmr = r_equiv * exp(-0.25 * material_props.mu_r) # GMR of an equivalent solid round conductor
-
- return Sector{T}(
- zero(T),
- params.r_back,
- params,
- rotation_angle_deg,
- material_props,
- temperature,
- cross_section,
- resistance,
- gmr,
- rotated_vertices,
- centroid,
- )
-end
-
-
-
-# --- Geometric Helper Functions (internal) ---
-
-function _calculate_sector_geometry(p::SectorParams)
- phi_deg = 90.0 - p.theta_cond_deg / 2.0
- phi_rad = deg2rad(phi_deg) # ϕ
- @debug "(Sector) phi_deg: $phi_deg"
- @debug "(Sector) phi_rad: $phi_rad rad"
-
- if abs(cos(phi_rad)) < 1e-9
- error("theta_cond_deg is too close to 180, leading to division by zero. Check parameters.")
- end
-
- d_base_corner = p.r_corner * (1.0 / cos(phi_rad) - 1.0) # D_B
- @debug "(Sector) d_base_corner (D_B) : $d_base_corner m"
- d_offset = p.r_back - p.d_sector - d_base_corner # D_O
- @debug "(Sector) d_offset (D_O) : $d_offset m"
- d_insulation_offset = (p.d_insulation / (cos((pi - ((2 * pi) / p.n_sectors)) / 2.0))) - d_offset # D_I
- @debug "(Sector) d_insulation_offset (D_I) : $d_insulation_offset m"
-
- # trick test (works though different that Urquhart's)
- #d_offset = (p.d_insulation / (cos((pi - ((2 * pi) / p.n_sectors)) / 2.0))) # D_I
- #d_insulation_offset = 0.0
-
- x_base_corner = p.r_corner * sin(phi_rad) # X_A = -X_F
- y_base_corner = x_base_corner * tan(phi_rad) + d_offset # Y_A = Y_F
- node_A = Point2f(x_base_corner, y_base_corner + d_insulation_offset)
-
- k = p.r_corner / cos(phi_rad) + d_offset
- qa = 1.0 + tan(phi_rad)^2
- qb = 2.0 * k * tan(phi_rad)
- qc = k^2 - (p.r_back - p.r_corner)^2
-
- discriminant = qb^2 - 4.0 * qa * qc
- if discriminant < 0
- error("Cannot calculate side corner center: negative discriminant ($discriminant). Check parameters.")
- end
-
- x_side_center = (-qb + sqrt(discriminant)) / (2.0 * qa) # X_N
- @debug "(Sector) x_side_center (X_N): $(x_side_center*1e3) mm"
- @debug "(Sector) r_corner: $(p.r_corner*1e3) mm"
- y_side_center = x_side_center * tan(phi_rad) + k # Y_N
-
- # Sector width (for checks):
- # w = 2 * X_N + 2 * r_corner
- w_sector = 2.0 * x_side_center + 2.0 * p.r_corner
- @debug "(Sector) sector width (computed): $(w_sector) m ($(w_sector*1e3) mm)"
-
- x_side_lower = x_side_center + p.r_corner * sin(phi_rad) # X_B
- y_side_lower = y_side_center - p.r_corner * cos(phi_rad) # Y_B
- node_B = Point2f(x_side_lower, y_side_lower + d_insulation_offset)
-
- dist_origin_side_center = sqrt(x_side_center^2 + y_side_center^2)
- if dist_origin_side_center < 1e-9
- error("Side corner center is at the origin, cannot determine upper point direction.")
- end
- x_side_upper = x_side_center * p.r_back / dist_origin_side_center # X_C
- y_side_upper = y_side_center * p.r_back / dist_origin_side_center # Y_C
- node_C = Point2f(x_side_upper, y_side_upper + d_insulation_offset)
-
- nodes = (
- A=node_A,
- B=node_B,
- C=node_C,
- D=Point2f(-node_C[1], node_C[2]),
- E=Point2f(-node_B[1], node_B[2]),
- F=Point2f(-node_A[1], node_A[2])
- )
- @debug "(Sector) Nodes: $nodes"
- centers = (
- Back=Point2f(0, 0+ d_insulation_offset),
- Base=Point2f(0, d_offset + p.r_corner / cos(phi_rad) + d_insulation_offset),
- RightSide=Point2f(x_side_center, y_side_center+ d_insulation_offset),
- LeftSide=Point2f(-x_side_center, y_side_center+ d_insulation_offset)
- )
- @debug "(Sector) Centers: $centers"
- return (Nodes=nodes, Centers=centers, Params=p)
-end
-
-function _generate_arc_points(center, radius, start_angle, end_angle, num_points)
- while end_angle < start_angle
- @debug "(Sector) end_angle < start_angle: $(rad2deg(end_angle)) < $(rad2deg(start_angle))"
- end_angle += 2pi
- @debug "(Sector) Adjusted end_angle to be greater than start_angle: $(rad2deg(end_angle)) > $(rad2deg(start_angle))"
- end
- while end_angle - start_angle > pi
- @debug "(Sector) overshoot! end_angle - start_angle > π: $(rad2deg(end_angle - start_angle)) > 180"
- end_angle -= 2pi
- @debug "(Sector) Adjusted end_angle to be less than start_angle: $(rad2deg(end_angle)) < $(rad2deg(start_angle))"
- end
- angle_range = range(start_angle, stop=end_angle, length=num_points)
- @debug "(Sector) ______________________________ ∠ $(rad2deg(start_angle-end_angle))."
- return [Point2f(center[1] + radius * cos(a), center[2] + radius * sin(a)) for a in angle_range]
-end
-
-function _calculate_sector_polygon_points(params; num_arc_points=30) # increase `num_arc_points` for higher accuracy
- geom = _calculate_sector_geometry(params)
- nodes, centers = geom.Nodes, geom.Centers
-
- poly_points = Point2f[]
- get_angle(p1, p2) = atan((p1[2] - p2[2]), (p1[1] - p2[1]))
- # Start at F, go to A (Base)
- push!(poly_points, nodes.F)
- if params.r_corner > 1e-9
- start_angle = get_angle(nodes.F, centers.Base)
- @debug "Arc from F to A: start_angle=$(rad2deg(start_angle))"
- end_angle = get_angle(nodes.A, centers.Base)
- @debug "Arc from F to A: end_angle=$(rad2deg(end_angle))"
- append!(poly_points,
- _generate_arc_points(centers.Base, params.r_corner, start_angle, end_angle, num_arc_points)[2:end])
- else
- push!(poly_points, Point2f(0, params.r_back - params.d_sector), nodes.A)
- end
-
- # Line A to B
- push!(poly_points, nodes.B)
-
- # Arc B to C (Right Side)
- if params.r_corner > 1e-9
- start_angle = get_angle(nodes.B, centers.RightSide)
- @debug "Arc from B to C: start_angle=$(rad2deg(start_angle))"
- end_angle = get_angle(nodes.C, centers.RightSide)
- @debug "Arc from B to C: end_angle=$(rad2deg(end_angle))"
- append!(poly_points, _generate_arc_points(centers.RightSide, params.r_corner, start_angle, end_angle, num_arc_points)[2:end])
- else
- push!(poly_points, nodes.C)
- end
-
- # Arc C to D (Back)
- start_angle = get_angle(nodes.C, centers.Back)
- @debug "Arc from C to D: start_angle=$(rad2deg(start_angle))"
- end_angle = get_angle(nodes.D, centers.Back)
- @debug "Arc from C to D: end_angle=$(rad2deg(end_angle))"
- append!(poly_points, _generate_arc_points(centers.Back, params.r_back, start_angle, end_angle, num_arc_points)[2:end])
-
- # Arc D to E (Left Side)
- if params.r_corner > 1e-9
- start_angle = get_angle(nodes.D, centers.LeftSide)
- @debug "Arc from D to E: start_angle=$(rad2deg(start_angle))"
- end_angle = get_angle(nodes.E, centers.LeftSide)
- @debug "Arc from D to E: end_angle=$(rad2deg(end_angle))"
- append!(poly_points, _generate_arc_points(centers.LeftSide, params.r_corner, start_angle, end_angle, num_arc_points)[2:end])
- else
- push!(poly_points, nodes.E)
- end
-
- return poly_points
-end
-
-function _rotate_point(p::Point2f, angle_rad::Real)
- cos_a, sin_a = cos(angle_rad), sin(angle_rad)
- return Point2f(p[1] * cos_a - p[2] * sin_a, p[1] * sin_a + p[2] * cos_a)
-end
-
-
-function _shoelace_area(vertices::AbstractVector{Point{2,T}}) where {T<:Real}
- n::Int = length(vertices)
- if n < 3
- @warn "Polygon must have at least 3 vertices to compute area. Returning 0 area"
- return zero(T)
- end
- area::T = zero(T)
- for i in 1:n
- p1 = vertices[i]
- p2 = vertices[mod1(i + 1, n)]
- area += (p1[1] * p2[2] - p2[1] * p1[2])
- end
- return abs(area) / T(2)
-end
-
-function _calculate_polygon_centroid(vertices::AbstractVector{Point{2,T}}) where {T<:Real}
- n = length(vertices)
- if n < 3
- return Point2f(0, 0)
- end
-
-
- Cx = zero(T)
- Cy = zero(T)
- signed_area = zero(T)
-
- for i in 1:n
- p1 = vertices[i]
- p2 = vertices[mod1(i + 1, n)]
- cross_prod = (p1[1] * p2[2] - p2[1] * p1[2])
- signed_area += (p1[1] * p2[2] - p2[1] * p1[2])
- Cx += (p1[1] + p2[1]) * cross_prod
- Cy += (p1[2] + p2[2]) * cross_prod
- end
- signed_area /= 2
-
- if abs(signed_area) < 1e-12
- return Point2f(0, 0)
- end
-
- Cx /= (6 * signed_area)
- Cy /= (6 * signed_area)
-
- return Point2f(Cx, Cy)
-end
\ No newline at end of file
diff --git a/src/datamodel/sectorinsulator.jl b/src/datamodel/sectorinsulator.jl
deleted file mode 100644
index 8f13ea6be..000000000
--- a/src/datamodel/sectorinsulator.jl
+++ /dev/null
@@ -1,131 +0,0 @@
-"""
-$(TYPEDEF)
-
-Represents an insulating layer surrounding a sector-shaped conductor.
-
-$(TYPEDFIELDS)
-"""
-struct SectorInsulator{T<:REALSCALAR} <: AbstractInsulatorPart{T}
- "Inner radius (not applicable, defined by inner sector) \\[m\\]."
- r_in::T
- "Outer radius (equivalent back radius of outer boundary) \\[m\\]."
- r_ex::T
- "The inner sector conductor that this insulator surrounds."
- inner_sector::Sector{T}
- "The thickness of the insulating layer \\[m\\]."
- thickness::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
- "Shunt capacitance (approximated) \\[F/m\\]."
- shunt_capacitance::T
- "Shunt conductance (approximated) \\[S·m\\]."
- shunt_conductance::T
- "Calculated vertices of the outer boundary polygon."
- outer_vertices::Vector{Point{2, T}}
-end
-
-
-function SectorInsulator(
- inner_sector::Sector{T},
- thickness::T,
- material_props::Material{T};
- temperature::T=T₀,
-) where {T<:REALSCALAR}
-
- # 1. Calculate the outer vertices by offsetting the inner sector's geometry
- outer_vertices = _calculate_offset_polygon(inner_sector.vertices, thickness)
-
- # 2. Calculate areas
- inner_area = inner_sector.cross_section
-
- # tol = 1e-9
- # if !isempty(outer_vertices)
- # firstp = outer_vertices[1]
- # lastp = outer_vertices[end]
- # if !(isapprox(firstp[1], lastp[1]; atol=tol, rtol=0.0) &&
- # isapprox(firstp[2], lastp[2]; atol=tol, rtol=0.0))
- # push!(outer_vertices, firstp)
- # @debug "(Sector) outer_vertices not closed — appended first point to close polygon."
- # end
- # end
-
-
-
- #outer_area = PolygonOps.area(outer_vertices)
- outer_area = _shoelace_area(outer_vertices)
- @debug "SectorInsulator inner area: $(inner_area*1e6) mm²"
- @debug "SectorInsulator outer area: $(outer_area*1e6) mm²"
- cross_section = outer_area - inner_area
-
- # 3. Approximate capacitance and conductance using equivalent coaxial circles
- # This is more consistent with the package's approach than a parallel plate model.
- r_eq_in = sqrt(inner_area / π)
- r_eq_ext = sqrt(outer_area / π)
- shunt_capacitance = calc_shunt_capacitance(r_eq_in, r_eq_ext, material_props.eps_r)
- shunt_conductance = calc_shunt_conductance(r_eq_in, r_eq_ext, material_props.rho)
-
- # 4. Determine outer radius from the new params
- outer_r_back = inner_sector.params.r_back + thickness
-
- return SectorInsulator{T}(
- inner_sector.r_ex,
- outer_r_back,
- inner_sector,
- thickness,
- material_props,
- temperature,
- cross_section,
- shunt_capacitance,
- shunt_conductance,
- outer_vertices
- )
-end
-
-# --- Geometric Helper Functions (internal) ---
-# REVISED: This function now takes vertices and thickness to compute a geometric offset.
-function _calculate_offset_polygon(vertices::Vector{Point{2, T}}, thickness::T) where {T<:REALSCALAR}
- num_vertices = length(vertices)
- if num_vertices < 3
- error("Polygon must have at least 3 vertices.")
- end
-
- new_vertices = similar(vertices)
-
- for i in 1:num_vertices
- p_prev = vertices[i == 1 ? num_vertices : i - 1]
- p_curr = vertices[i]
- p_next = vertices[i == num_vertices ? 1 : i + 1]
-
- v1 = p_curr - p_prev
- v2 = p_next - p_curr
-
- # Normalize the vectors
- v1_norm = v1 / norm(v1)
- v2_norm = v2 / norm(v2)
-
- # Normal vectors (rotated 90 degrees clockwise for outward direction)
- n1 = Point(v1_norm[2], -v1_norm[1])
- n2 = Point(v2_norm[2], -v2_norm[1])
-
- # Bisector of the normals
- bisector = (n1 + n2) / norm(n1 + n2)
-
- # Angle between the two vectors to calculate the correct offset distance
- angle = acos(clamp(v1_norm ⋅ v2_norm, -1.0, 1.0))
-
- # Miter length
- offset_distance = thickness / sin((π - angle) / 2)
-
- if isinf(offset_distance) # The vectors are parallel
- new_vertices[i] = p_curr + n1 * thickness
- else
- new_vertices[i] = p_curr + bisector * offset_distance
- end
- end
-
- return new_vertices
-end
\ No newline at end of file
diff --git a/src/datamodel/semicon.jl b/src/datamodel/semicon.jl
deleted file mode 100644
index bc1380f86..000000000
--- a/src/datamodel/semicon.jl
+++ /dev/null
@@ -1,116 +0,0 @@
-"""
-$(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\\]."
- r_in::T
- "External radius of the semiconducting layer \\[m\\]."
- r_ex::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
-
-- `r_in`: Internal radius of the semiconducting layer \\[m\\].
-- `r_ex`: 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(
- r_in::T,
- r_ex::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 = π * (r_ex^2 - r_in^2)
-
- resistance =
- calc_tubular_resistance(r_in, r_ex, rho, alpha, T0, temperature)
- gmr = calc_tubular_gmr(r_ex, r_in, material_props.mu_r)
- shunt_capacitance = calc_shunt_capacitance(r_in, r_ex, epsr_r)
- shunt_conductance = calc_shunt_conductance(r_in, r_ex, rho)
-
- # Initialize object
- return Semicon(
- r_in,
- r_ex,
- material_props,
- temperature,
- cross_section,
- resistance,
- gmr,
- shunt_capacitance,
- shunt_conductance,
- )
-end
-
-const _REQ_SEMICON = (:r_in, :r_ex, :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{:r_in}, x::AbstractCablePart) = true
-Validation.is_radius_input(::Type{Semicon}, ::Val{:r_in}, x::Thickness) = true
-Validation.is_radius_input(::Type{Semicon}, ::Val{:r_ex}, x::Thickness) = true
-Validation.is_radius_input(::Type{Semicon}, ::Val{:r_ex}, 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.r_in, nt.r_ex)
- (; nt..., r_in = rin, r_ex = 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/strands_handler.jl b/src/datamodel/strands_handler.jl
deleted file mode 100644
index e9be77de7..000000000
--- a/src/datamodel/strands_handler.jl
+++ /dev/null
@@ -1,30 +0,0 @@
-struct MaxFill end # The promise proxy
-
-import ..Validation: maxfill
-
-"""
-$(TYPEDSIGNATURES)
-
-Fallback method for the [`maxfill`](@ref) interface. Throws an explicit error indicating that the component type `T` has not implemented its physical capacity limit.
-
-# Arguments
-
-- `::Type{T}`: Component type \\[dimensionless\\].
-- `args...`: Geometric parameters required for the calculation \\[dimensionless\\].
-
-# Returns
-
-- Nothing. Always throws an `ArgumentError`.
-"""
-@noinline function maxfill(::Type{T}, args...) where {T}
- throw(
- ArgumentError(
- "[$(_typename(T))] `maxfill` is not implemented. Any component using `MaxFill()` " *
- "or the `PhysicalFillLimit` rule must overload `maxfill(::Type{$(_typename(T))}, args...)`.",
- ),
- )
-end
-
-# The plumbing (Never needs to be touched again)
-@inline _resolve_strands(x::Int, ::Type{T}, args...) where {T} = x
-@inline _resolve_strands(::MaxFill, ::Type{T}, args...) where {T} = maxfill(T, args...)
diff --git a/src/datamodel/strip.jl b/src/datamodel/strip.jl
deleted file mode 100644
index 70e475b7b..000000000
--- a/src/datamodel/strip.jl
+++ /dev/null
@@ -1,155 +0,0 @@
-"""
-$(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\\]."
- r_in::T
- "External radius of the strip \\[m\\]."
- r_ex::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
-
-- `r_in`: Internal radius of the strip \\[m\\].
-- `r_ex`: 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(
- r_in::T,
- r_ex::T,
- width::T,
- lay_ratio::T,
- material_props::Material{T},
- temperature::T,
- lay_direction::Int,
-) where {T <: REALSCALAR}
-
- thickness = r_ex - r_in
- rho = material_props.rho
- T0 = material_props.T0
- alpha = material_props.alpha
-
- mean_diameter, pitch_length, overlength = calc_helical_params(
- r_in,
- r_ex,
- lay_ratio,
- )
-
- cross_section = thickness * width
-
- R_strip =
- calc_strip_resistance(thickness, width, rho, alpha, T0, temperature) *
- overlength
-
- gmr = calc_tubular_gmr(r_ex, r_in, material_props.mu_r)
-
- # Initialize object
- return Strip(
- r_in,
- r_ex,
- thickness,
- width,
- lay_ratio,
- mean_diameter,
- pitch_length,
- lay_direction,
- material_props,
- temperature,
- cross_section,
- R_strip,
- gmr,
- )
-end
-
-const _REQ_STRIP = (:r_in, :r_ex, :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}) =
- (:r_in, :r_ex, :width, :lay_ratio, :material_props, :temperature) # not :lay_direction
-# accept proxies for radii
-
-Validation.is_radius_input(::Type{Strip}, ::Val{:r_in}, x::AbstractCablePart) = true
-Validation.is_radius_input(::Type{Strip}, ::Val{:r_in}, x::Thickness) = true
-Validation.is_radius_input(::Type{Strip}, ::Val{:r_ex}, x::Thickness) = true
-Validation.is_radius_input(::Type{Strip}, ::Val{:r_ex}, 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.r_in, nt.r_ex)
- (; nt..., r_in = rin, r_ex = 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/textdisplay.jl b/src/datamodel/textdisplay.jl
new file mode 100644
index 000000000..5b2a865c0
--- /dev/null
+++ b/src/datamodel/textdisplay.jl
@@ -0,0 +1,575 @@
+function _identity_pose(pose::Pose2)
+ return iszero(pose.x) && iszero(pose.y) && iszero(pose.φ)
+end
+
+function _display_pose(pose::Pose2)
+ _identity_pose(pose) && return nothing
+ return sprint(show, pose; context = :compact => true)
+end
+
+function _bounded_collection(value)
+ value isa AbstractArray && return "$(length(value)) values"
+ value isa Tuple && return "$(length(value)) values"
+ return sprint(show, value; context = :compact => true)
+end
+
+TextDisplay.@showfields Pose2 "Pose" pose -> (
+ x = TextDisplay.engineering(pose.x, :meter),
+ y = TextDisplay.engineering(pose.y, :meter),
+ φ = TextDisplay.angle(pose.φ)
+)
+
+TextDisplay.@showfields Disk "Disk" primitive -> (
+ r = TextDisplay.engineering(primitive.r, :meter),
+ at = _display_pose(primitive.at)
+)
+
+TextDisplay.@showfields Rectangle "Rectangle" primitive -> (
+ w = TextDisplay.engineering(primitive.w, :meter),
+ h = TextDisplay.engineering(primitive.h, :meter),
+ at = _display_pose(primitive.at)
+)
+
+TextDisplay.@showfields Ellipse "Ellipse" primitive -> (
+ a = TextDisplay.engineering(primitive.a, :meter),
+ b = TextDisplay.engineering(primitive.b, :meter),
+ at = _display_pose(primitive.at)
+)
+
+TextDisplay.@showfields EllipseOffset "EllipseOffset" shape -> (
+ a = TextDisplay.engineering(shape.a, :meter),
+ b = TextDisplay.engineering(shape.b, :meter),
+ t = TextDisplay.engineering(shape.t, :meter),
+ at = _display_pose(shape.at)
+)
+
+TextDisplay.@showfields Sector "Sector" primitive -> (
+ Δφ = TextDisplay.angle(primitive.span),
+ rᵢ = TextDisplay.engineering(primitive.r_base, :meter),
+ rₒ = TextDisplay.engineering(primitive.r_back, :meter),
+ fillet = iszero(primitive.fillet) ? nothing :
+ TextDisplay.engineering(primitive.fillet, :meter)
+)
+
+TextDisplay.@showfields Annulus "Annulus" primitive -> (
+ rᵢ = TextDisplay.engineering(primitive.ri, :meter),
+ rₒ = TextDisplay.engineering(primitive.ro, :meter),
+ at = _display_pose(primitive.at)
+)
+
+TextDisplay.@showfields Polygon "Polygon" primitive -> (
+ vertices = length(primitive.points),
+ at = _display_pose(primitive.at)
+)
+
+TextDisplay.@showfields BentStrip "BentStrip" shape -> (
+ rᵢ = TextDisplay.engineering(shape.ri, :meter),
+ rₒ = TextDisplay.engineering(shape.ro, :meter),
+ Δφ = TextDisplay.angle(shape.span),
+ at = _display_pose(shape.at)
+)
+
+TextDisplay.@showfields BoundedPlacement "BoundedPlacement" placement -> (
+ boundary = string(nameof(typeof(placement.boundary))),
+ course = placement.course,
+)
+
+TextDisplay.@showfields Shell "Shell" layer -> (
+ t = TextDisplay.engineering(layer.t, :meter),
+)
+
+TextDisplay.@showfields DifferenceShape "DifferenceShape" shape -> (
+ outer = string(nameof(typeof(shape.outer))),
+ holes = length(shape.holes)
+)
+
+TextDisplay.@showfields AssemblyShape "AssemblyShape" shape -> (
+ members = length(shape.members),
+)
+
+TextDisplay.@showfields AssemblyMember "AssemblyMember" member -> (
+ item = string(nameof(typeof(member.item))),
+ at = _display_pose(member.at)
+)
+
+TextDisplay.name(::Type{<:EmptyBoundary}) = "Empty boundary"
+Base.summary(io::IO, ::EmptyBoundary) = print(io, "Empty boundary")
+Base.show(io::IO, ::EmptyBoundary) = print(io, "EmptyBoundary()")
+Base.show(io::IO, ::MIME"text/plain", value::EmptyBoundary) = show(io, value)
+
+TextDisplay.name(::Type{<:EnclosureBoundary}) = "Enclosure boundary"
+Base.summary(io::IO, ::EnclosureBoundary) = print(io, "Enclosure boundary")
+Base.show(io::IO, ::EnclosureBoundary) = print(io, "EnclosureBoundary()")
+Base.show(io::IO, ::MIME"text/plain", value::EnclosureBoundary) = show(io, value)
+
+function _ring_count(pattern::Ring)
+ pattern.n isa _DeferredCardinality && return "capacity()"
+ return pattern.n
+end
+
+TextDisplay.@showfields Ring "Ring" pattern -> (
+ n = _ring_count(pattern),
+ r = pattern.r === nothing ? nothing : TextDisplay.engineering(pattern.r, :meter),
+ φ₀ = iszero(pattern.φ0) ? nothing : TextDisplay.angle(pattern.φ0),
+ Δφ = isapprox(pattern.span, oftype(pattern.span, 2π)) ? nothing :
+ TextDisplay.angle(pattern.span),
+ gap = iszero(pattern.gap_frac) ? nothing : TextDisplay.value(pattern.gap_frac)
+)
+
+TextDisplay.@showfields Polar "Polar" pattern -> (
+ nᵣ = pattern.nr,
+ nφ = pattern.nφ,
+ r₀ = TextDisplay.engineering(pattern.r0, :meter),
+ Δr = TextDisplay.engineering(pattern.dr, :meter),
+ φ₀ = iszero(pattern.φ0) ? nothing : TextDisplay.angle(pattern.φ0),
+ Δφ = isapprox(pattern.span, oftype(pattern.span, 2π)) ? nothing :
+ TextDisplay.angle(pattern.span)
+)
+
+TextDisplay.@showfields Fill "Fill" pattern -> (
+ r = TextDisplay.engineering(pattern.r, :meter),
+ φ = iszero(pattern.φ) ? nothing : TextDisplay.angle(pattern.φ),
+ φ₀ = iszero(pattern.φ0) ? nothing : TextDisplay.angle(pattern.φ0),
+ Δφ = isapprox(pattern.span, oftype(pattern.span, 2π)) ? nothing :
+ TextDisplay.angle(pattern.span)
+)
+
+TextDisplay.@showfields Lattice "Lattice" pattern -> (
+ nₓ = pattern.nx,
+ nᵧ = pattern.ny,
+ Δx = TextDisplay.engineering(pattern.dx, :meter),
+ Δy = TextDisplay.engineering(pattern.dy, :meter)
+)
+
+TextDisplay.@showfields FillFactor "FillFactor" factor -> (
+ η = TextDisplay.value(factor.η),
+)
+
+TextDisplay.@showfields LayRatio "LayRatio" lay -> (
+ q = TextDisplay.value(lay.q),
+)
+
+TextDisplay.@showfields Pitch "Pitch" lay -> (
+ p = TextDisplay.engineering(lay.p, :meter),
+)
+
+TextDisplay.@showfields LayAngle "LayAngle" lay -> (
+ α = TextDisplay.angle(lay.α),
+)
+
+TextDisplay.@showfields Helix{<:LayRatio} "Helix" path -> (
+ q = TextDisplay.value(path.lay.q),
+ dir = path.dir > 0 ? "+1" : "−1",
+ φ₀ = iszero(path.φ0) ? nothing : TextDisplay.angle(path.φ0)
+)
+
+TextDisplay.@showfields Helix{<:Pitch} "Helix" path -> (
+ p = TextDisplay.engineering(path.lay.p, :meter),
+ dir = path.dir > 0 ? "+1" : "−1",
+ φ₀ = iszero(path.φ0) ? nothing : TextDisplay.angle(path.φ0)
+)
+
+TextDisplay.@showfields Helix{<:LayAngle} "Helix" path -> (
+ α = TextDisplay.angle(path.lay.α),
+ dir = path.dir > 0 ? "+1" : "−1",
+ φ₀ = iszero(path.φ0) ? nothing : TextDisplay.angle(path.φ0)
+)
+
+TextDisplay.name(::Type{<:Helix}) = "Helix"
+Base.summary(io::IO, ::Helix) = print(io, "Helix")
+function Base.show(io::IO, path::Helix)
+ return TextDisplay.fields(io,
+ "Helix",
+ (
+ lay = sprint(show, path.lay; context = :compact => true),
+ dir = path.dir > 0 ? "+1" : "−1",
+ φ₀ = iszero(path.φ0) ? nothing : TextDisplay.angle(path.φ0)
+ ))
+end
+function Base.show(io::IO, ::MIME"text/plain", path::Helix)
+ get(io, :compact, false) && return show(io, path)
+ return TextDisplay.fields(io,
+ "Helix",
+ (
+ lay = sprint(show, path.lay; context = :compact => true),
+ dir = path.dir > 0 ? "+1" : "−1",
+ φ₀ = iszero(path.φ0) ? nothing : TextDisplay.angle(path.φ0)
+ );
+ multiline = true)
+end
+
+_datasheet_unit(::Val{:U0}) = "kV"
+_datasheet_unit(::Val{:U}) = "kV"
+_datasheet_unit(::Val{:conductor_cross_section}) = "mm²"
+_datasheet_unit(::Val{:screen_cross_section}) = "mm²"
+_datasheet_unit(::Val{:armor_cross_section}) = "mm²"
+_datasheet_unit(::Val{:resistance}) = "Ω/km"
+_datasheet_unit(::Val{:capacitance}) = "μF/km"
+_datasheet_unit(::Val{:inductance}) = "mH/km"
+_datasheet_unit(::Val) = nothing
+
+function _datasheet_value(name::Symbol, item)
+ item === nothing && return nothing
+ text = item isa Real ? TextDisplay.value(item) :
+ sprint(show, item; context = :compact => true)
+ unit = _datasheet_unit(Val(name))
+ return unit === nothing ? text : "$text $unit"
+end
+
+function _datasheet_fields(info::DatasheetInfo)
+ names = keys(info)
+ values = Tuple(_datasheet_value(name, info[name]) for name in names)
+ return NamedTuple{names}(values)
+end
+
+TextDisplay.@showfields DatasheetInfo "DatasheetInfo" info -> _datasheet_fields(info)
+
+_compact_text(value) = sprint(show, value; context = :compact => true)
+
+function _part_label(region::Region)
+ primitive = _compact_text(region.primitive)
+ return "Region :$(region.tag) · $primitive · $(region.material.kind)"
+end
+
+function _part_label(stack::Stack)
+ count = length(stack.items)
+ return "Stack · $count $(count == 1 ? "part" : "parts")"
+end
+
+function _part_label(group::Group)
+ attributes = String[]
+ !_identity_pose(group.at) && push!(attributes, _compact_text(group.at))
+ one_member = group.pattern === nothing ||
+ group.pattern isa Ring && group.pattern.n == 1 &&
+ (group.pattern.r === nothing || iszero(group.pattern.r))
+ push!(attributes, one_member ? "one member" : _compact_text(group.pattern))
+ group.path === nothing || push!(attributes, _compact_text(group.path))
+ group.compact === nothing || push!(attributes, _compact_text(group.compact))
+ return "Group :$(group.name) · $(join(attributes, " · "))"
+end
+
+function _part_label(assembly::Assembly)
+ attributes = String[]
+ !_identity_pose(assembly.at) && push!(attributes, _compact_text(assembly.at))
+ if assembly.item isa Tuple
+ count = length(assembly.item)
+ push!(attributes, "$count explicit $(count == 1 ? "member" : "members")")
+ else
+ push!(attributes, _compact_text(assembly.pattern))
+ assembly.path === nothing || push!(attributes, _compact_text(assembly.path))
+ assembly.compact === nothing || push!(attributes, _compact_text(assembly.compact))
+ end
+ return "Assembly · $(join(attributes, " · "))"
+end
+
+function _part_label(enclosure::Enclosure)
+ attributes = String[_compact_text(enclosure.primitive)]
+ !_identity_pose(enclosure.at) && push!(attributes, _compact_text(enclosure.at))
+ return "Enclosure :$(enclosure.tag) · $(join(attributes, " · "))"
+end
+
+_part_children(::Region) = ()
+_part_children(stack::Stack) = Tuple(_part_node(item) for item in stack.items)
+_part_children(group::Group) = (_part_node(group.item),)
+function _part_children(assembly::Assembly)
+ if assembly.item isa Tuple
+ return Tuple((
+ label = _identity_pose(member.at) ? _part_label(member.item) :
+ string(_part_label(member.item), " · ", _compact_text(member.at)),
+ children = _part_children(member.item),
+ noun = "parts"
+ ) for member in assembly.item)
+ end
+ return (_part_node(assembly.item),)
+end
+function _part_children(enclosure::Enclosure)
+ fill = enclosure.fill isa Material ?
+ "fill · Material · $(enclosure.fill.kind)" :
+ string("fill · ", _part_label(enclosure.fill))
+ children = Any[_part_node(enclosure.item), (
+ label = fill, noun = "parts")]
+ enclosure.wall === nothing || push!(children,
+ (
+ label = string("wall · ", _part_label(enclosure.wall)),
+ children = _part_children(enclosure.wall),
+ noun = "parts"
+ ))
+ return Tuple(children)
+end
+
+function _part_node(part::AbstractCablePart)
+ (
+ label = _part_label(part),
+ children = _part_children(part),
+ noun = "parts"
+ )
+end
+
+TextDisplay.name(::Type{<:Region}) = "Region"
+Base.summary(io::IO, region::Region) = print(io, "Region :", region.tag)
+Base.show(io::IO, region::Region) = print(io, _part_label(region))
+Base.show(io::IO, ::MIME"text/plain", region::Region) = show(io, region)
+
+TextDisplay.name(::Type{<:Stack}) = "Stack"
+function Base.summary(io::IO, stack::Stack)
+ count = length(stack.items)
+ print(io, "Stack with $count $(count == 1 ? "part" : "parts")")
+end
+function Base.show(io::IO, stack::Stack)
+ count = length(stack.items)
+ print(io, "Stack($count $(count == 1 ? "part" : "parts"))")
+end
+function Base.show(io::IO, ::MIME"text/plain", stack::Stack)
+ get(io, :compact, false) && return show(io, stack)
+ return TextDisplay.tree(io, _part_label(stack), _part_children(stack); noun = "parts")
+end
+
+TextDisplay.name(::Type{<:Group}) = "Group"
+Base.summary(io::IO, group::Group) = print(io, "Group :", group.name)
+function Base.show(io::IO, group::Group)
+ print(io, "Group(:", group.name, "; ")
+ if group.pattern === nothing
+ print(io, "one member")
+ else
+ show(IOContext(io, :compact => true), group.pattern)
+ end
+ group.path === nothing ||
+ (print(io, ", path="); show(IOContext(io, :compact => true), group.path))
+ group.compact === nothing ||
+ (print(io, ", compact="); show(IOContext(io, :compact => true), group.compact))
+ group.boundary === nothing ||
+ (print(io, ", boundary="); show(IOContext(io, :compact => true), group.boundary))
+ print(io, ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", group::Group)
+ get(io, :compact, false) && return show(io, group)
+ return TextDisplay.tree(io, _part_label(group), _part_children(group); noun = "parts")
+end
+
+TextDisplay.name(::Type{<:Assembly}) = "Assembly"
+function Base.summary(io::IO, assembly::Assembly)
+ assembly.item isa Tuple ?
+ print(io, "Assembly with $(length(assembly.item)) members") :
+ print(io, "Assembly")
+end
+function Base.show(io::IO, assembly::Assembly)
+ if assembly.item isa Tuple
+ print(io, "Assembly($(length(assembly.item)) members)")
+ else
+ print(io, "Assembly(")
+ show(IOContext(io, :compact => true), assembly.pattern)
+ print(io, ")")
+ end
+end
+function Base.show(io::IO, ::MIME"text/plain", assembly::Assembly)
+ get(io, :compact, false) && return show(io, assembly)
+ return TextDisplay.tree(io, _part_label(assembly), _part_children(assembly); noun = "members")
+end
+
+TextDisplay.name(::Type{<:Enclosure}) = "Enclosure"
+Base.summary(io::IO, enclosure::Enclosure) = print(io, "Enclosure :", enclosure.tag)
+function Base.show(io::IO, enclosure::Enclosure)
+ print(io, "Enclosure(:", enclosure.tag, "; ")
+ show(IOContext(io, :compact => true), enclosure.primitive)
+ print(io, ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", enclosure::Enclosure)
+ get(io, :compact, false) && return show(io, enclosure)
+ return TextDisplay.tree(
+ io, _part_label(enclosure), _part_children(enclosure); noun = "parts"
+ )
+end
+
+TextDisplay.name(::Type{<:PlacedRegion}) = "PlacedRegion"
+Base.summary(io::IO, region::PlacedRegion) = print(io, "Placed region :", region.source.tag)
+function Base.show(io::IO, region::PlacedRegion)
+ terminal = region.terminal === nothing ? "" : "; terminal=:$(region.terminal)"
+ print(io, "PlacedRegion(:", region.source.tag, terminal, "; ")
+ show(IOContext(io, :compact => true), region.primitive)
+ print(io, ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", region::PlacedRegion)
+ get(io, :compact, false) && return show(io, region)
+ attributes = String[
+ "source $(_part_label(region.source))",
+ "primitive $(_compact_text(region.primitive))"
+]
+ region.terminal === nothing || push!(attributes, "terminal :$(region.terminal)")
+ isempty(region.placement.patterns) || push!(attributes,
+ "patterns $(length(region.placement.patterns))")
+ isempty(region.paths) || push!(attributes, "paths $(length(region.paths))")
+ return TextDisplay.tree(io, "PlacedRegion :$(region.source.tag)", attributes)
+end
+
+TextDisplay.name(::Type{<:CableGeometry}) = "CableGeometry"
+function Base.summary(io::IO, geometry::CableGeometry)
+ print(io, "Cable geometry with $(length(geometry.regions)) regions")
+end
+function Base.show(io::IO, geometry::CableGeometry)
+ print(io, "CableGeometry(regions=$(length(geometry.regions)); outer=")
+ show(IOContext(io, :compact => true), geometry.outer)
+ print(io, ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", geometry::CableGeometry)
+ get(io, :compact, false) && return show(io, geometry)
+ children = Any[(
+ label = "outer $(_compact_text(geometry.outer))", noun = "regions")]
+ append!(children,
+ (
+ label = string(
+ "region :", region.source.tag,
+ region.terminal === nothing ? "" : " · terminal :$(region.terminal)",
+ " · ", _compact_text(region.primitive)
+ ),
+ noun = "regions"
+ ) for region in geometry.regions)
+ count = length(geometry.regions)
+ return TextDisplay.tree(
+ io,
+ "CableGeometry · $count $(count == 1 ? "region" : "regions")",
+ Tuple(children);
+ noun = "regions"
+ )
+end
+
+TextDisplay.name(::Type{<:CableDesign}) = "CableDesign"
+function Base.summary(io::IO, design::CableDesign)
+ print(io, "CableDesign \"", design.cable_id, "\"")
+end
+function Base.show(io::IO, design::CableDesign)
+ print(
+ io,
+ "CableDesign(\"", design.cable_id, "\"; terminals=",
+ length(design.terminal_order), ", regions=", length(design.geometry.regions), ")"
+ )
+end
+function Base.show(io::IO, ::MIME"text/plain", design::CableDesign)
+ get(io, :compact, false) && return show(io, design)
+ terminals = isempty(design.terminal_order) ? "none" : join(design.terminal_order, ", ")
+ origin = _part_node(design.origin)
+ children = (
+ (label = "terminals $terminals", noun = "parts"),
+ (label = "regions $(length(design.geometry.regions))", noun = "parts"),
+ (label = "diameter $(TextDisplay.engineering(2outer_radius(design), :meter))",
+ noun = "parts"),
+ (label = "origin $(origin.label)", children = origin.children, noun = "parts")
+ )
+ return TextDisplay.tree(io, "CableDesign \"$(design.cable_id)\"", children)
+end
+
+TextDisplay.name(::Type{<:LineCableSystem}) = "LineCableSystem"
+function Base.summary(io::IO, system::LineCableSystem)
+ print(io, "LineCableSystem \"", system.system_id, "\"")
+end
+function Base.show(io::IO, system::LineCableSystem)
+ print(
+ io,
+ "LineCableSystem(\"", system.system_id, "\"; cables=", ncables(system),
+ ", terminals=", length(system.terminal_order), ")"
+ )
+end
+function Base.show(io::IO, ::MIME"text/plain", system::LineCableSystem)
+ get(io, :compact, false) && return show(io, system)
+ cables = Tuple((
+ label = string(
+ design.cable_id,
+ " · ", _compact_text(position),
+ " · ", join(
+ ("$terminal→$phase"
+ for (terminal, phase) in
+ zip(design.terminal_order, connections)),
+ ", "
+ )
+ ),
+ noun = "cables"
+ )
+ for (design, position, connections) in zip(
+ system.designs, system.positions, system.connections
+ ))
+ children = (
+ (label = "length $(TextDisplay.engineering(system.line_length, :meter))",
+ noun = "cables"),
+ (label = "terminals $(length(system.terminal_order))", noun = "cables"),
+ (label = "cables", children = cables, noun = "cables")
+ )
+ return TextDisplay.tree(io, "LineCableSystem \"$(system.system_id)\"", children)
+end
+
+TextDisplay.name(::Type{<:CablesLibrary}) = "CablesLibrary"
+function Base.summary(io::IO, library::CablesLibrary)
+ count = length(library)
+ print(io, "CablesLibrary with $count $(count == 1 ? "design" : "designs")")
+end
+function Base.show(io::IO, library::CablesLibrary)
+ count = length(library)
+ print(io, "CablesLibrary($count $(count == 1 ? "design" : "designs"))")
+end
+function Base.show(io::IO, ::MIME"text/plain", library::CablesLibrary)
+ get(io, :compact, false) && return show(io, library)
+ ids = sort!(collect(keys(library)))
+ children = Tuple(begin
+ design = library[cable_id]
+ (
+ label = string(
+ cable_id, " · ", length(design.terminal_order), " terminals · ",
+ length(design.geometry.regions), " regions · D=",
+ TextDisplay.engineering(2outer_radius(design), :meter)
+ ),
+ noun = "designs"
+ )
+ end
+ for cable_id in ids)
+ count = length(library)
+ return TextDisplay.tree(
+ io,
+ "CablesLibrary · $count $(count == 1 ? "design" : "designs")",
+ children;
+ noun = "designs"
+ )
+end
+
+TextDisplay.name(::Type{<:SectorShape}) = "Sector shape"
+Base.summary(io::IO, ::SectorShape) = print(io, "Resolved sector")
+function Base.show(io::IO, shape::SectorShape)
+ print(io, "ResolvedSector(")
+ show(IOContext(io, :compact => true), shape.primitive)
+ _identity_pose(shape.at) ||
+ (print(io, "; at="); show(IOContext(io, :compact => true), shape.at))
+ print(io, ")")
+end
+Base.show(io::IO, ::MIME"text/plain", shape::SectorShape) = show(io, shape)
+
+TextDisplay.name(::Type{<:ShellShape}) = "Shell shape"
+Base.summary(io::IO, ::ShellShape) = print(io, "Resolved shell")
+function Base.show(io::IO, shape::ShellShape)
+ print(io, "ResolvedShell(inner=")
+ show(IOContext(io, :compact => true), shape.inner)
+ print(io, ", outer=")
+ show(IOContext(io, :compact => true), shape.outer)
+ print(io, ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", shape::ShellShape)
+ get(io, :compact, false) && return show(io, shape)
+ return TextDisplay.tree(io,
+ "Resolved shell",
+ (
+ (label = "inner $(_compact_text(shape.inner))", noun = "boundaries"),
+ (label = "outer $(_compact_text(shape.outer))", noun = "boundaries")
+ ))
+end
+
+function _preview_geometry_count(geometry)
+ points = geometry isa GeometryBasics.Polygon ? geometry.exterior : geometry
+ applicable(length, points) && return length(points)
+ return count(_ -> true, points)
+end
+
+TextDisplay.name(::Type{<:PreviewShape}) = "Preview shape"
+function Base.summary(io::IO, shape::PreviewShape)
+ print(io, "Preview shape :", shape.tag)
+end
+function Base.show(io::IO, shape::PreviewShape)
+ print(io, "PreviewShape(:", shape.tag, "; vertices=",
+ _preview_geometry_count(shape.geometry), ")")
+end
+Base.show(io::IO, ::MIME"text/plain", shape::PreviewShape) = show(io, shape)
diff --git a/src/datamodel/tubular.jl b/src/datamodel/tubular.jl
deleted file mode 100644
index ee7b29fdd..000000000
--- a/src/datamodel/tubular.jl
+++ /dev/null
@@ -1,112 +0,0 @@
-"""
-$(TYPEDEF)
-
-Represents a tubular or solid (`r_in=0`) conductor with geometric and material properties defined as:
-
-$(TYPEDFIELDS)
-"""
-struct Tubular{T <: REALSCALAR} <: AbstractConductorPart{T}
- "Internal radius of the tubular conductor \\[m\\]."
- r_in::T
- "External radius of the tubular conductor \\[m\\]."
- r_ex::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
-
-- `r_in`: Internal radius of the tubular conductor \\[m\\].
-- `r_ex`: 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(
- r_in::T,
- r_ex::T,
- material_props::Material{T},
- temperature::T,
-) where {T <: REALSCALAR}
-
- rho = material_props.rho
- T0 = material_props.T0
- alpha = material_props.alpha
-
- cross_section = π * (r_ex^2 - r_in^2)
-
- R0 = calc_tubular_resistance(r_in, r_ex, rho, alpha, T0, temperature)
-
- gmr = calc_tubular_gmr(r_ex, r_in, material_props.mu_r)
-
- # Initialize object
- return Tubular(
- r_in,
- r_ex,
- material_props,
- temperature,
- cross_section,
- R0,
- gmr,
- )
-end
-
-const _REQ_TUBULAR = (:r_in, :r_ex, :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{:r_in}, x::AbstractCablePart) = true
-Validation.is_radius_input(::Type{Tubular}, ::Val{:r_in}, x::Thickness) = true
-Validation.is_radius_input(::Type{Tubular}, ::Val{:r_ex}, x::Thickness) = true
-Validation.is_radius_input(::Type{Tubular}, ::Val{:r_ex}, 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.r_in, nt.r_ex)
- (; nt..., r_in = rin, r_ex = 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
deleted file mode 100644
index 144a57d3a..000000000
--- a/src/datamodel/typecoercion.jl
+++ /dev/null
@@ -1,111 +0,0 @@
-@inline function _rebuild_part_typed_core(p, ::Type{T}) where {T}
- C0 = typeof(p).name.wrapper # concrete parametric type (e.g., CircStrands)
- 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.r_in, T),
- coerce_to_T(g.r_ex, 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.r_in, T),
- coerce_to_T(g.r_ex, 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
diff --git a/src/datamodel/types.jl b/src/datamodel/types.jl
index a3de41e38..192ace250 100644
--- a/src/datamodel/types.jl
+++ b/src/datamodel/types.jl
@@ -1,89 +1,6 @@
-# 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:
-- [`Tubular`](@ref)
-- [`Strip`](@ref)
-"""
-abstract type AbstractConductorPart{T} <: AbstractCablePart{T} end
-
-"""
-$(TYPEDEF)
-
-Abstract type representing all stranded configurations composed of grouped discrete geometric shapes.
-
-Subtypes implement specific configurations:
-- [`CircStrands`](@ref)
-- [`RectStrands`](@ref)
-"""
-abstract type AbstractStrandsLayer{T} <: AbstractConductorPart{T} end
-
-
-"""
-$(TYPEDEF)
-
-Abstract type representing an insulating part of a cable.
-
-Subtypes implement specific configurations:
-- [`Insulator`](@ref)
-- [`Semicon`](@ref)
+Supertype for materialized cable parts.
"""
-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
-
-
-### Provisions for the new types currently under development: RectStrandsShape and SectorShape
-abstract type AbstractShapeGeometry end
\ No newline at end of file
+abstract type AbstractCablePart end
diff --git a/src/datamodel/validation.jl b/src/datamodel/validation.jl
deleted file mode 100644
index 0e6304236..000000000
--- a/src/datamodel/validation.jl
+++ /dev/null
@@ -1,86 +0,0 @@
-"""
-$(TYPEDSIGNATURES)
-
-Default policy for **inner** radius raw inputs: accept proxies that expose an outer radius. This permits stacking by hijacking `p.r_ex` during parsing.
-
-# Arguments
-
-- `::Type{T}`: Component type \\[dimensionless\\].
-- `::Val{:r_in}`: Field tag for the inner radius \\[dimensionless\\].
-- `p::AbstractCablePart`: Proxy object \\[dimensionless\\].
-
-# Returns
-
-- `Bool` indicating acceptance (`true` if `hasproperty(p, :r_ex)`).
-
-# Examples
-
-```julia
-Validation.is_radius_input(Tubular, Val(:r_in), prev_layer) # true if prev_layer has :r_ex
-```
-"""
-is_radius_input(::Type{T}, ::Val{:r_in}, p::AbstractCablePart) where {T} =
- hasproperty(p, :r_ex)
-
-"""
-$(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{:r_ex}`: Field tag for the outer radius \\[dimensionless\\].
-- `::AbstractCablePart`: Proxy object \\[dimensionless\\].
-
-# Returns
-
-- `false` always.
-
-# Examples
-
-```julia
-Validation.is_radius_input(Tubular, Val(:r_ex), prev_layer) # false
-```
-"""
-is_radius_input(::Type{T}, ::Val{:r_ex}, ::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{:r_ex}`: Field tag for the outer radius \\[dimensionless\\].
-- `::Thickness`: Thickness wrapper \\[dimensionless\\].
-
-# Returns
-
-- `Bool` indicating acceptance (`true`).
-
-# Examples
-
-```julia
-Validation.is_radius_input(Tubular, Val(:r_ex), Thickness(1e-3)) # true
-```
-"""
-is_radius_input(::Type{T}, ::Val{:r_ex}, ::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
diff --git a/src/docstrings.jl b/src/docstrings.jl
new file mode 100644
index 000000000..797688d51
--- /dev/null
+++ b/src/docstrings.jl
@@ -0,0 +1,65 @@
+using DocStringExtensions: DocStringExtensions, SIGNATURES, TYPEDSIGNATURES, TYPEDEF,
+ TYPEDFIELDS, FIELDS, FUNCTIONNAME, IMPORTS, EXPORTS
+
+export SIGNATURES, TYPEDSIGNATURES, TYPEDEF, TYPEDFIELDS, FIELDS, FUNCTIONNAME,
+ METHODLIST, IMPORTS, EXPORTS
+
+#! explicit-imports: off
+# METHODLIST adapts dependency-internal DocStringExtensions machinery. Keep this
+# exception local to the adapter so the rest of the package remains strict.
+struct _CleanMethodList <: DocStringExtensions.Abbreviation end
+
+"`METHODLIST` abbreviation with package-local, CI-safe source paths."
+const METHODLIST = _CleanMethodList()
+const _PACKAGE_ROOT = normpath(dirname(@__DIR__))
+
+function _method_path(method)
+ file = string(method.file)
+ isempty(file) && return "unknown"
+ normalized = normpath(file)
+ return startswith(normalized, _PACKAGE_ROOT) ?
+ relpath(normalized, _PACKAGE_ROOT) : basename(normalized)
+end
+
+function DocStringExtensions.format(::_CleanMethodList, buffer, doc)
+ binding = doc.data[:binding]
+ typesig = doc.data[:typesig]
+ module_name = doc.data[:module]
+ function_value = Docs.resolve(binding)
+ groups = DocStringExtensions.methodgroups(
+ function_value,
+ typesig,
+ module_name;
+ exact = false
+ )
+ isempty(groups) && return nothing
+
+ println(buffer)
+ for group in groups
+ println(buffer, "```julia")
+ for method in group
+ DocStringExtensions.printmethod(buffer, binding, function_value, method)
+ println(buffer)
+ end
+ println(buffer, "```\n")
+ if !isempty(group)
+ method = first(group)
+ url = DocStringExtensions.url(method)
+ if isempty(url) || startswith(url, "file:")
+ println(
+ buffer,
+ "defined at `$(_method_path(method)):$(method.line)`."
+ )
+ else
+ println(
+ buffer,
+ "defined at [`$(_method_path(method)):$(method.line)`]($url)."
+ )
+ end
+ end
+ println(buffer)
+ end
+ println(buffer)
+ return nothing
+end
+#! explicit-imports: on
diff --git a/src/earth/Earth.jl b/src/earth/Earth.jl
new file mode 100644
index 000000000..e7463aaf3
--- /dev/null
+++ b/src/earth/Earth.jl
@@ -0,0 +1,62 @@
+"""
+ LineCableModels.Earth
+
+Define static homogeneous and layered-earth descriptions, measured
+frequency-dependent material relations, and equivalent homogeneous-earth
+reductions required by line-parameter formulations.
+
+# Public actions
+
+- Declare earth descriptions with [`layer`](@ref) and [`homogeneous`](@ref),
+ represented by [`EarthLayer`](@ref) and [`EarthModel`](@ref).
+- Construct the ephemeral [`EarthMaterial`](@ref) used by the engine.
+- Select measured frequency dependence through [`FrequencyDependent`](@ref).
+- Select equivalent homogeneous-earth reductions through [`EquivalentHomogeneous`](@ref).
+- Build immutable earth models from complete ordered layer declarations.
+- Present earth data through the Base display protocol.
+"""
+module Earth
+
+export AbstractEarthModel, AbstractEarthLayer, AbstractEarthMaterial, EarthMaterial,
+ EarthLayer, EarthModel
+export layer, homogeneous
+export build
+export FrequencyDependent, EquivalentHomogeneous
+
+using DocStringExtensions: TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+using RequiredInterfaces: @required
+import ..LineCableModels: build, validate
+import ..LineCableModels: parameterize
+using ..Materials: AbstractMaterial
+import ..Commons
+import ..TextDisplay
+
+"""
+Supertype for materialized static earth-layer and earth-model descriptions.
+"""
+abstract type AbstractEarthModel end
+
+"""
+Supertype for a static earth layer.
+"""
+abstract type AbstractEarthLayer <: AbstractEarthModel end
+
+"""
+Supertype for frequency-evaluated earth constitutive properties.
+"""
+abstract type AbstractEarthMaterial <: AbstractMaterial end
+
+@required AbstractEarthModel begin
+ validate(::AbstractEarthModel)
+end
+
+include("earthmaterial.jl")
+include("earthlayer.jl")
+include("earthmodel.jl")
+
+include("frequencydependent/FrequencyDependent.jl")
+include("equivalenthomogeneous/EquivalentHomogeneous.jl")
+
+include("base.jl")
+
+end # module Earth
diff --git a/src/earth/base.jl b/src/earth/base.jl
new file mode 100644
index 000000000..02f849830
--- /dev/null
+++ b/src/earth/base.jl
@@ -0,0 +1,81 @@
+TextDisplay.@showfields EarthMaterial "EarthMaterial" material -> (
+ ρ = TextDisplay.engineering(material.rho, :ohm_meter),
+ εᵣ = TextDisplay.value(material.eps_r),
+ μᵣ = TextDisplay.value(material.mu_r)
+)
+
+TextDisplay.@showfields EarthLayer "EarthLayer" layer -> (
+ ρ = TextDisplay.engineering(layer.rho, :ohm_meter),
+ εᵣ = TextDisplay.value(layer.eps_r),
+ μᵣ = TextDisplay.value(layer.mu_r),
+ h = isinf(layer.thickness) ? "half-space" :
+ TextDisplay.engineering(layer.thickness, :meter)
+)
+
+TextDisplay.name(::Type{<:EarthModel}) = "EarthModel"
+
+function _earth_description(model::EarthModel)
+ earth_count = length(model.layers) - 1
+ earth_count == 1 && return "homogeneous"
+ orientation = model.vertical_layers ? "vertical " : ""
+ return "$earth_count $(orientation)earth layers"
+end
+
+function Base.summary(io::IO, model::EarthModel)
+ print(io, "EarthModel · ", _earth_description(model))
+end
+
+function Base.show(io::IO, model::EarthModel)
+ print(io, "EarthModel(", _earth_description(model), ")")
+end
+
+function _earth_layer_name(index::Int, count::Int)
+ index == 1 && return "air"
+ count == 2 && return "earth"
+ index == count && return "basement"
+ return "layer $(index - 1)"
+end
+
+function _earth_resistivity(layer::EarthLayer)
+ return isinf(layer.rho) ? "∞" : TextDisplay.engineering(layer.rho, :ohm_meter)
+end
+
+function _earth_layer_text(
+ name::AbstractString,
+ layer::EarthLayer;
+ show_thickness::Bool
+)
+ thickness = if !show_thickness
+ ""
+ elseif isinf(layer.thickness)
+ "half-space"
+ else
+ "h=$(TextDisplay.engineering(layer.thickness, :meter))"
+ end
+ separator = show_thickness ? string(" ", rpad(thickness, 12)) : ""
+ return string(
+ rpad(name, 9), separator,
+ " ρ=", _earth_resistivity(layer),
+ " εᵣ=", TextDisplay.value(layer.eps_r),
+ " μᵣ=", TextDisplay.value(layer.mu_r)
+ )
+end
+
+function Base.show(io::IO, ::MIME"text/plain", model::EarthModel)
+ get(io, :compact, false) && return show(io, model)
+ show_thickness = length(model.layers) > 2
+ children = [
+ _earth_layer_text(
+ _earth_layer_name(index, length(model.layers)),
+ layer;
+ show_thickness
+ )
+ for (index, layer) in enumerate(model.layers)
+ ]
+ return TextDisplay.tree(
+ io,
+ "EarthModel · $(_earth_description(model))",
+ children;
+ noun = "layers"
+ )
+end
diff --git a/src/earth/earthlayer.jl b/src/earth/earthlayer.jl
new file mode 100644
index 000000000..5b0db313f
--- /dev/null
+++ b/src/earth/earthlayer.jl
@@ -0,0 +1,123 @@
+"""
+$(TYPEDEF)
+
+Store the static physical properties of one earth or air layer.
+
+$(TYPEDFIELDS)
+"""
+struct EarthLayer{T <: Real} <: AbstractEarthLayer
+ "Electrical resistivity \\[Ω·m\\]."
+ rho::T
+ "Relative permittivity \\[dimensionless\\]."
+ eps_r::T
+ "Relative permeability \\[dimensionless\\]."
+ mu_r::T
+ "Layer thickness \\[m\\]."
+ thickness::T
+
+ function EarthLayer{T}(rho::T, eps_r::T, mu_r::T, thickness::T) where {T <: Real}
+ return validate(new{T}(rho, eps_r, mu_r, thickness))
+ end
+end
+
+Base.eltype(::EarthLayer{T}) where {T} = T
+Base.eltype(::Type{EarthLayer{T}}) where {T} = T
+
+function validate(layer::EarthLayer)
+ isnan(layer.rho) && throw(DomainError(
+ layer.rho,
+ "EarthLayer.rho must not be NaN"
+ ))
+ layer.rho > zero(layer.rho) ||
+ throw(DomainError(layer.rho, "EarthLayer.rho must be positive"))
+ isfinite(layer.eps_r) && layer.eps_r > zero(layer.eps_r) ||
+ throw(DomainError(
+ layer.eps_r,
+ "EarthLayer.eps_r must be positive and finite"
+ ))
+ isfinite(layer.mu_r) && layer.mu_r > zero(layer.mu_r) ||
+ throw(DomainError(
+ layer.mu_r,
+ "EarthLayer.mu_r must be positive and finite"
+ ))
+ layer.thickness > zero(layer.thickness) ||
+ throw(DomainError(layer.thickness, "EarthLayer.thickness must be positive"))
+ return layer
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct one static earth layer after floating and promoting its properties.
+"""
+function EarthLayer(rho::Real, eps_r::Real, mu_r::Real, thickness::Real)
+ values = promote(float(rho), float(eps_r), float(mu_r), float(thickness))
+ T = typeof(first(values))
+ return EarthLayer{T}(values...)
+end
+
+function EarthLayer(rho::Real, eps_r::Real, mu_r::Real)
+ values = promote(float(rho), float(eps_r), float(mu_r))
+ T = typeof(first(values))
+ return EarthLayer{T}(values..., T(Inf))
+end
+
+function _earth_layer(rho, eps_r, mu_r, thickness)
+ permittivity = eps_r === nothing ? one(float(rho)) : eps_r
+ permeability = mu_r === nothing ? one(float(rho)) : mu_r
+ return thickness === nothing ?
+ EarthLayer(rho, permittivity, permeability) :
+ EarthLayer(rho, permittivity, permeability, thickness)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Declare one static earth layer directly or as an explicit finite space.
+
+# Keywords
+
+- `rho`: electrical resistivity \\[Ω·m\\].
+- `eps_r=nothing`: relative permittivity \\[dimensionless\\]. `nothing`
+ selects unity in the resistivity scalar type.
+- `mu_r=nothing`: relative permeability \\[dimensionless\\]. `nothing`
+ selects unity in the resistivity scalar type.
+- `thickness=nothing`: layer thickness \\[m\\]. `nothing` selects a
+ semi-infinite layer.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- An `EarthLayer`, or a `Gridspace{EarthLayer}` when an explicit finite source
+ is supplied.
+"""
+function layer(;
+ rho,
+ eps_r = nothing,
+ mu_r = nothing,
+ thickness = nothing,
+ combine::Symbol = :product
+)
+ values = (rho, eps_r, mu_r, thickness)
+ return parameterize(EarthLayer, _earth_layer, values; combine)
+end
+
+function Base.convert(::Type{EarthLayer{T}}, layer::EarthLayer) where {T <: Real}
+ return EarthLayer{T}(
+ convert(T, layer.rho), convert(T, layer.eps_r),
+ convert(T, layer.mu_r), convert(T, layer.thickness)
+ )
+end
+
+Base.convert(::Type{EarthLayer{T}}, layer::EarthLayer{T}) where {T <: Real} = layer
+
+"""
+Construct the ephemeral electromagnetic material represented by an earth layer.
+"""
+function EarthMaterial(layer::EarthLayer{T}) where {T <: Real}
+ EarthMaterial{T}(layer.rho, layer.eps_r, layer.mu_r)
+end
+
+Commons.input_fields(::Type{<:EarthLayer}) = (rho=(name="electrical resistivity",unit="Ω·m"),
+ eps_r=(name="relative permittivity",unit=""),mu_r=(name="relative permeability",unit=""),
+ thickness=(name="layer thickness",unit="m"))
diff --git a/src/earth/earthmaterial.jl b/src/earth/earthmaterial.jl
new file mode 100644
index 000000000..c27113222
--- /dev/null
+++ b/src/earth/earthmaterial.jl
@@ -0,0 +1,83 @@
+"""
+$(TYPEDEF)
+
+Store the electromagnetic properties of one earth material at one frequency.
+
+`EarthMaterial` is evaluated constitutive state produced from an
+[`EarthLayer`](@ref) for analytical or finite-element computations. It is not stored in an
+[`EarthModel`](@ref) or in a materials library.
+
+$(TYPEDFIELDS)
+"""
+struct EarthMaterial{T <: Real} <: AbstractEarthMaterial
+ "Electrical resistivity \\[Ω·m\\]."
+ rho::T
+ "Relative permittivity \\[dimensionless\\]. May be negative for an artificial EquivalentHomogeneous."
+ eps_r::T
+ "Relative permeability \\[dimensionless\\]."
+ mu_r::T
+
+ @inline function EarthMaterial{T}(rho::T, eps_r::T, mu_r::T) where {T <: Real}
+ return validate(new{T}(rho, eps_r, mu_r))
+ end
+end
+
+Base.eltype(::EarthMaterial{T}) where {T} = T
+Base.eltype(::Type{EarthMaterial{T}}) where {T} = T
+
+function validate(material::EarthMaterial)
+ isnan(material.rho) && throw(DomainError(
+ material.rho,
+ "EarthMaterial.rho must not be NaN"
+ ))
+ material.rho > zero(material.rho) || throw(DomainError(
+ material.rho,
+ "EarthMaterial.rho must be positive"
+ ))
+ isfinite(material.eps_r) && !iszero(material.eps_r) || throw(DomainError(
+ material.eps_r,
+ "EarthMaterial.eps_r must be nonzero and finite"
+ ))
+ isfinite(material.mu_r) && material.mu_r > zero(material.mu_r) ||
+ throw(DomainError(
+ material.mu_r,
+ "EarthMaterial.mu_r must be positive and finite"
+ ))
+ return material
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct one ephemeral earth material after floating and promoting its
+electromagnetic properties.
+
+# Arguments
+
+- `rho`: electrical resistivity \\[Ω·m\\].
+- `eps_r`: relative permittivity \\[dimensionless\\]. Artificial equivalent
+ earth models may produce a negative value.
+- `mu_r`: relative permeability \\[dimensionless\\].
+"""
+@inline function EarthMaterial(rho::Real, eps_r::Real, mu_r::Real)
+ values = promote(float(rho), float(eps_r), float(mu_r))
+ T = typeof(first(values))
+ return EarthMaterial{T}(values...)
+end
+
+function Base.convert(::Type{EarthMaterial{T}}, material::EarthMaterial) where {T <: Real}
+ return EarthMaterial{T}(
+ convert(T, material.rho),
+ convert(T, material.eps_r),
+ convert(T, material.mu_r)
+ )
+end
+
+function Base.convert(
+ ::Type{EarthMaterial{T}}, material::EarthMaterial{T}
+) where {T <: Real}
+ material
+end
+
+Commons.input_fields(::Type{<:EarthMaterial}) = (rho=(name="electrical resistivity",unit="Ω·m"),
+ eps_r=(name="relative permittivity",unit=""),mu_r=(name="relative permeability",unit=""))
diff --git a/src/earth/earthmodel.jl b/src/earth/earthmodel.jl
new file mode 100644
index 000000000..910379514
--- /dev/null
+++ b/src/earth/earthmodel.jl
@@ -0,0 +1,210 @@
+"""
+$(TYPEDEF)
+
+Store an immutable static layered-earth description. The first layer is always
+air, and `layers` is a read-only ordered tuple.
+
+$(TYPEDFIELDS)
+"""
+struct EarthModel{T <: Real, N} <: AbstractEarthModel
+ "Whether earth interfaces are vertical rather than horizontal."
+ vertical_layers::Bool
+ "Read-only static layers beginning with semi-infinite air."
+ layers::NTuple{N, EarthLayer{T}}
+
+ function EarthModel{T, N}(
+ vertical_layers::Bool,
+ layers::NTuple{N, EarthLayer{T}}
+ ) where {T <: Real, N}
+ return validate(new{T, N}(vertical_layers, layers))
+ end
+end
+
+function EarthModel{T}(
+ vertical_layers::Bool,
+ layers::NTuple{N, EarthLayer{T}}
+) where {T <: Real, N}
+ return EarthModel{T, N}(vertical_layers, layers)
+end
+
+Base.eltype(::EarthModel{T}) where {T} = T
+Base.eltype(::Type{<:EarthModel{T}}) where {T} = T
+
+function validate(model::EarthModel)
+ for layer in model.layers
+ validate(layer)
+ end
+ length(model.layers) >= 2 || throw(ArgumentError(
+ "EarthModel.layers must contain air and at least one earth layer; " *
+ "received $(length(model.layers)) layers"
+ ))
+ air = first(model.layers)
+ isinf(air.rho) && isinf(air.thickness) || throw(ArgumentError(
+ "EarthModel.layers[1] must be semi-infinite air",
+ ))
+ model.vertical_layers && !isinf(model.layers[2].thickness) &&
+ throw(ArgumentError(
+ "EarthModel.layers[2].thickness must be infinite for vertical layers",
+ ))
+ for index in 2:(length(model.layers) - 1)
+ if isinf(model.layers[index].thickness)
+ model.vertical_layers && index == 2 && continue
+ throw(ArgumentError(
+ "EarthModel.layers[$index].thickness must be finite because " *
+ "only the final horizontal layer may be semi-infinite"
+ ))
+ end
+ end
+ return model
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct a complete immutable static earth model. Frequencies are supplied
+later by a `LineParametersProblem`.
+"""
+function EarthModel(
+ rho::Real,
+ eps_r::Real = one(float(rho)),
+ mu_r::Real = one(float(rho));
+ thickness::Real = oftype(float(rho), Inf),
+ vertical_layers::Bool = false,
+ air_layer::Union{Nothing, EarthLayer} = nothing
+)
+ T = promote_type(
+ typeof(float(rho)), typeof(float(eps_r)), typeof(float(mu_r)),
+ typeof(float(thickness)),
+ air_layer === nothing ? typeof(float(rho)) : eltype(air_layer)
+ )
+ air = air_layer === nothing ?
+ EarthLayer{T}(T(Inf), convert(T, 1), convert(T, 1), T(Inf)) :
+ convert(EarthLayer{T}, air_layer)
+ earth = EarthLayer(
+ convert(T, rho), convert(T, eps_r), convert(T, mu_r),
+ convert(T, thickness)
+ )
+ return EarthModel{T}(
+ vertical_layers, (air, convert(EarthLayer{T}, earth))
+ )
+end
+
+function _earth_model(layers, vertical_layers, air_layer)
+ vertical_layers isa Bool || throw(ArgumentError(
+ "vertical_layers must be true or false"
+ ))
+ declared = if layers isa EarthLayer
+ (layers,)
+ elseif layers isa Union{Tuple, AbstractVector}
+ all(layer -> layer isa EarthLayer, layers) || throw(ArgumentError(
+ "earth layers must contain completed EarthLayer objects"
+ ))
+ Tuple(layers)
+ else
+ throw(ArgumentError(
+ "earth layers must be an EarthLayer or a nonempty layer collection"
+ ))
+ end
+ isempty(declared) && throw(ArgumentError(
+ "an earth model requires at least one earth layer"
+ ))
+ air_layer isa Union{Nothing, EarthLayer} || throw(ArgumentError(
+ "air_layer must be nothing or a completed EarthLayer"
+ ))
+ T = promote_type(
+ eltype.(declared)...,
+ air_layer === nothing ? eltype(first(declared)) : eltype(air_layer)
+ )
+ air = air_layer === nothing ?
+ EarthLayer{T}(T(Inf), convert(T, 1), convert(T, 1), T(Inf)) :
+ convert(EarthLayer{T}, air_layer)
+ earth = map(layer -> convert(EarthLayer{T}, layer), declared)
+ return EarthModel{T}(vertical_layers, (air, earth...))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build a complete immutable static earth model from one or more completed earth
+layers. A semi-infinite air layer is prepended unless `air_layer` is supplied.
+
+# Arguments
+
+- `layers`: one `EarthLayer` or an ordered collection. Horizontal models are
+ ordered from the earth surface downward.
+
+# Keywords
+
+- `vertical_layers=false`: whether earth interfaces are vertical.
+- `air_layer=nothing`: optional explicit semi-infinite air layer.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- An `EarthModel`, or a `Gridspace{EarthModel}` when an explicit finite source
+ is supplied.
+"""
+function build(
+ ::Type{EarthModel},
+ layers;
+ vertical_layers = false,
+ air_layer = nothing,
+ combine::Symbol = :product
+)
+ values = (layers, vertical_layers, air_layer)
+ return parameterize(EarthModel, _earth_model, values; combine)
+end
+
+function Base.convert(
+ ::Type{EarthModel{T}}, model::EarthModel{U, N}
+) where {T <: Real, U <: Real, N}
+ return EarthModel{T}(
+ model.vertical_layers,
+ ntuple(index -> convert(EarthLayer{T}, model.layers[index]), N)
+ )
+end
+
+Base.convert(::Type{EarthModel{T}}, model::EarthModel{T}) where {T <: Real} = model
+
+"""
+$(TYPEDSIGNATURES)
+
+Declare a homogeneous earth model through [`layer`](@ref) and
+`build(EarthModel, ...)`. Semi-infinite air is implicit.
+
+# Keywords
+
+- `rho`: electrical resistivity \\[Ω·m\\].
+- `eps_r=nothing`: relative permittivity \\[dimensionless\\]. `nothing`
+ selects unity in the resistivity scalar type.
+- `mu_r=nothing`: relative permeability \\[dimensionless\\]. `nothing`
+ selects unity in the resistivity scalar type.
+- `thickness=nothing`: earth-layer thickness \\[m\\]. `nothing` selects a
+ semi-infinite earth layer.
+- `vertical_layers=false`: whether earth interfaces are vertical.
+- `air_layer=nothing`: optional explicit semi-infinite air layer.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- An `EarthModel`, or a `Gridspace{EarthModel}` when an explicit finite source
+ is supplied.
+"""
+function homogeneous(;
+ rho,
+ eps_r = nothing,
+ mu_r = nothing,
+ thickness = nothing,
+ vertical_layers = false,
+ air_layer = nothing,
+ combine::Symbol = :product
+)
+ earth_layer = layer(; rho, eps_r, mu_r, thickness, combine)
+ return build(
+ EarthModel,
+ earth_layer;
+ vertical_layers,
+ air_layer,
+ combine
+ )
+end
diff --git a/src/earth/equivalenthomogeneous/EquivalentHomogeneous.jl b/src/earth/equivalenthomogeneous/EquivalentHomogeneous.jl
new file mode 100644
index 000000000..2dff242ba
--- /dev/null
+++ b/src/earth/equivalenthomogeneous/EquivalentHomogeneous.jl
@@ -0,0 +1,49 @@
+"""
+ LineCableModels.Earth.EquivalentHomogeneous
+
+Define equivalent homogeneous-earth rules used when a homogeneous earth-return
+formulation consumes a horizontally layered [`EarthModel`](@ref).
+
+Material frequency dependence and equivalent-earth reduction remain separate
+operations. [`AfterFD`](@ref) and [`BeforeFD`](@ref) select their composition
+order through dispatch.
+
+# Dependencies
+
+$(IMPORTS)
+"""
+module EquivalentHomogeneous
+import ...Commons: FormulationOptions
+import ...Commons: formulation_options, initialize_buffers, formulas
+import ...LineCableModels: validate
+
+export Formula, AfterFD, BeforeFD
+export formula_id, formulas, rule
+
+#! explicit-imports: off
+using DocStringExtensions: IMPORTS, TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+#! explicit-imports: on
+using ..Earth: EarthMaterial, EarthModel
+import ...Commons: AbstractFormulation, Functor
+import ...LineCableModels: FormulaDefinition, Expression, formula_id
+#! explicit-imports: off
+import ...LineCableModels: description
+#! explicit-imports: on
+
+include("interface.jl")
+
+public equivalent_material, AbstractRule, AbstractSequence
+
+#! explicit-imports: off
+const FORMULAS = (
+ include("formulas/bottommost.jl"),
+ include("formulas/default.jl"),
+)
+#! explicit-imports: on
+
+"""
+Return the built-in equivalent homogeneous-earth formula identifiers.
+"""
+formulas(::Type{<:Formula}) = FORMULAS
+
+end # module EquivalentHomogeneous
diff --git a/src/earth/equivalenthomogeneous/formulas/bottommost.jl b/src/earth/equivalenthomogeneous/formulas/bottommost.jl
new file mode 100644
index 000000000..114b4ae6e
--- /dev/null
+++ b/src/earth/equivalenthomogeneous/formulas/bottommost.jl
@@ -0,0 +1,47 @@
+
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Homogeneous earth represented by the deepest soil layer.
+
+**Expression.** Select the deepest layer's resistivity \\[Ω·m\\], relative
+permittivity, and relative permeability (both dimensionless). Property
+vectors use index 1 for air and indices 2 through N for soil. This default
+selects index N for every conductor-pair layout.
+
+**Scope.** Martins-Britto et al. found that deep-layer conductivity predominated
+in the magnetic ground-return impedance of the multilayer soil cases they
+studied. This supports using deep-layer resistivity as a default, subject to
+the soil structure and frequency range. Large conductivity contrasts and
+high-frequency effects limit that approximation. Selecting one layer does
+not implement their equivalent-conductivity formula, and their result does
+not establish the accuracy of selecting its permittivity or permeability.
+
+Select a different rule to change the equivalent material. See [`Formula`](@ref).
+
+**Reference.** A. G. Martins-Britto, F. V. Lopes, and S. R. M. J. Rondineau,
+“Multilayer Earth Structure Approximation by a Homogeneous Conductivity Soil
+for Ground Return Impedance Calculations,” *IEEE Transactions on Power
+Delivery*, 35(2), 881–891, 2020.
+[DOI: 10.1109/TPWRD.2019.2930406](https://doi.org/10.1109/TPWRD.2019.2930406).
+"""
+description(::Type{<:Formula{:bottommost}}; compact::Bool=false) = compact ? "Bottommost" : "Bottommost earth layer"
+
+function Formula{:bottommost}(; parameters::NamedTuple=(;), options::Union{NamedTuple, FormulationOptions} = FormulationOptions())
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ isempty(parameters) || throw(ArgumentError("bottommost earth has no configurable model parameters"))
+ return Formula{:bottommost, typeof(parameters), typeof(options)}(parameters, options)
+end
+
+function equivalent_material(
+ ::Formula{:bottommost}, ::Union{Val{:self}, Val{:mutual}}, ::Val{S}, ::Val{T},
+ functor, workspace
+) where {S, T}
+ S >= 1 && T >= 1 || throw(ArgumentError("physical layer indices must be positive"))
+ (; rho, eps_r, mu_r) = functor.input
+ return EarthMaterial(rho[end], eps_r[end], mu_r[end])
+end
+
+formulation_options(::Expression{<:Formula{:bottommost}, typeof(equivalent_material)}) = FormulationOptions()
+
+:bottommost
diff --git a/src/earth/equivalenthomogeneous/formulas/default.jl b/src/earth/equivalenthomogeneous/formulas/default.jl
new file mode 100644
index 000000000..34d685810
--- /dev/null
+++ b/src/earth/equivalenthomogeneous/formulas/default.jl
@@ -0,0 +1,12 @@
+"""
+$(TYPEDSIGNATURES)
+
+Route the default selection to the explicit `:bottommost` implementation.
+No numerical equation is owned by `:default`.
+"""
+description(::Type{<:Formula{:default}}; compact::Bool=false) =
+ compact ? "Default" : "Default routing to :bottommost"
+
+Formula{:default}(; kwargs...) = Formula{:bottommost}(; kwargs...)
+
+:default
diff --git a/src/earth/equivalenthomogeneous/interface.jl b/src/earth/equivalenthomogeneous/interface.jl
new file mode 100644
index 000000000..35bb34167
--- /dev/null
+++ b/src/earth/equivalenthomogeneous/interface.jl
@@ -0,0 +1,213 @@
+"""
+Abstract equivalent homogeneous-earth rule.
+"""
+abstract type AbstractRule <: AbstractFormulation end
+
+"""
+Abstract ordering of material frequency dependence and EquivalentHomogeneous reduction.
+"""
+abstract type AbstractSequence <: AbstractFormulation end
+
+"""
+$(TYPEDEF)
+
+Select one equivalent homogeneous-earth rule by its stable formula
+identifier.
+
+Each rule implements
+`equivalent_material(selected, Val(kind), Val(source), Val(target), functor, workspace)` for
+the conductor pairs it reduces. The input of `functor` holds the evaluated layer properties
+`rho`, `eps_r` and `mu_r`, the earth `model`, the physical conductor `pair`, the `frequency`
+and the `options`. The method returns one artificial homogeneous [`EarthMaterial`](@ref).
+Formula parameters participate in the concrete Julia type.
+
+The `:default` formula uses the bottommost soil layer as the equivalent
+homogeneous material.
+
+$(TYPEDFIELDS)
+"""
+struct Formula{ID, A <: NamedTuple, O <: FormulationOptions} <: AbstractRule
+ "Explicit model parameters."
+ parameters::A
+ "Explicit numerical sections owned by the reduction."
+ options::O
+end
+
+"""
+$(TYPEDEF)
+
+Apply material frequency dependence to every physical layer before the EquivalentHomogeneous
+rule constructs an equivalent material.
+
+$(TYPEDFIELDS)
+"""
+struct AfterFD{R <: AbstractRule} <: AbstractSequence
+ "Equivalent homogeneous-earth rule."
+ rule::R
+end
+
+"""
+$(TYPEDEF)
+
+Construct an equivalent material from static layers before applying the
+selected material frequency dependence to that artificial material.
+
+$(TYPEDFIELDS)
+"""
+struct BeforeFD{R <: AbstractRule} <: AbstractSequence
+ "Equivalent homogeneous-earth rule."
+ rule::R
+end
+
+"""
+Return the rule stored by an EquivalentHomogeneous composition.
+"""
+rule(sequence::AbstractSequence) = sequence.rule
+
+function initialize_buffers(sequence::AbstractSequence, ::Type{T}, input, plan,
+ buffers) where {T}
+ return initialize_buffers(rule(sequence), T, input, plan, buffers)
+end
+
+"""
+Construct one formula-owned equivalent homogeneous-earth material.
+"""
+function equivalent_material end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct an equivalent-earth rule with model parameters and numerical controls.
+Custom rules subtype `AbstractRule` and extend `equivalent_material` on their
+own concrete type. The selected sequence defines its position relative to the
+frequency-dependent material law. Each registered rule defines its own identity
+constructor. Any other identifier is unknown.
+"""
+Formula{ID}(; kwargs...) where {ID} = throw(ArgumentError("unknown equivalent-earth rule :$ID"))
+
+AfterFD(identifier::Symbol; kwargs...) = AfterFD(Formula(identifier; kwargs...))
+BeforeFD(identifier::Symbol; kwargs...) = BeforeFD(Formula(identifier; kwargs...))
+
+function description(sequence::AfterFD;compact::Bool=false)
+ "$(description(sequence.rule;compact)) after layerwise FrequencyDependent"
+end
+function description(sequence::BeforeFD;compact::Bool=false)
+ "$(description(sequence.rule;compact)) before layerwise FrequencyDependent"
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the `equivalent_material` expression that the reduction `rule` declares for the
+conductor pair `pair`: self or mutual by its indices, from the physical layer of its source to
+that of its target.
+"""
+function Expression(rule::AbstractRule, pair)
+ kind = pair.row == pair.column ? :self : :mutual
+ return Expression(rule, equivalent_material, Val(kind), Val.(pair.layers)...)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the Functor of the reduction `rule` for one physical conductor pair at one frequency. It
+checks that the layer properties align with the layers of the earth model, that the frequency
+is positive and finite, and that the pair's layers belong to the model. The state is empty.
+"""
+function Functor(rule::AbstractRule, input::NamedTuple; workspace = nothing)
+ (; rho, eps_r, mu_r, model, pair, frequency) = input
+ length(rho) == length(eps_r) == length(mu_r) == length(model.layers) ||
+ throw(DimensionMismatch("EquivalentHomogeneous properties must align with the complete physical model"))
+ isfinite(frequency) && frequency > zero(frequency) || throw(DomainError(
+ frequency,
+ "EquivalentHomogeneous evaluation frequency must be positive and finite"
+ ))
+ validate(pair, getproperty.(model.layers, :thickness))
+ return Functor(rule, input, (;))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Check that the reduction `rule` returned an [`EarthMaterial`](@ref). Return `material`.
+"""
+function validate(material, rule::AbstractRule)
+ material isa EarthMaterial ||
+ throw(ArgumentError("an EquivalentHomogeneous contribution must return EarthMaterial"))
+ return material
+end
+
+@inline function (formula::AbstractRule)(
+ rho::AbstractVector,
+ eps_r::AbstractVector,
+ mu_r::AbstractVector,
+ model::EarthModel,
+ pair,
+ frequency::Real; workspace = nothing
+)
+ expression = validate(Expression(formula, pair))
+ options = only(formulation_options(formula, (expression,)).options)
+ functor = Functor(formula, (; rho, eps_r, mu_r, model, pair, frequency, options);
+ workspace)
+ return validate(expression(functor, workspace), formula)
+end
+
+function AbstractSequence(selection::FormulaDefinition{ID, Order}) where {ID, Order}
+ selection.equivalent_earth === nothing ||
+ throw(ArgumentError("a reduction cannot contain another reduction"))
+ rule = Formula{ID}(; parameters = selection.parameters,
+ options = selection.options)
+ return Order === :before ? BeforeFD(rule) : AfterFD(rule)
+end
+
+"""Expose the reduction rule, model parameters and numerical options as a native record."""
+function Base.NamedTuple(value::Formula)
+ return (identifier=formula_id(value), parameters=value.parameters,
+ options=value.options.data)
+end
+
+"""Expose the order of material evaluation and the selected equivalent-earth rule."""
+Base.NamedTuple(value::AfterFD) = (order = :after, rule = NamedTuple(value.rule))
+Base.NamedTuple(value::BeforeFD) = (order = :before, rule = NamedTuple(value.rule))
+
+Formula(identifier::Symbol; kwargs...) = Formula(Val(identifier); kwargs...)
+Formula(::Val{ID}; kwargs...) where {ID} = Formula{ID}(; kwargs...)
+Formula(selected::AbstractRule) = selected
+
+function Formula(selection::FormulaDefinition{ID, Order}) where {ID, Order}
+ Order === :default || throw(ArgumentError("order applies only to equivalent_earth"))
+ selection.equivalent_earth === nothing ||
+ throw(ArgumentError("a reduction cannot contain another reduction"))
+ return Formula{ID}(; parameters = selection.parameters,
+ options = selection.options)
+end
+
+"""
+Return the stable formula identifier of an EquivalentHomogeneous formula.
+"""
+formula_id(::Formula{ID}) where {ID} = ID
+formula_id(::Type{<:Formula{ID}}) where {ID} = ID
+# Identity-only dispatch also describes retained selections without constructors.
+description(value::Formula; compact::Bool = false) = description(typeof(value); compact)
+formulation_options(value::Formula) = value.options
+
+"""Iterate the independently selectable child slots admitted by this formula family."""
+Base.pairs(::Type{<:Formula}; quantity = nothing) = pairs((;))
+
+"""Describe an explicit equivalent-earth rule and its requested material-law order."""
+description(owner::Type{FormulaDefinition},slot::Val{:equivalent_earth},value::FormulaDefinition;
+ compact::Bool=true) = description(owner,slot,NamedTuple(value);compact)
+function description(::Type{FormulaDefinition},::Val{:equivalent_earth},value::NamedTuple;
+ compact::Bool=true)
+ record=get(value,:rule,value)
+ text=record.identifier in formulas(Formula) ? description(Formula{record.identifier};compact) :
+ string(record.identifier)
+ order=get(value,:order,:default)
+ order===:default || (text *= " "*string(order)*" FrequencyDependent")
+ controls=(; (key => record[key] for key in (:parameters, :options)
+ if haskey(record,key) && !isempty(record[key]))...)
+ isempty(controls) || (text *= " "*description(FormulaDefinition,controls;compact))
+ return text
+end
+description(::Type{FormulaDefinition},::Val{:equivalent_earth},value::AbstractSequence;
+ compact::Bool=true) = description(value;compact)
diff --git a/src/earth/frequencydependent/FrequencyDependent.jl b/src/earth/frequencydependent/FrequencyDependent.jl
new file mode 100644
index 000000000..a7eff5d24
--- /dev/null
+++ b/src/earth/frequencydependent/FrequencyDependent.jl
@@ -0,0 +1,57 @@
+"""
+ LineCableModels.Earth.FrequencyDependent
+
+Define measured and material-physics relations that map one soil material and
+one frequency to its frequency-dependent electromagnetic properties.
+
+The explicit `:constant` formula is the frequency-independent pass-through.
+The `:default` formula is a routing alias for `:constant`. The remaining
+registered identifiers implement literature-based frequency-dispersive laws.
+
+# Dependencies
+
+$(IMPORTS)
+"""
+module FrequencyDependent
+import ...Commons: FormulationOptions, formulas
+import ...Commons: formulation_options
+import ...LineCableModels: FormulaDefinition
+
+export Formula, formula_id, formulas
+public FrequencyDependentFormulation, earth_material
+
+#! explicit-imports: off
+using DocStringExtensions: IMPORTS, TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+#! explicit-imports: on
+using ..Earth: EarthMaterial
+import ...Commons: AbstractFormulation, Functor
+import ...LineCableModels: Expression, constitutive, formula_id, validate
+#! explicit-imports: off
+import ...LineCableModels: description
+using ...Commons: vacuum_permittivity
+#! explicit-imports: on
+
+include("interface.jl")
+
+#! explicit-imports: off
+const FORMULAS = (
+ include("formulas/alipio2014.jl"),
+ include("formulas/cigre2019.jl"),
+ include("formulas/constant.jl"),
+ include("formulas/datsios2019.jl"),
+ include("formulas/default.jl"),
+ include("formulas/longmire1975.jl"),
+ include("formulas/messier1985.jl"),
+ include("formulas/portela1999.jl"),
+ include("formulas/scott1967.jl"),
+ include("formulas/visacro1987.jl"),
+ include("formulas/visacro2012.jl"),
+)
+#! explicit-imports: on
+
+"""
+Return the built-in frequency-dependent earth-material formula identifiers.
+"""
+formulas(::Type{<:Formula}) = FORMULAS
+
+end # module FrequencyDependent
diff --git a/src/earth/frequencydependent/formulas/alipio2014.jl b/src/earth/frequencydependent/formulas/alipio2014.jl
new file mode 100644
index 000000000..3c6b270eb
--- /dev/null
+++ b/src/earth/frequencydependent/formulas/alipio2014.jl
@@ -0,0 +1,69 @@
+# Construct `:alipio2014` with the fitted parameters of the Alipio-Visacro causal soil
+# model as parameter defaults.
+function Formula{:alipio2014}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions())
+ defaults = (exponent = 0.54, epsilon_infinity = 12.0, scale = 1.26,
+ conductivity_exponent = -0.73)
+ return Formula{:alipio2014}(defaults, parameters, options)
+end
+
+function validate(selected::Formula{:alipio2014})
+ exponent = selected.parameters.exponent
+ isinteger(exponent) && isodd(exponent) && throw(ArgumentError(
+ "Alipio–Visacro exponent must not be an odd integer (tangent pole)"))
+ return selected
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Causal power law fitted by Alipio and Visacro to measured soil dispersion.
+The relation evaluates conductivity and relative permittivity from the static
+reference resistivity. Ametani is not involved in this model.
+
+**Expression.** With ``\\widehat\\sigma_0=1000/\\rho_0`` in mS/m,
+``\\gamma=0.54``, and ``D=1.26\\widehat\\sigma_0^{-0.73}``,
+
+```math
+\\widehat\\sigma(f)=\\widehat\\sigma_0
+\\left[1+D\\left(\\frac{f}{10^6}\\right)^\\gamma\\right],
+```
+
+```math
+\\varepsilon_r(f)=12+
+\\tan\\left(\\frac{\\pi\\gamma}{2}\\right)
+\\frac{10^{-3}\\widehat\\sigma_0D f^{\\gamma-1}}
+{2\\pi\\varepsilon_0\\cdot10^{6\\gamma}}.
+```
+"""
+function description(::Type{<:Formula{:alipio2014}}; compact::Bool=false)
+ compact ? "Alipio" : "Alipio–Visacro causal soil dispersion (2014)"
+end
+
+function earth_material(formula::Formula{:alipio2014}, functor, workspace)
+ (; material, frequency) = functor.input
+ T = typeof(frequency)
+ parameters = formula.parameters
+ gamma = convert(T, parameters.exponent)
+ epsilon_infinity = convert(T, parameters.epsilon_infinity)
+ scale = convert(T, parameters.scale)
+ conductivity_exponent = convert(T, parameters.conductivity_exponent)
+ thousand = convert(T, 1000)
+ million = convert(T, 1e6)
+ conductivity_reference = thousand / material.rho
+ dispersion = scale * conductivity_reference^conductivity_exponent
+ epsilon0 = vacuum_permittivity(typeof(frequency))
+ relative_permittivity = epsilon_infinity +
+ tan((one(frequency) * π) * gamma / 2) *
+ convert(T, 1e-3) * conductivity_reference * dispersion *
+ frequency^(gamma - one(gamma)) /
+ (2 * (one(frequency) * π) * epsilon0 *
+ convert(T, 10)^(6 * gamma))
+ conductivity = conductivity_reference *
+ (one(frequency) + dispersion * (frequency / million)^gamma)
+ return EarthMaterial{T}(thousand / conductivity, relative_permittivity, material.mu_r)
+end
+
+formulation_options(::Expression{<:Formula{:alipio2014}, typeof(earth_material)}) = FormulationOptions()
+
+:alipio2014
diff --git a/src/earth/frequencydependent/formulas/cigre2019.jl b/src/earth/frequencydependent/formulas/cigre2019.jl
new file mode 100644
index 000000000..ad1d32216
--- /dev/null
+++ b/src/earth/frequencydependent/formulas/cigre2019.jl
@@ -0,0 +1,51 @@
+# Construct `:cigre2019` with the fitted parameters recommended by CIGRE Technical
+# Brochure 781 as parameter defaults.
+function Formula{:cigre2019}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions())
+ defaults = (epsilon_infinity = 12.0, epsilon_scale = 9.5e4,
+ epsilon_conductivity_exponent = 0.27, epsilon_frequency_exponent = -0.46,
+ conductivity_scale = 4.7e-6, conductivity_frequency_exponent = 0.54)
+ return Formula{:cigre2019}(defaults, parameters, options)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+CIGRE WG C4.33 recommended empirical soil-dispersion relation.
+
+**Expression.** With ``\\sigma_0=1/\\rho_0``,
+
+```math
+\\varepsilon_r(f)=12+9.5\\times10^4\\sigma_0^{0.27}f^{-0.46},
+\\qquad
+\\sigma(f)=\\sigma_0+4.7\\times10^{-6}\\sigma_0^{0.27}f^{0.54}.
+```
+
+The fitted constants are exposed through the formula parameters.
+
+**Reference.** CIGRE WG C4.33, Technical Brochure 781 (2019).
+"""
+function description(::Type{<:Formula{:cigre2019}}; compact::Bool=false)
+ compact ? "CIGRE" : "CIGRE WG C4.33 recommended soil dispersion (2019)"
+end
+
+function earth_material(formula::Formula{:cigre2019}, functor, workspace)
+ (; material, frequency) = functor.input
+ T = typeof(frequency)
+ parameters = formula.parameters
+ conductivity_reference = inv(material.rho)
+ conductivity_exponent = convert(T, parameters.epsilon_conductivity_exponent)
+ relative_permittivity = convert(T, parameters.epsilon_infinity) +
+ convert(T, parameters.epsilon_scale) *
+ conductivity_reference^conductivity_exponent *
+ frequency^convert(T, parameters.epsilon_frequency_exponent)
+ conductivity = conductivity_reference +
+ convert(T, parameters.conductivity_scale) *
+ conductivity_reference^conductivity_exponent *
+ frequency^convert(T, parameters.conductivity_frequency_exponent)
+ return EarthMaterial{T}(inv(conductivity), relative_permittivity, material.mu_r)
+end
+
+formulation_options(::Expression{<:Formula{:cigre2019}, typeof(earth_material)}) = FormulationOptions()
+
+:cigre2019
diff --git a/src/earth/frequencydependent/formulas/constant.jl b/src/earth/frequencydependent/formulas/constant.jl
new file mode 100644
index 000000000..3cca1dde5
--- /dev/null
+++ b/src/earth/frequencydependent/formulas/constant.jl
@@ -0,0 +1,44 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Frequency-independent earth-material pass-through.
+
+**Expression.**
+
+```math
+\\rho(f)=\\rho_0,\\qquad
+\\varepsilon_r(f)=\\varepsilon_{r,0},\\qquad
+\\mu_r(f)=\\mu_{r,0}.
+```
+
+This relation preserves the static material at every positive evaluation
+frequency. It is the explicit equation selected by `:default`.
+"""
+function description(::Type{<:Formula{:constant}}; compact::Bool=false)
+ compact ? "Constant" : "Constant frequency-independent earth material"
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Preserve the supplied static earth properties at the requested frequency.
+
+# Arguments
+
+- `functor`: the Functor of the evaluation point. Its input holds:
+ - `material`: static earth material.
+ - `frequency`: evaluation frequency \\[Hz\\].
+ - `options`: the normalized formulation options of the formula.
+- `workspace`: optional execution resources.
+
+# Returns
+
+- The unchanged `EarthMaterial`.
+"""
+function earth_material(::Formula{:constant}, functor, workspace)
+ return functor.input.material
+end
+
+formulation_options(::Expression{<:Formula{:constant}, typeof(earth_material)}) = FormulationOptions()
+
+:constant
diff --git a/src/earth/frequencydependent/formulas/datsios2019.jl b/src/earth/frequencydependent/formulas/datsios2019.jl
new file mode 100644
index 000000000..0cb2e2f50
--- /dev/null
+++ b/src/earth/frequencydependent/formulas/datsios2019.jl
@@ -0,0 +1,70 @@
+# Construct `:datsios2019` with the dry-soil parameter of the Datsios-Mikropoulos relation
+# as parameter defaults.
+function Formula{:datsios2019}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions())
+ defaults = (dry_permittivity = 3.5,)
+ return Formula{:datsios2019}(defaults, parameters, options)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Datsios-Mikropoulos two-limit fit for sandy soil, with relative permittivity
+held at its 3 kHz value below the fitted frequency limit.
+
+**Expression.** With ``\\widehat\\sigma_{42}=10^4/\\rho_0`` in μS/cm and
+dry permittivity ``\\varepsilon_d=3.5``,
+
+```math
+p=0.537\\widehat\\sigma_{42}^{0.16},\\quad
+\\varepsilon_\\infty=1.24\\widehat\\sigma_{42}^{0.415}\\varepsilon_d,
+\\quad
+\\varepsilon_{3k}=4\\widehat\\sigma_{42}^{0.463}(2.9\\varepsilon_d-3.8).
+```
+
+The conductivity interpolation is evaluated between the measured 42 Hz and
+high-frequency limits.
+
+**Reference.** Z. G. Datsios and P. N. Mikropoulos, *IEEE Transactions on
+Dielectrics and Electrical Insulation*, 26(3), 2019.
+"""
+function description(::Type{<:Formula{:datsios2019}}; compact::Bool=false)
+ compact ? "Datsios" : "Datsios–Mikropoulos two-limit soil fit (2019)"
+end
+
+function earth_material(formula::Formula{:datsios2019}, functor, workspace)
+ (; material, frequency) = functor.input
+ T = typeof(frequency)
+ parameters = formula.parameters
+ conductivity_low = convert(T, 1e4) / material.rho
+ frequency_low = convert(T, 42)
+ frequency_boundary = convert(T, 3000)
+ dry_permittivity = convert(T, parameters.dry_permittivity)
+
+ permittivity_exponent = convert(T, 0.537) * conductivity_low^convert(T, 0.16)
+ dry_permittivity_3khz = convert(T, 2.9) * dry_permittivity - convert(T, 3.8)
+ permittivity_high = convert(T, 1.24) *
+ conductivity_low^convert(T, 0.415) * dry_permittivity
+ permittivity_3khz = convert(T, 4) *
+ conductivity_low^convert(T, 0.463) * dry_permittivity_3khz
+ permittivity_frequency = frequency < frequency_boundary ?
+ frequency_boundary : frequency
+ relative_permittivity = permittivity_high +
+ (frequency_boundary / permittivity_frequency)^permittivity_exponent *
+ (permittivity_3khz - permittivity_high)
+
+ conductivity_high = conductivity_low * (
+ one(frequency) + convert(T, 0.65) / conductivity_low^convert(T, 0.57)
+ )
+ micro = convert(T, 1e-6)
+ conductivity_micro_siemens_per_centimetre =
+ conductivity_high * frequency * micro +
+ (frequency_low - frequency_low * (frequency - frequency_low) * micro) *
+ (conductivity_low / frequency_low - conductivity_high * micro)
+ conductivity = convert(T, 1e-4) * conductivity_micro_siemens_per_centimetre
+ return EarthMaterial{T}(inv(conductivity), relative_permittivity, material.mu_r)
+end
+
+formulation_options(::Expression{<:Formula{:datsios2019}, typeof(earth_material)}) = FormulationOptions()
+
+:datsios2019
diff --git a/src/earth/frequencydependent/formulas/default.jl b/src/earth/frequencydependent/formulas/default.jl
new file mode 100644
index 000000000..033312676
--- /dev/null
+++ b/src/earth/frequencydependent/formulas/default.jl
@@ -0,0 +1,12 @@
+"""
+$(TYPEDSIGNATURES)
+
+Route the default selection to the explicit `:constant` implementation.
+No numerical equation is owned by `:default`.
+"""
+description(::Type{<:Formula{:default}}; compact::Bool=false) =
+ compact ? "Default" : "Default routing to :constant"
+
+Formula{:default}(; kwargs...) = Formula{:constant}(; kwargs...)
+
+:default
diff --git a/src/earth/frequencydependent/formulas/longmire1975.jl b/src/earth/frequencydependent/formulas/longmire1975.jl
new file mode 100644
index 000000000..b9f3d5fa0
--- /dev/null
+++ b/src/earth/frequencydependent/formulas/longmire1975.jl
@@ -0,0 +1,74 @@
+const LONGMIRE_SMITH_COEFFICIENTS = (
+ 3.4e6, 2.74e5, 2.58e4, 3.38e3, 5.26e2, 1.33e2, 2.72e1,
+ 1.25e1, 4.8, 2.17, 0.98, 0.392, 0.173
+)
+
+# Construct `:longmire1975` with the high-frequency and relaxation parameters of
+# Longmire-Smith as parameter defaults.
+function Formula{:longmire1975}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions())
+ defaults = (epsilon_infinity = 5.0, corner_scale = 125.0, corner_exponent = 0.8312)
+ return Formula{:longmire1975}(defaults, parameters, options)
+end
+
+function validate(selected::Formula{:longmire1975})
+ selected.parameters.corner_scale > 0 || throw(ArgumentError(
+ "Longmire–Smith corner_scale must be positive"))
+ return selected
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Longmire-Smith thirteen-term dielectric-relaxation soil model.
+
+**Expression.** With ``\\sigma_0=1/\\rho_0``, base corner
+``f_c=(125\\sigma_0)^{0.8312}``, tabulated coefficients ``a_n``, and
+``f_n=10^{n-1}f_c``,
+
+```math
+\\varepsilon_r(f)=5+\\sum_{n=1}^{13}
+\\frac{a_n}{1+(f/f_n)^2},
+```
+
+```math
+\\sigma(f)=\\sigma_0+2\\pi f\\varepsilon_0
+\\sum_{n=1}^{13}\\frac{a_n(f/f_n)}{1+(f/f_n)^2}.
+```
+
+**Reference.** C. L. Longmire and K. S. Smith, *A Universal Impedance for
+Soils*, Defense Nuclear Agency, 1975.
+"""
+function description(::Type{<:Formula{:longmire1975}}; compact::Bool=false)
+ compact ? "Longmire" : "Longmire–Smith 13-term dielectric relaxation (1975)"
+end
+
+function earth_material(formula::Formula{:longmire1975}, functor, workspace)
+ (; material, frequency) = functor.input
+ T = typeof(frequency)
+ parameters = formula.parameters
+ conductivity_reference = inv(material.rho)
+ corner = (convert(T, parameters.corner_scale) * conductivity_reference)^convert(T, parameters.corner_exponent)
+ permittivity_sum = zero(frequency)
+ conductivity_sum = zero(frequency)
+ decade = one(frequency)
+ ten = convert(T, 10)
+ @inbounds for coefficient in LONGMIRE_SMITH_COEFFICIENTS
+ corner_frequency = corner * decade
+ ratio = frequency / corner_frequency
+ denominator = one(frequency) + ratio^2
+ typed_coefficient = convert(T, coefficient)
+ permittivity_sum += typed_coefficient / denominator
+ conductivity_sum += typed_coefficient * ratio / denominator
+ decade *= ten
+ end
+ relative_permittivity = convert(T, parameters.epsilon_infinity) + permittivity_sum
+ conductivity = conductivity_reference +
+ 2 * (one(frequency) * π) * frequency *
+ vacuum_permittivity(typeof(frequency)) * conductivity_sum
+ return EarthMaterial{T}(inv(conductivity), relative_permittivity, material.mu_r)
+end
+
+formulation_options(::Expression{<:Formula{:longmire1975}, typeof(earth_material)}) = FormulationOptions()
+
+:longmire1975
diff --git a/src/earth/frequencydependent/formulas/messier1985.jl b/src/earth/frequencydependent/formulas/messier1985.jl
new file mode 100644
index 000000000..eb4040e1a
--- /dev/null
+++ b/src/earth/frequencydependent/formulas/messier1985.jl
@@ -0,0 +1,57 @@
+# Construct `:messier1985` with the high-frequency permittivity parameter of the Messier
+# relation as parameter defaults.
+function Formula{:messier1985}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions())
+ defaults = (epsilon_infinity = 8.0,)
+ return Formula{:messier1985}(defaults, parameters, options)
+end
+
+function validate(selected::Formula{:messier1985})
+ selected.parameters.epsilon_infinity >= 0 || throw(ArgumentError(
+ "Messier epsilon_infinity must be nonnegative for the real square roots"))
+ return selected
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Messier square-root dispersive soil model.
+
+**Expression.** With ``\\sigma_0=1/\\rho_0`` and
+``\\varepsilon_\\infty=8``,
+
+```math
+\\varepsilon_r(f)=\\varepsilon_\\infty+
+\\sqrt{\\frac{\\sigma_0\\varepsilon_\\infty}{\\pi f\\varepsilon_0}},
+\\qquad
+\\sigma(f)=\\sigma_0+
+\\sqrt{4\\pi f\\sigma_0\\varepsilon_0\\varepsilon_\\infty}.
+```
+
+**Reference.** M. Messier, *Another Soil Conductivity Model*, JAYCOR,
+Santa Barbara, 1985.
+"""
+function description(::Type{<:Formula{:messier1985}}; compact::Bool=false)
+ compact ? "Messier" : "Messier square-root soil dispersion (1985)"
+end
+
+function earth_material(formula::Formula{:messier1985}, functor, workspace)
+ (; material, frequency) = functor.input
+ T = typeof(frequency)
+ parameters = formula.parameters
+ conductivity_reference = inv(material.rho)
+ epsilon_infinity = convert(T, parameters.epsilon_infinity)
+ epsilon0 = vacuum_permittivity(typeof(frequency))
+ pi_typed = one(frequency) * π
+ relative_permittivity = epsilon_infinity + sqrt(
+ conductivity_reference * epsilon_infinity / (pi_typed * frequency * epsilon0)
+ )
+ conductivity = conductivity_reference + sqrt(
+ 4 * pi_typed * frequency * conductivity_reference * epsilon0 * epsilon_infinity
+ )
+ return EarthMaterial{T}(inv(conductivity), relative_permittivity, material.mu_r)
+end
+
+formulation_options(::Expression{<:Formula{:messier1985}, typeof(earth_material)}) = FormulationOptions()
+
+:messier1985
diff --git a/src/earth/frequencydependent/formulas/portela1999.jl b/src/earth/frequencydependent/formulas/portela1999.jl
new file mode 100644
index 000000000..4af64dd90
--- /dev/null
+++ b/src/earth/frequencydependent/formulas/portela1999.jl
@@ -0,0 +1,57 @@
+# Construct `:portela1999` with the fitted parameters of the Portela soil-dispersion
+# relation as parameter defaults.
+function Formula{:portela1999}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions())
+ defaults = (beta = 0.1, exponent = 0.72)
+ return Formula{:portela1999}(defaults, parameters, options)
+end
+
+function validate(selected::Formula{:portela1999})
+ exponent = selected.parameters.exponent
+ isinteger(exponent) && isodd(exponent) && throw(ArgumentError(
+ "Portela exponent must not be an odd integer (tangent pole)"))
+ return selected
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Portela causal power-law soil-dispersion model.
+
+**Expression.** For ``\\omega=2\\pi f``, ``\\beta=0.1``, and
+``\\alpha=0.72``,
+
+```math
+\\sigma(f)=\\sigma_0+\\beta10^{-6}\\omega^\\alpha,
+\\qquad
+\\varepsilon_r(f)=\\frac{\\beta10^{-6}\\tan(\\pi\\alpha/2)
+\\omega^{\\alpha-1}}{\\varepsilon_0}.
+```
+
+**Reference.** C. M. Portela, “Measurement and Modeling of Soil
+Electromagnetic Behavior,” *IEEE International Symposium on Electromagnetic
+Compatibility*, 1004–1009, 1999.
+"""
+function description(::Type{<:Formula{:portela1999}}; compact::Bool=false)
+ compact ? "Portela" : "Portela power-law soil dispersion (1999)"
+end
+
+function earth_material(formula::Formula{:portela1999}, functor, workspace)
+ (; material, frequency) = functor.input
+ T = typeof(frequency)
+ parameters = formula.parameters
+ beta = convert(T, parameters.beta)
+ exponent = convert(T, parameters.exponent)
+ angular_frequency = 2 * (one(frequency) * π) * frequency
+ fitted_scale = beta * convert(T, 1e-6)
+ conductivity = inv(material.rho) + fitted_scale * angular_frequency^exponent
+ relative_permittivity = fitted_scale *
+ tan((one(frequency) * π) * exponent / 2) *
+ angular_frequency^(exponent - one(exponent)) /
+ vacuum_permittivity(typeof(frequency))
+ return EarthMaterial{T}(inv(conductivity), relative_permittivity, material.mu_r)
+end
+
+formulation_options(::Expression{<:Formula{:portela1999}, typeof(earth_material)}) = FormulationOptions()
+
+:portela1999
diff --git a/src/earth/frequencydependent/formulas/scott1967.jl b/src/earth/frequencydependent/formulas/scott1967.jl
new file mode 100644
index 000000000..c1206f7ae
--- /dev/null
+++ b/src/earth/frequencydependent/formulas/scott1967.jl
@@ -0,0 +1,52 @@
+"""
+$(TYPEDSIGNATURES)
+
+Scott-Carroll-Cunningham empirical moist-rock soil fit over its measured range
+of 100 Hz to 1 MHz.
+
+**Expression.** Let ``s=\\log_{10}(1000/\\rho_0)`` and ``x=\\log_{10}f``.
+
+```math
+\\log_{10}\\varepsilon_r=5.491+0.946s-1.097x+0.069s^2-0.114sx+0.067x^2,
+```
+
+```math
+\\log_{10}\\widehat\\sigma=0.028+1.098s-0.068x+0.036s^2-0.046sx+0.018x^2,
+\\qquad \\sigma=10^{-3}\\widehat\\sigma.
+```
+
+**Reference.** J. H. Scott, R. D. Carroll, and D. R. Cunningham,
+*Journal of Geophysical Research*, 72(20), 1967.
+"""
+function description(::Type{<:Formula{:scott1967}}; compact::Bool=false)
+ compact ? "Scott" : "Scott–Carroll–Cunningham empirical moist-soil fit (1967)"
+end
+
+function earth_material(::Formula{:scott1967}, functor, workspace)
+ (; material, frequency) = functor.input
+ T = typeof(frequency)
+ thousand = convert(T, 1000)
+ conductivity_100hz = thousand / material.rho
+ conductivity_log = log10(conductivity_100hz)
+ frequency_log = log10(frequency)
+ permittivity_log = convert(T, 5.491) +
+ convert(T, 0.946) * conductivity_log -
+ convert(T, 1.097) * frequency_log +
+ convert(T, 0.069) * conductivity_log^2 -
+ convert(T, 0.114) * conductivity_log * frequency_log +
+ convert(T, 0.067) * frequency_log^2
+ output_conductivity_log = convert(T, 0.028) +
+ convert(T, 1.098) * conductivity_log -
+ convert(T, 0.068) * frequency_log +
+ convert(T, 0.036) * conductivity_log^2 -
+ convert(T, 0.046) * conductivity_log * frequency_log +
+ convert(T, 0.018) * frequency_log^2
+ ten = convert(T, 10)
+ relative_permittivity = ten^permittivity_log
+ conductivity = ten^output_conductivity_log
+ return EarthMaterial{T}(thousand / conductivity, relative_permittivity, material.mu_r)
+end
+
+formulation_options(::Expression{<:Formula{:scott1967}, typeof(earth_material)}) = FormulationOptions()
+
+:scott1967
diff --git a/src/earth/frequencydependent/formulas/visacro1987.jl b/src/earth/frequencydependent/formulas/visacro1987.jl
new file mode 100644
index 000000000..f22cd2595
--- /dev/null
+++ b/src/earth/frequencydependent/formulas/visacro1987.jl
@@ -0,0 +1,50 @@
+# Construct `:visacro1987` with the normalization parameter of the Visacro-Portela soil
+# relation as parameter defaults.
+function Formula{:visacro1987}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions())
+ defaults = (normalization_frequency = 100.0,)
+ return Formula{:visacro1987}(defaults, parameters, options)
+end
+
+function validate(selected::Formula{:visacro1987})
+ selected.parameters.normalization_frequency > 0 || throw(ArgumentError(
+ "Visacro–Portela normalization_frequency must be positive [Hz]"))
+ return selected
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Visacro-Portela empirical power laws for soil conductivity and permittivity.
+
+**Expression.** With ``\\sigma_0=1/\\rho_0``,
+
+```math
+\\varepsilon_r(f)=2.34\\times10^6\\sigma_0^{0.535}f^{-0.597},
+\\qquad
+\\sigma(f)=\\sigma_0\\left(\\frac{f}{100}\\right)^{0.072}.
+```
+
+**Reference.** S. Visacro and C. M. Portela, *International Symposium on
+High Voltage Engineering*, 1987.
+"""
+function description(::Type{<:Formula{:visacro1987}}; compact::Bool=false)
+ compact ? "Visacro" : "Visacro–Portela empirical soil dispersion (1987)"
+end
+
+function earth_material(formula::Formula{:visacro1987}, functor, workspace)
+ (; material, frequency) = functor.input
+ T = typeof(frequency)
+ parameters = formula.parameters
+ conductivity_reference = inv(material.rho)
+ relative_permittivity = convert(T, 2.34e6) *
+ conductivity_reference^convert(T, 0.535) *
+ frequency^convert(T, -0.597)
+ conductivity = conductivity_reference *
+ (frequency / convert(T, parameters.normalization_frequency))^convert(T, 0.072)
+ return EarthMaterial{T}(inv(conductivity), relative_permittivity, material.mu_r)
+end
+
+formulation_options(::Expression{<:Formula{:visacro1987}, typeof(earth_material)}) = FormulationOptions()
+
+:visacro1987
diff --git a/src/earth/frequencydependent/formulas/visacro2012.jl b/src/earth/frequencydependent/formulas/visacro2012.jl
new file mode 100644
index 000000000..b0c6d36e2
--- /dev/null
+++ b/src/earth/frequencydependent/formulas/visacro2012.jl
@@ -0,0 +1,54 @@
+# Construct `:visacro2012` with the lower frequency limit of the Visacro-Alipio soil
+# relation as parameter defaults.
+function Formula{:visacro2012}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions())
+ defaults = (frequency_boundary = 100.0,)
+ return Formula{:visacro2012}(defaults, parameters, options)
+end
+
+function validate(selected::Formula{:visacro2012})
+ selected.parameters.frequency_boundary > 0 || throw(ArgumentError(
+ "Visacro–Alipio frequency_boundary must be positive [Hz]"))
+ return selected
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Visacro-Alipio empirical causal soil-dispersion fit with a 100 Hz lower
+frequency limit.
+
+**Expression.** With ``f_e=\\max(f,100)`` and ``\\sigma_0=1/\\rho_0``,
+
+```math
+\\varepsilon_r(f)=1.3+7.6\\times10^3 f_e^{-0.4},
+\\qquad
+\\sigma(f)=\\sigma_0+1.2\\times10^{-6}\\sigma_0^{0.27}(f_e-100)^{0.65}.
+```
+
+**Reference.** S. Visacro and R. Alipio, *IEEE Transactions on Power
+Delivery*, 27(2), 2012.
+"""
+function description(::Type{<:Formula{:visacro2012}}; compact::Bool=false)
+ compact ? "Visacro" : "Visacro–Alipio empirical soil dispersion (2012)"
+end
+
+function earth_material(formula::Formula{:visacro2012}, functor, workspace)
+ (; material, frequency) = functor.input
+ T = typeof(frequency)
+ parameters = formula.parameters
+ frequency_boundary = convert(T, parameters.frequency_boundary)
+ evaluated_frequency = frequency < frequency_boundary ?
+ frequency_boundary : frequency
+ conductivity_reference = inv(material.rho)
+ relative_permittivity = convert(T, 1.3) +
+ convert(T, 7.6e3) * evaluated_frequency^convert(T, -0.4)
+ conductivity = conductivity_reference +
+ convert(T, 1.2e-6) * conductivity_reference^convert(T, 0.27) *
+ (evaluated_frequency - frequency_boundary)^convert(T, 0.65)
+ return EarthMaterial{T}(inv(conductivity), relative_permittivity, material.mu_r)
+end
+
+formulation_options(::Expression{<:Formula{:visacro2012}, typeof(earth_material)}) = FormulationOptions()
+
+:visacro2012
diff --git a/src/earth/frequencydependent/interface.jl b/src/earth/frequencydependent/interface.jl
new file mode 100644
index 000000000..2becac5ac
--- /dev/null
+++ b/src/earth/frequencydependent/interface.jl
@@ -0,0 +1,152 @@
+"""
+Interface for a selected frequency-dependent earth-material relation.
+Concrete selections expose model `parameters` and numerical `options` records.
+"""
+abstract type FrequencyDependentFormulation <: AbstractFormulation end
+
+"""
+$(TYPEDEF)
+
+Select one frequency-dependent earth-material relation by its stable formula
+identifier.
+
+Each formula implements `earth_material(selected, functor, workspace) -> EarthMaterial` on
+its concrete selection type. The input of `functor` holds the earth material, the frequency
+and the options. `:default` is a routing alias for the explicit `:constant` pass-through.
+
+$(TYPEDFIELDS)
+"""
+struct Formula{ID, P <: NamedTuple, O <: FormulationOptions} <:
+ FrequencyDependentFormulation
+ "Resolved physical model parameters."
+ parameters::P
+ "Normalized numerical sections for this equation."
+ options::O
+end
+
+"""
+Evaluate one formula-owned frequency-dependent earth material relation.
+"""
+function earth_material end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct a selected formulation with model parameters and numerical controls.
+Custom formulations extend `earth_material` on their own concrete selection type.
+Unknown controls fail before numerical evaluation. A formula with model parameters
+defines its own identity constructor, which holds their defaults.
+"""
+function Formula{ID}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions()) where {ID}
+ return Formula{ID}((;), parameters, options)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Complete the supplied model `parameters` with a formula's `defaults`, check every
+coefficient and the formula's coefficient domains, and normalize its numerical controls.
+"""
+function Formula{ID}(defaults::NamedTuple, parameters::NamedTuple,
+ options::Union{NamedTuple, FormulationOptions}) where {ID}
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ unknown = setdiff(keys(parameters), keys(defaults))
+ isempty(unknown) || throw(ArgumentError(
+ "unknown parameters for earth-property formula :$ID: $(collect(unknown))"))
+ parameters = merge(defaults, parameters)
+ for (name, value) in pairs(parameters)
+ value isa Real && !(value isa Bool) && isfinite(value) ||
+ throw(ArgumentError("earth-property parameter :$name must be a finite real coefficient"))
+ end
+ selected = Formula{ID, typeof(parameters), typeof(options)}(parameters, options)
+ validate(selected)
+ expression = Expression(selected, earth_material)
+ normalized = formulation_options(expression, options)
+ return Formula{ID, typeof(parameters), typeof(normalized)}(parameters, normalized)
+end
+
+"""Check model-specific coefficient domains before evaluating a material."""
+validate(selected::Formula) = selected
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the Functor of a frequency-dependent earth relation for one earth material at one
+frequency, after checking the frequency. The state is empty.
+"""
+function Functor(formula::FrequencyDependentFormulation, input::NamedTuple;
+ workspace = nothing)
+ (; frequency) = input
+ isfinite(frequency) && frequency > zero(frequency) || throw(DomainError(
+ frequency, "earth-property evaluation frequency must be positive and finite"))
+ return Functor(formula, input, (;))
+end
+
+@inline function (formula::FrequencyDependentFormulation)(
+ material::EarthMaterial{T}, frequency::T; workspace = nothing
+) where {T <: Real}
+ functor = Functor(formula, (; material, frequency, options = formula.options); workspace)
+ evaluated = Expression(formula, earth_material)(functor, workspace)
+ evaluated isa EarthMaterial ||
+ throw(ArgumentError("a frequency-dependent earth relation must return EarthMaterial"))
+ return evaluated
+end
+
+function (formula::FrequencyDependentFormulation)(
+ material::EarthMaterial{T}, frequency::Real; workspace = nothing) where {T <:
+ Real}
+ U = promote_type(T, typeof(float(frequency)))
+ return formula(
+ convert(EarthMaterial{U}, material),
+ convert(U, float(frequency)); workspace
+ )
+end
+
+"""
+Pass static earth properties through when no constitutive relation is selected.
+"""
+constitutive(::Nothing, material::EarthMaterial, ::Real; workspace = nothing) = material
+
+"""
+Evaluate one registered frequency-dependent earth constitutive relation.
+"""
+function constitutive(
+ formula::FrequencyDependentFormulation, material::EarthMaterial, frequency::Real;
+ workspace = nothing)
+ formula(material, frequency; workspace)
+end
+
+Formula(identifier::Symbol; kwargs...) = Formula(Val(identifier); kwargs...)
+Formula(::Val{ID}; kwargs...) where {ID} = Formula{ID}(; kwargs...)
+Formula(selected::FrequencyDependentFormulation) = selected
+
+function Formula(selection::FormulaDefinition{ID, Order}) where {ID, Order}
+ Order === :default || throw(ArgumentError("order applies only to equivalent_earth"))
+ selection.equivalent_earth === nothing || throw(ArgumentError(
+ "equivalent_earth applies only to external earth formulas"))
+ return Formula{ID}(; parameters = selection.parameters,
+ options = selection.options)
+end
+
+"""
+Return the stable formula identifier of an earth-property formula.
+"""
+formula_id(::Formula{ID}) where {ID} = ID
+formula_id(::Type{<:Formula{ID}}) where {ID} = ID
+# Identity-only dispatch also describes retained selections without constructors.
+description(value::Formula; compact::Bool = false) = description(typeof(value); compact)
+formulation_options(value::Formula) = value.options
+
+"""
+$(TYPEDSIGNATURES)
+
+Expose the selected identity, model parameters, and numerical options as a native record.
+"""
+function Base.NamedTuple(value::Formula)
+ return (identifier = formula_id(value),
+ parameters = value.parameters, options = value.options.data)
+end
+
+"""Iterate the independently selectable child slots admitted by this formula family."""
+Base.pairs(::Type{<:Formula}; quantity = nothing) = pairs((;))
diff --git a/src/earthprops/EarthProps.jl b/src/earthprops/EarthProps.jl
deleted file mode 100644
index bb9e75ca1..000000000
--- a/src/earthprops/EarthProps.jl
+++ /dev/null
@@ -1,447 +0,0 @@
-"""
- 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")
-
-end # module EarthProps
\ No newline at end of file
diff --git a/src/earthprops/base.jl b/src/earthprops/base.jl
deleted file mode 100644
index 6f8a165fd..000000000
--- a/src/earthprops/base.jl
+++ /dev/null
@@ -1,82 +0,0 @@
-
-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
-
-end
\ No newline at end of file
diff --git a/src/earthprops/dataframe.jl b/src/earthprops/dataframe.jl
deleted file mode 100644
index 1b2e3a036..000000000
--- a/src/earthprops/dataframe.jl
+++ /dev/null
@@ -1,41 +0,0 @@
-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,
- )
-end
\ No newline at end of file
diff --git a/src/earthprops/fdprops.jl b/src/earthprops/fdprops.jl
deleted file mode 100644
index 61521303b..000000000
--- a/src/earthprops/fdprops.jl
+++ /dev/null
@@ -1,83 +0,0 @@
-"""
-$(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
diff --git a/src/earthprops/typecoercion.jl b/src/earthprops/typecoercion.jl
deleted file mode 100644
index 3e54f3c5c..000000000
--- a/src/earthprops/typecoercion.jl
+++ /dev/null
@@ -1,85 +0,0 @@
-"""
-$(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
-
diff --git a/src/engine/Engine.jl b/src/engine/Engine.jl
index 9931cf323..4d58775cc 100644
--- a/src/engine/Engine.jl
+++ b/src/engine/Engine.jl
@@ -1,62 +1,114 @@
"""
- LineCableModels.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.
+Calculate cable constants and frequency-dependent line-parameter matrices from
+completed cable declarations and Engine-owned numerical blueprints.
# 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.
+- Define scalar problems, formulations, and core results.
+- Calculate conductor, insulation, and earth-return impedance and admittance.
+- Assemble phase-domain series-impedance and shunt-admittance matrices.
+- Apply bundle reduction, Kron elimination, and ideal transposition.
+- Compare, tabulate, and describe plots of completed line-parameter results.
# Dependencies
$(IMPORTS)
-# Exports
-
-$(EXPORTS)
"""
module Engine
+import ..LineCableModels
# Export public API
-export LineParametersProblem,
- LineParameters, SeriesImpedance, ShuntAdmittance, per_km,
- per_m, kronify
-export EMTFormulation, FormulationSet, LineParamOptions
-
-export compute!, plot
+export LineParametersProblem, CableConstantsProblem,
+ LineParameters, CableConstants, SeriesImpedance, ShuntAdmittance,
+ RMSError, LineParametersBenchmark, compare,
+ absolute_error, relative_error,
+ Z, Y, R, X, L, G, B, C,
+ series_impedance, shunt_admittance,
+ resistance, reactance, inductance,
+ conductance, susceptance, capacitance,
+ frequencies, nconductors, nfrequencies, basis
+export AbstractFormulation, LineParametersFormulation, CableConstantsFormulation,
+ Formulation
+export LineCableModelsCoaxial, LineCableModelsFEM,
+ LineCableModelsFEMError, LineParametersWorkspace
+export constitutive, formula_id, EarthPair
+export verbosity
+export InternalImpedance, InsulationImpedance, EarthImpedance, PipeImpedance
+export InsulationAdmittance, SemiconAdmittance, EarthAdmittance
+export ShuntModel, BoundarySolveError
+export ModalAnalysis
+
+export compute
# Module-specific dependencies
-using Reexport, ForceImport
-using Measurements
-using LinearAlgebra
-using ..Commons
-import ..Commons: get_description, LineParamsDomain, PhaseDomain, ModalDomain, domain
-
-using ..Utils
+using LinearAlgebra: diag
+import LinearAlgebra: norm
+using DocStringExtensions: IMPORTS, TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+import ..LineCableModels: basis, line_length, build, R, L, C,
+ resistance, inductance, capacitance
+import ..LineCableModels: nominal
+import ..LineCableModels: constitutive, formula, formula_id,
+ Expression, FormulaDefinition
+import ..LineCableModels: parameterize
+import ..LineCableModels: verbosity, VerbosityLogger
+#! explicit-imports: off
+import ..LineCableModels: description
+#! explicit-imports: on
+import ..Commons: AbstractProblemDefinition, AbstractFormulation,
+ AbstractProblemResult, AbstractCoreResult,
+ FormulationOptions, ComputationOptions,
+ ComputationDetails,
+ formulation_options, computation_options, computation_details, details,
+ compute, observe, observables,
+ observation_indices, observation_resolution,
+ uncertainty,
+ request_identity, request_indices
+
+using ..Units
+import ..Commons
+using ..Commons: vacuum_permittivity, vacuum_permeability
+using ..Commons: kron_reduce!, ReductionPlan, reduce_line_matrices!
+import ..Commons: Functor
+import ..Commons: initialize_buffers
using ..Materials
-using ..EarthProps: EarthModel
-using ..DataModel: LineCableSystem
-using ..Utils: levelfrom, TimestampLogger
-using Logging, LoggingExtras
-
-include("types.jl")
-
-# Problem definitions
-include("lineparamopts.jl")
-include("problemdefs.jl")
-include("lineparams.jl")
+using ..Materials: TemperatureDependent
+import ..Earth
+using ..Earth: EarthMaterial, EarthModel, EquivalentHomogeneous
+using ..DataModel: CableDesign, LineCableSystem, ncables, nphases
+import ..DataModel
+import ..TextDisplay
+import ..LineCableModels: validate
+import Logging
+using Logging: with_logger
+import SpecialFunctions
+using QuadGK: alloc_segbuf, quadgk
+
+include("interfaces.jl")
+include("formulations.jl")
+include("earthplan.jl")
+include("specialfunctions.jl")
+
+# Problem and coaxial formulation definitions
+include("problems.jl")
+include("options.jl")
+include("integration.jl")
+include("earthkernels.jl")
+
+# Line-parameter results and their protocols
+include("lineparameters/lineparameters.jl")
+include("lineparameters/quantities.jl")
+include("lineparameters/resolution.jl")
+include("lineparameters/benchmark.jl")
# Submodule `InternalImpedance`
include("internalimpedance/InternalImpedance.jl")
using .InternalImpedance: InternalImpedance
+include("pipeimpedance/PipeImpedance.jl")
+
# Submodule `InsulationImpedance`
include("insulationimpedance/InsulationImpedance.jl")
using .InsulationImpedance: InsulationImpedance
@@ -69,41 +121,57 @@ using .EarthImpedance: EarthImpedance
include("insulationadmittance/InsulationAdmittance.jl")
using .InsulationAdmittance: InsulationAdmittance
+# Submodule `SemiconAdmittance`
+include("semiconadmittance/SemiconAdmittance.jl")
+using .SemiconAdmittance: SemiconAdmittance
+
# 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")
-include("dataframe.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
+# Native workspace and numerical action
+include("blueprint.jl")
+include("shuntmodel/ShuntModel.jl")
+using .ShuntModel: BoundarySolveError
+include("blueprint_shunt.jl")
+include("input.jl")
+include("earthreturn.jl")
+include("impedance.jl")
+include("admittance.jl")
+include("lineparameters.jl")
+include("cableconstants.jl")
+include("observed_inputs.jl")
+include("lineparameters/observations.jl")
+
+# Line-parameter protocols and observation publication
+include("lineparameters/base.jl")
+include("textdisplay.jl")
+
+# Submodule `ModalAnalysis`
+include("modalanalysis/ModalAnalysis.jl")
+using .ModalAnalysis: ModalAnalysis
+
+public completion_details, completed_inputs, completed_formulation, retain_gridpoint
+public selectdetails
+public AbstractModalOperators
+public SpectralIntegral, integrate
+public AirVoltageSpectrum, earth_spectral_term, earth_spectral_value,
+ earth_spectral_points!, earth_contour_angle, earth_direct,
+ outgoing_root, bessel_i0m1, bessel_current_ratio, special_besselix
+public earth!, materials!, homogenize!,
+ same_physical_state, layer_index, computation_type
+public has_uncertainty_type, numerical_magnitude
+public resolution_available
+public observation_assumptions
+public domain, LineParamsDomain, PhaseDomain, ModalDomain, line_coordinates
+public internal_shunt_response, blueprint_dependencies
+public InternalImpedanceFormulation, InsulationImpedanceFormulation,
+ PipeImpedanceFormulation,
+ EarthImpedanceFormulation, InsulationAdmittanceFormulation,
+ SemiconAdmittanceFormulation,
+ EarthAdmittanceFormulation, ShuntModelFormulation
+public layer_admittance
+public CableBlueprint, BlueprintConductor, BlueprintDielectric, flatten, lineinput,
+ earth_pairs
end # module Engine
diff --git a/src/engine/admittance.jl b/src/engine/admittance.jl
new file mode 100644
index 000000000..4ee947e02
--- /dev/null
+++ b/src/engine/admittance.jl
@@ -0,0 +1,285 @@
+"""
+$(TYPEDSIGNATURES)
+
+Calculate the shunt admittance of one homogeneous coaxial annulus:
+
+```math
+y=\\frac{2\\pi\\kappa}{\\log(r_{ex}/r_{in})}.
+```
+
+# Returns
+
+- Complex shunt admittance per unit length ``y`` \\[S/m\\].
+"""
+@inline function layer_admittance(r_in::T, r_ex::T, κ::Complex{T}) where {T <: Real}
+ return 2 * (one(T) * π) * κ / log(r_ex / r_in)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate one homogeneous coaxial annulus's potential coefficient
+``p=s/y`` from its registered constitutive relation's complex admittivity. A
+zero inner radius or zero-thickness annulus contributes zero.
+
+# Returns
+
+- Complex charge-based potential coefficient ``p`` \\[m/F\\].
+
+Here `r_in` and `r_ex` are the dielectric radii in meters, `κ` is complex
+admittivity in S/m, and `s` is complex frequency in s⁻¹. For sinusoidal
+evaluation, ``s=jω``. This definition gives ``y=s/p``. The FEM backend extracts
+the inverse-admittance coefficient ``P=1/y`` in m/S and passes ``p=sP`` to
+[`reduce_line_matrices!`](@ref LineCableModels.Commons.reduce_line_matrices!).
+"""
+@inline function potential_coefficient(
+ r_in::T,
+ r_ex::T,
+ κ::Complex{T},
+ s::Complex{T}
+) where {T <: Real}
+ if isapprox(r_in, zero(T); atol = eps(T)) ||
+ isapprox(r_in, r_ex; atol = eps(T))
+ return zero(Complex{T})
+ end
+ y = layer_admittance(r_in, r_ex, κ)
+ return s / y
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate the selected dielectric law at frequency in Hz and temperature in °C.
+The selected `temperature_dependence` law evaluates resistivity exactly once
+before the dielectric equation. Its default is the linear resistivity law.
+Use `nothing` to retain reference resistivity. The temporary material records the
+operating temperature when resistivity changes. The source material remains unchanged.
+The lossless dielectric law ignores resistivity and loss tangent.
+Returns complex admittivity in S/m.
+"""
+function constitutive(
+ formula::Union{InsulationAdmittanceFormulation, SemiconAdmittanceFormulation},
+ material::Material, frequency::Real, temperature::Real;
+ temperature_dependence = TemperatureDependent.Formula(:default), workspace = nothing)
+ rho = constitutive(temperature_dependence, material, temperature; workspace)
+ if rho != material.rho
+ material = Material(material.kind, rho, material.eps_r, material.mu_r,
+ temperature, material.alpha; rho_thermal = material.rho_thermal,
+ theta_max = material.theta_max, tan_delta = material.tan_delta,
+ sigma_solar = material.sigma_solar)
+ end
+ return formula(material, frequency, temperature; workspace)
+end
+
+@inline function radial_coefficient(coefficients, layers::UnitRange{Int})
+ coefficient = zero(eltype(coefficients))
+ @inbounds for layer in layers
+ coefficient += coefficients[layer]
+ end
+ return coefficient
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate a homogeneous radial dielectric from its original constituents.
+`relations` contains the selected insulation and semicon material callables,
+in that order. Each callable returns complex admittivity in S/m at the supplied
+frequency in Hz and temperature in °C. Each constituent loss is added only once.
+
+The effective admittivity is the logarithmically weighted harmonic mean of
+the selected constituent responses. This is radial circuit equivalence, not
+an assertion of full-field equivalence for an arbitrary heterogeneous domain.
+"""
+function constitutive(relations::Tuple{I, S}, material::RadialDielectric,
+ frequency::Real, temperature::Real) where {I, S}
+ T = promote_type(eltype(material), typeof(float(frequency)), typeof(float(temperature)))
+ impedance = zero(Complex{T})
+ for (source, weight) in zip(material.materials, material.weights)
+ response = source.kind === :semicon ?
+ relations[2](source, frequency, temperature) :
+ relations[1](source, frequency, temperature)
+ iszero(response) && return zero(Complex{T})
+ impedance += weight / response
+ end
+ return sum(material.weights) / impedance
+end
+
+function constitutive(relations::Tuple{I, S}, material::RadialDielectric,
+ frequency::Real, temperature::Real;
+ temperature_dependence = TemperatureDependent.Formula(:default), workspace = nothing
+) where {I <: InsulationAdmittanceFormulation, S <: SemiconAdmittanceFormulation}
+ evaluated = map(relations) do selected
+ (source, f,
+ t) -> constitutive(selected, source, f, t; temperature_dependence, workspace)
+ end
+ return constitutive(evaluated, material, frequency, temperature)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate the registered insulation and semicon constitutive relations for
+every physical dielectric layer, without applying radial geometry.
+
+# Returns
+
+- `admittivity`, overwritten in physical radial-layer order \\[S/m\\].
+"""
+function dielectric!(
+ admittivity::AbstractVector{Complex{T}},
+ input::LocalCableData{T},
+ methods::NamedTuple,
+ frequency::T,
+ temperature::T; workspace = nothing
+) where {T <: Real}
+ @inbounds for layer in input.insulation_indices
+ κ = constitutive(
+ methods.insulation_admittance,
+ input.dielectric_materials[layer],
+ frequency,
+ temperature; temperature_dependence = methods.temperature_dependence, workspace
+ )
+ admittivity[layer] = κ
+ end
+ @inbounds for layer in input.semicon_indices
+ κ = constitutive(
+ methods.semicon_admittance,
+ input.dielectric_materials[layer],
+ frequency,
+ temperature; temperature_dependence = methods.temperature_dependence, workspace
+ )
+ admittivity[layer] = κ
+ end
+ return admittivity
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Assemble the unreduced cable-internal potential-coefficient matrix from the
+series sum of physical radial dielectric layers.
+
+# Returns
+
+- `destination`, overwritten with the local potential coefficients \\[m/F\\].
+"""
+function cable_potential!(
+ destination::AbstractMatrix{Complex{T}},
+ input::LocalCableData{T},
+ admittivity::AbstractVector{Complex{T}},
+ s::Complex{T},
+ layer_coefficients::AbstractVector{Complex{T}},
+ coefficients::AbstractVector{Complex{T}},
+ tails::AbstractVector{Complex{T}}
+) where {T <: Real}
+ fill!(destination, zero(Complex{T}))
+ @. layer_coefficients = potential_coefficient(
+ input.r_layer_in, input.r_layer_ext, admittivity, s)
+ @inbounds for conductors in input.assemblies
+ count = length(conductors)
+ for component in 1:count
+ coefficients[component] = input.shunt_covered[conductors[component]] ? zero(s) :
+ radial_coefficient(
+ layer_coefficients,
+ input.dielectric_ranges[conductors[component]]
+ )
+ end
+ tails[count] = coefficients[count]
+ for gap in (count - 1):-1:1
+ tails[gap] = coefficients[gap] + tails[gap + 1]
+ end
+ for row in 1:count, column in 1:count
+
+ destination[conductors[row], conductors[column]] += tails[max(row, column)]
+ end
+ end
+ return _shunt_potential!(destination, input.shunt)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Assemble the earth-free nodal shunt-admittance matrix of independent
+concentric assemblies.
+
+For each conductor-owned radial interval, the physical layer coefficients
+combine in series as ``p=\\sum_k p_k`` and contribute the branch admittance
+``y=s/p``. An interval outside the last retained conductor terminates at the
+external reference.
+
+# Returns
+
+- `destination`, overwritten with the local shunt admittance [S/m].
+"""
+function cable_admittance!(
+ destination::AbstractMatrix{Complex{T}},
+ input::LocalCableData{T},
+ admittivity::AbstractVector{Complex{T}},
+ s::Complex{T},
+ layer_coefficients::AbstractVector{Complex{T}}
+) where {T <: Real}
+ fill!(destination, zero(Complex{T}))
+ @. layer_coefficients = potential_coefficient(
+ input.r_layer_in, input.r_layer_ext, admittivity, s)
+ @inbounds for conductors in input.assemblies
+ count = length(conductors)
+ for position in 1:count
+ index = conductors[position]
+ input.shunt_covered[index] && continue
+ coefficient = radial_coefficient(
+ layer_coefficients,
+ input.dielectric_ranges[index]
+ )
+ iszero(coefficient) && continue
+ admittance = s / coefficient
+ destination[index, index] += admittance
+ if position < count
+ outside = conductors[position + 1]
+ destination[outside, outside] += admittance
+ destination[index, outside] -= admittance
+ destination[outside, index] -= admittance
+ end
+ end
+ end
+ return _shunt_admittance!(destination, input.shunt, s)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Add the already calculated exterior potential coefficients to the cable-local
+primitive potential matrix \\[m/F\\] and retain its optional trace at `frequency`.
+The caller then forms admittance from this matrix. No equation or
+material law is evaluated here. Return the mutated `destination`.
+"""
+function admittance!(
+ destination::AbstractMatrix{Complex{T}},
+ workspace::LineParametersWorkspace{T},
+ frequency::Int
+) where {T <: Real}
+ input = workspace.input
+ indices = workspace.plan.cable_indices
+ earth_matrix = workspace.buffers.Pearth
+ trace = workspace.trace
+
+ @inbounds for cable in 1:input.n_cables
+ self = earth_matrix[cable, cable]
+ for row in indices[cable], column in indices[cable]
+
+ destination[row, column] += self
+ end
+ end
+ @inbounds for left in 1:(input.n_cables - 1)
+ for right in (left + 1):input.n_cables
+ mutual = earth_matrix[left, right]
+ for row in indices[left], column in indices[right]
+
+ destination[row, column] += mutual
+ destination[column, row] += earth_matrix[right, left]
+ end
+ end
+ end
+ _stash!(trace, :P, frequency, destination)
+ return destination
+end
diff --git a/src/engine/base.jl b/src/engine/base.jl
deleted file mode 100644
index 59e4d5823..000000000
--- a/src/engine/base.jl
+++ /dev/null
@@ -1,394 +0,0 @@
-
-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/blueprint.jl b/src/engine/blueprint.jl
new file mode 100644
index 000000000..29c792acd
--- /dev/null
+++ b/src/engine/blueprint.jl
@@ -0,0 +1,570 @@
+"""
+$(TYPEDEF)
+
+Store one frequency-independent equivalent coaxial conductor row.
+
+$(TYPEDFIELDS)
+"""
+struct BlueprintConductor{T <: Real}
+ "Retained terminal name."
+ terminal::Symbol
+ "Concentric assembly containing the terminal."
+ assembly::Int
+ "Equivalent inner radius [m]."
+ r_in::T
+ "Equivalent outer radius [m]."
+ r_ex::T
+ "Physical conductor cross-section [m²]."
+ cross_section::T
+ "Number of explicitly represented wires."
+ num_wires::Int
+ "Equivalent helical turns per unit length [1/m]."
+ turns_per_length::T
+ "Equivalent resistance at the material reference temperature [Ω/m]."
+ resistance::T
+ "Equivalent temperature coefficient [1/°C]."
+ alpha::T
+ "Equivalent geometric-mean radius [m]."
+ gmr::T
+ "Assembly-local center in the design frame [m]."
+ position::Tuple{T, T}
+ "Artificial homogeneous conductor material."
+ material::Material{T}
+end
+
+"""
+$(TYPEDEF)
+
+Store one physical dielectric layer owned by a coaxial conductor interval.
+
+$(TYPEDFIELDS)
+"""
+struct BlueprintDielectric{T <: Real}
+ "Index of the conductor immediately inside this radial interval."
+ conductor::Int
+ "Layer inner radius [m]."
+ r_in::T
+ "Layer outer radius [m]."
+ r_ex::T
+ "Unmodified physical material."
+ material::Material{T}
+end
+
+"""
+$(TYPEDEF)
+
+Store one lossless terminal-capacitance block in its owner's conductor indices.
+Blueprint indices are cable-local. Local assembly data remap them to the system.
+`C` is shield-referenced capacitance \\[F/m\\]. `P` is its charge-potential
+inverse \\[m/F\\].
+
+$(TYPEDFIELDS)
+"""
+struct InternalShuntBlock{T <: Real}
+ "Conductor range of the containing concentric assembly."
+ assembly::UnitRange{Int}
+ "Domain terminals including the reference shield."
+ terminals::UnitRange{Int}
+ "Terminal capacitance \\[F/m\\]."
+ C::Matrix{T}
+ "Terminal charge-potential coefficients \\[m/F\\]."
+ P::Matrix{T}
+end
+
+function Base.convert(::Type{InternalShuntBlock{T}}, block::InternalShuntBlock) where {T <: Real}
+ InternalShuntBlock{T}(block.assembly, block.terminals, block.C, block.P)
+end
+Base.convert(::Type{InternalShuntBlock{T}}, block::InternalShuntBlock{T}) where {T <: Real} = block
+
+"""
+$(TYPEDEF)
+
+Store the frequency-independent, unreduced numerical description of one cable
+design and its selected local shunt formulation.
+
+The blueprint is the result of computational flattening. It retains equivalent
+conductor annuli and every physical dielectric layer. An explicitly selected
+boundary shunt model supplies completed lossless terminal coefficients during
+construction. Frequency-dependent constitutive evaluation, conductor temperature
+correction, earth return and matrix reductions remain calculation work.
+
+$(TYPEDFIELDS)
+"""
+struct CableBlueprint{T <: Real}
+ "Source cable identifier."
+ cable_id::String
+ "Equivalent conductors in DataModel terminal order."
+ conductors::Vector{BlueprintConductor{T}}
+ "Physical dielectric layers in radial order."
+ dielectrics::Vector{BlueprintDielectric{T}}
+ "Dielectric-layer range owned by every conductor row."
+ dielectric_ranges::Vector{UnitRange{Int}}
+ "Contiguous conductor ranges for independent concentric assemblies."
+ assembly_ranges::Vector{UnitRange{Int}}
+ "Completed boundary shunt blocks. Empty for the annular model."
+ shunt::Vector{InternalShuntBlock{T}}
+ "Requested/effective local model, domain outcomes and numerical diagnostics."
+ shunt_details::NamedTuple
+
+ function CableBlueprint{T}(
+ cable_id::String,
+ conductors::Vector{BlueprintConductor{T}},
+ dielectrics::Vector{BlueprintDielectric{T}},
+ dielectric_ranges::Vector{UnitRange{Int}},
+ assembly_ranges::Vector{UnitRange{Int}},
+ shunt::Vector{InternalShuntBlock{T}},
+ shunt_details::NamedTuple
+ ) where {T <: Real}
+ return validate(new{T}(
+ cable_id,
+ conductors,
+ dielectrics,
+ dielectric_ranges,
+ assembly_ranges,
+ shunt,
+ shunt_details
+ ))
+ end
+end
+
+Base.eltype(::CableBlueprint{T}) where {T} = T
+Base.eltype(::Type{<:CableBlueprint{T}}) where {T} = T
+Base.length(blueprint::CableBlueprint) = length(blueprint.conductors)
+
+function _assembly_ranges(components)
+ isempty(components) && throw(ArgumentError(
+ "a coaxial blueprint requires at least one retained terminal",
+ ))
+ starts = Int[1]
+ positions = [first(components).conductor.position]
+ @inbounds for index in 2:length(components)
+ position = components[index].conductor.position
+ if !DataModel.same_radial_position(position, last(positions))
+ any(reference -> DataModel.same_radial_position(position, reference), positions) &&
+ throw(ArgumentError(
+ "a concentric assembly cannot reappear after another assembly",
+ ))
+ push!(starts, index)
+ push!(positions, position)
+ end
+ end
+ return UnitRange{Int}[start:(index == length(starts) ? length(components) :
+ starts[index + 1] - 1)
+ for (index, start) in pairs(starts)]
+end
+
+function validate(blueprint::CableBlueprint)
+ isempty(blueprint.cable_id) && throw(ArgumentError(
+ "CableBlueprint.cable_id cannot be empty"
+ ))
+ count = length(blueprint.conductors)
+ count > 0 || throw(ArgumentError(
+ "CableBlueprint.conductors must contain at least one conductor"
+ ))
+ length(blueprint.dielectric_ranges) == count || throw(DimensionMismatch(
+ "CableBlueprint.dielectric_ranges must contain one range per conductor; " *
+ "received $(length(blueprint.dielectric_ranges)) ranges for $count conductors",
+ ))
+ isempty(blueprint.assembly_ranges) && throw(ArgumentError(
+ "CableBlueprint.assembly_ranges must contain at least one assembly",
+ ))
+ collect(Iterators.flatten(blueprint.assembly_ranges)) == collect(1:count) ||
+ throw(DimensionMismatch(
+ "CableBlueprint.assembly_ranges must partition conductor indices 1:$count in order",
+ ))
+ layer_count = length(blueprint.dielectrics)
+ collected_layers = collect(Iterators.flatten(blueprint.dielectric_ranges))
+ collected_layers == collect(1:layer_count) || throw(DimensionMismatch(
+ "CableBlueprint.dielectric_ranges must partition dielectric indices " *
+ "1:$layer_count in order",
+ ))
+ @inbounds for (index, conductor) in pairs(blueprint.conductors)
+ isempty(String(conductor.terminal)) && throw(ArgumentError(
+ "CableBlueprint.conductors[$index].terminal cannot be empty"
+ ))
+ conductor.assembly in eachindex(blueprint.assembly_ranges) || throw(DomainError(
+ conductor.assembly,
+ "CableBlueprint.conductors[$index].assembly must index assembly_ranges"
+ ))
+ index in blueprint.assembly_ranges[conductor.assembly] || throw(DimensionMismatch(
+ "CableBlueprint.conductors[$index].assembly does not own conductor $index",
+ ))
+ isfinite(conductor.r_in) && conductor.r_in >= zero(conductor.r_in) ||
+ throw(DomainError(
+ conductor.r_in,
+ "CableBlueprint.conductors[$index].r_in must be nonnegative and finite"
+ ))
+ isfinite(conductor.r_ex) && conductor.r_ex > conductor.r_in ||
+ throw(DomainError(
+ conductor.r_ex,
+ "CableBlueprint.conductors[$index].r_ex must be finite and greater than r_in"
+ ))
+ isfinite(conductor.cross_section) &&
+ conductor.cross_section > zero(conductor.cross_section) || throw(DomainError(
+ conductor.cross_section,
+ "CableBlueprint.conductors[$index].cross_section must be positive and finite"
+ ))
+ conductor.num_wires >= 0 || throw(DomainError(
+ conductor.num_wires,
+ "CableBlueprint.conductors[$index].num_wires must be nonnegative"
+ ))
+ isfinite(conductor.turns_per_length) || throw(DomainError(
+ conductor.turns_per_length,
+ "CableBlueprint.conductors[$index].turns_per_length must be finite"
+ ))
+ isfinite(conductor.resistance) &&
+ conductor.resistance > zero(conductor.resistance) || throw(DomainError(
+ conductor.resistance,
+ "CableBlueprint.conductors[$index].resistance must be positive and finite"
+ ))
+ isfinite(conductor.alpha) || throw(DomainError(
+ conductor.alpha,
+ "CableBlueprint.conductors[$index].alpha must be finite"
+ ))
+ isfinite(conductor.gmr) && conductor.gmr > zero(conductor.gmr) ||
+ throw(DomainError(
+ conductor.gmr,
+ "CableBlueprint.conductors[$index].gmr must be positive and finite"
+ ))
+ all(isfinite, conductor.position) || throw(DomainError(
+ conductor.position,
+ "CableBlueprint.conductors[$index].position must be finite"
+ ))
+ validate(conductor.material)
+ conductor.material.kind === :conductor || throw(ArgumentError(
+ "CableBlueprint.conductors[$index].material.kind must be :conductor; " *
+ "received $(repr(conductor.material.kind))",
+ ))
+ for layer_index in blueprint.dielectric_ranges[index]
+ layer = blueprint.dielectrics[layer_index]
+ layer.conductor == index || throw(DimensionMismatch(
+ "CableBlueprint.dielectrics[$layer_index].conductor must be $index; " *
+ "received $(layer.conductor)",
+ ))
+ isfinite(layer.r_in) && layer.r_in >= zero(layer.r_in) ||
+ throw(DomainError(
+ layer.r_in,
+ "CableBlueprint.dielectrics[$layer_index].r_in must be nonnegative and finite"
+ ))
+ isfinite(layer.r_ex) && layer.r_ex > layer.r_in ||
+ throw(DomainError(
+ layer.r_ex,
+ "CableBlueprint.dielectrics[$layer_index].r_ex must be finite and greater than r_in"
+ ))
+ validate(layer.material)
+ layer.material.kind in (:insulator, :semicon) || throw(ArgumentError(
+ "CableBlueprint.dielectrics[$layer_index].material.kind must be " *
+ ":insulator or :semicon; received $(repr(layer.material.kind))",
+ ))
+ end
+ end
+ for block in blueprint.shunt
+ block.assembly in blueprint.assembly_ranges || throw(ArgumentError(
+ "blueprint shunt block must belong to a conductor assembly"))
+ first(block.terminals) in block.assembly &&
+ last(block.terminals) in block.assembly ||
+ throw(ArgumentError("blueprint shunt terminals must lie in their assembly"))
+ n = length(block.terminals) - 1
+ size(block.C) == size(block.P) == (n, n) || throw(DimensionMismatch(
+ "blueprint shunt coefficients must match the nonreference terminals"))
+ end
+ return blueprint
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Flatten one completed cable design into the frequency-independent numerical
+description consumed by the coaxial backend.
+
+# Arguments
+
+- `engine`: coaxial backend identity.
+- `design`: completed physical cable design.
+- `T`: scalar type used by the numerical payload.
+- `formulation`: selected local formulas. Defaults to [`Formulation`](@ref).
+
+# Returns
+
+- A validated [`CableBlueprint`](@ref) with conductor annuli, physical
+ dielectric layers, assembly partitions and any selected boundary coefficients.
+"""
+function flatten(
+ engine::LineCableModelsCoaxial,
+ design::CableDesign,
+ ::Type{T},
+ formulation::AbstractFormulation = Formulation()
+) where {T <: Real}
+ return only(only(flatten(engine, [design], T, [formulation])))
+end
+
+function flatten(
+ ::LineCableModelsCoaxial,
+ design::CableDesign,
+ ::Type{T},
+ methods::NamedTuple,
+ solutions::Vector,
+ design_index::Int
+) where {T <: Real}
+ components = DataModel.radial_components(design, T)
+ ranges = _assembly_ranges(components)
+ assembly_by_conductor = Vector{Int}(undef, length(components))
+ @inbounds for (assembly, indices) in pairs(ranges), index in indices
+
+ assembly_by_conductor[index] = assembly
+ end
+
+ conductors = Vector{BlueprintConductor{T}}(undef, length(components))
+ layer_count = sum(component -> length(component.dielectric.layers), components)
+ dielectrics = Vector{BlueprintDielectric{T}}(undef, layer_count)
+ dielectric_ranges = Vector{UnitRange{Int}}(undef, length(components))
+ layer_index = 0
+ @inbounds for (index, component) in pairs(components)
+ conductor = component.conductor
+ conductors[index] = BlueprintConductor{T}(
+ component.name,
+ assembly_by_conductor[index],
+ conductor.r_in,
+ conductor.r_ex,
+ conductor.cross_section,
+ conductor.num_wires,
+ conductor.turns_per_length,
+ conductor.resistance,
+ conductor.alpha,
+ conductor.gmr,
+ conductor.position,
+ conductor.material
+ )
+ first_layer = layer_index + 1
+ for layer in component.dielectric.layers
+ layer_index += 1
+ dielectrics[layer_index] = BlueprintDielectric{T}(
+ index,
+ layer.r_in,
+ layer.r_ex,
+ layer.material
+ )
+ end
+ dielectric_ranges[index] = first_layer:layer_index
+ end
+ geometry = (; conductors, assembly_ranges = ranges)
+ response = internal_shunt_response(methods.shunt_model, design, geometry, T,
+ methods, solutions, design_index)
+ return CableBlueprint{T}(
+ design.cable_id,
+ conductors,
+ dielectrics,
+ dielectric_ranges,
+ ranges,
+ response.blocks,
+ response.details
+ )
+end
+
+function flatten(
+ engine::LineCableModelsCoaxial,
+ design::CableDesign,
+ formulation::AbstractFormulation = Formulation()
+)
+ T = eltype(design)
+ return flatten(engine, design, T, formulation)
+end
+
+"""
+$(TYPEDEF)
+
+Store the concrete array representation shared by the coaxial local primitive
+impedance and potential-coefficient assemblers.
+
+$(TYPEDFIELDS)
+"""
+struct LocalCableData{T <: Real}
+ "Terminal names in DataModel order."
+ terminals::Vector{Symbol}
+ "Contiguous conductor-index ranges for concentric assemblies."
+ assemblies::Vector{UnitRange{Int}}
+ "Source-design index for each concentric assembly."
+ assembly_designs::Vector{Int}
+ "Assembly-local conductor centers [m]."
+ positions::Vector{Tuple{T, T}}
+ "Equivalent conductor inner radii [m]."
+ r_in::Vector{T}
+ "Equivalent conductor outer radii [m]."
+ r_ext::Vector{T}
+ "Inner radii of conductor-owned dielectric intervals [m]."
+ r_ins_in::Vector{T}
+ "Outer radii of conductor-owned dielectric intervals [m]."
+ r_ins_ext::Vector{T}
+ "Reference conductor materials, including their resistivity calibration."
+ conductor_materials::Vector{Material{T}}
+ "Conductor relative permeabilities."
+ mu_r_cond::Vector{T}
+ "Equivalent relative permeabilities of conductor-owned dielectric intervals."
+ mu_r_ins::Vector{T}
+ "Physical dielectric-layer range owned by each conductor."
+ dielectric_ranges::Vector{UnitRange{Int}}
+ "Physical dielectric-layer inner radii [m]."
+ r_layer_in::Vector{T}
+ "Physical dielectric-layer outer radii [m]."
+ r_layer_ext::Vector{T}
+ "Physical dielectric materials in radial order."
+ dielectric_materials::Vector{Material{T}}
+ "Indices of dielectric layers classified as insulation."
+ insulation_indices::Vector{Int}
+ "Indices of dielectric layers classified as semiconducting material."
+ semicon_indices::Vector{Int}
+ "Boundary coefficients remapped from cable-local to system conductor indices."
+ shunt::Vector{InternalShuntBlock{T}}
+ "Radial intervals replaced by boundary coefficients."
+ shunt_covered::BitVector
+ "Local-model outcomes in system terminal order."
+ shunt_details::NamedTuple
+end
+
+# Completed blueprint coefficients do not depend on external formula arguments.
+# Widen their representation without recalculating the blueprint or detaching
+# correlated material values. Native field conversion retains unchanged indices.
+function Base.convert(::Type{LocalCableData{T}}, cable::LocalCableData) where {T <: Real}
+ LocalCableData{T}(map(name -> getfield(cable, name), fieldnames(typeof(cable)))...)
+end
+Base.convert(::Type{LocalCableData{T}}, cable::LocalCableData{T}) where {T <: Real} = cable
+
+function LocalCableData(blueprints::AbstractVector{<:CableBlueprint{T}}) where {T <: Real}
+ isempty(blueprints) && throw(ArgumentError(
+ "local cable data require at least one blueprint",
+ ))
+ conductor_count = sum(length, blueprints)
+ layer_count = sum(blueprint -> length(blueprint.dielectrics), blueprints)
+ assembly_count = sum(blueprint -> length(blueprint.assembly_ranges), blueprints)
+
+ terminals = Vector{Symbol}(undef, conductor_count)
+ assemblies = Vector{UnitRange{Int}}(undef, assembly_count)
+ assembly_designs = Vector{Int}(undef, assembly_count)
+ positions = Vector{Tuple{T, T}}(undef, conductor_count)
+ r_in_values = Vector{T}(undef, conductor_count)
+ r_ext_values = Vector{T}(undef, conductor_count)
+ r_ins_in = Vector{T}(undef, conductor_count)
+ r_ins_ext = Vector{T}(undef, conductor_count)
+ conductor_materials = Vector{Material{T}}(undef, conductor_count)
+ mu_r_cond = Vector{T}(undef, conductor_count)
+ mu_r_ins = Vector{T}(undef, conductor_count)
+ dielectric_ranges = Vector{UnitRange{Int}}(undef, conductor_count)
+ r_layer_in = Vector{T}(undef, layer_count)
+ r_layer_ext = Vector{T}(undef, layer_count)
+ dielectric_materials = Vector{Material{T}}(undef, layer_count)
+ insulation_indices = Int[]
+ semicon_indices = Int[]
+ sizehint!(insulation_indices, layer_count)
+ sizehint!(semicon_indices, layer_count)
+ shunt = InternalShuntBlock{T}[]
+ shunt_covered = falses(conductor_count)
+ reports = similar(first(blueprints).shunt_details.domains, 0)
+ diagnostics = similar(first(blueprints).shunt_details.diagnostics, 0)
+ solved = Base.IdSet{Matrix{T}}()
+ requested = first(blueprints).shunt_details.requested
+
+ conductor_offset = 0
+ layer_offset = 0
+ assembly_offset = 0
+ @inbounds for (design_index, blueprint) in pairs(blueprints)
+ blueprint.shunt_details.requested === requested || throw(ArgumentError(
+ "local cable blueprints must use the same shunt model"))
+ for block in blueprint.shunt
+ assembly_range = (first(block.assembly) + conductor_offset):(last(block.assembly) + conductor_offset)
+ terminal_range = (first(block.terminals) + conductor_offset):(last(block.terminals) + conductor_offset)
+ push!(shunt, InternalShuntBlock(assembly_range, terminal_range, block.C, block.P))
+ shunt_covered[first(terminal_range):(last(terminal_range) - 1)] .= true
+ push!(solved, block.C)
+ end
+ append!(diagnostics, blueprint.shunt_details.diagnostics)
+ for report in blueprint.shunt_details.domains
+ terminal_range = (first(report.terminals) + conductor_offset):(last(report.terminals) + conductor_offset)
+ push!(reports, merge(report, (;
+ design = design_index, terminals = terminal_range)))
+ end
+ for local_range in blueprint.assembly_ranges
+ assembly_offset += 1
+ assemblies[assembly_offset] = (
+ first(local_range) + conductor_offset
+ ):(last(local_range) + conductor_offset)
+ assembly_designs[assembly_offset] = design_index
+ end
+ for local_index in eachindex(blueprint.conductors)
+ index = conductor_offset + local_index
+ conductor = blueprint.conductors[local_index]
+ terminals[index] = conductor.terminal
+ positions[index] = conductor.position
+ r_in_values[index] = conductor.r_in
+ r_ext_values[index] = conductor.r_ex
+ conductor_materials[index] = conductor.material
+ mu_r_cond[index] = conductor.material.mu_r
+
+ local_layers = blueprint.dielectric_ranges[local_index]
+ first_layer = layer_offset + 1
+ for local_layer in local_layers
+ layer_offset += 1
+ layer = blueprint.dielectrics[local_layer]
+ r_layer_in[layer_offset] = layer.r_in
+ r_layer_ext[layer_offset] = layer.r_ex
+ dielectric_materials[layer_offset] = layer.material
+ if layer.material.kind === :insulator
+ push!(insulation_indices, layer_offset)
+ elseif layer.material.kind === :semicon
+ push!(semicon_indices, layer_offset)
+ else
+ throw(ArgumentError(
+ "unsupported coaxial dielectric kind :$(layer.material.kind)",
+ ))
+ end
+ end
+ dielectric_ranges[index] = first_layer:layer_offset
+ if isempty(local_layers)
+ r_ins_in[index] = conductor.r_ex
+ r_ins_ext[index] = conductor.r_ex
+ mu_r_ins[index] = one(T)
+ else
+ r_ins_in[index] = blueprint.dielectrics[first(local_layers)].r_in
+ r_ins_ext[index] = blueprint.dielectrics[last(local_layers)].r_ex
+ layers = @view blueprint.dielectrics[local_layers]
+ mu_r_ins[index] = DataModel.equivalent_dielectric_permeability(
+ layers,
+ conductor.turns_per_length,
+ conductor.r_ex,
+ r_ins_ext[index]
+ )
+ end
+ end
+ conductor_offset += length(blueprint.conductors)
+ end
+
+ return LocalCableData{T}(
+ terminals,
+ assemblies,
+ assembly_designs,
+ positions,
+ r_in_values,
+ r_ext_values,
+ r_ins_in,
+ r_ins_ext,
+ conductor_materials,
+ mu_r_cond,
+ mu_r_ins,
+ dielectric_ranges,
+ r_layer_in,
+ r_layer_ext,
+ dielectric_materials,
+ insulation_indices,
+ semicon_indices,
+ shunt,
+ shunt_covered,
+ (requested,
+ effective = isempty(reports) ? :equivalent :
+ all(r -> r.effective === :boundary, reports) ? :boundary :
+ all(r -> r.effective === :equivalent, reports) ? :equivalent : :mixed,
+ solves = length(solved), domains = reports, diagnostics)
+ )
+end
+
+function LocalCableData(blueprint::CableBlueprint{T}) where {T <: Real}
+ LocalCableData(CableBlueprint{T}[blueprint])
+end
diff --git a/src/engine/blueprint_shunt.jl b/src/engine/blueprint_shunt.jl
new file mode 100644
index 000000000..47c89ce70
--- /dev/null
+++ b/src/engine/blueprint_shunt.jl
@@ -0,0 +1,57 @@
+"""
+$(TYPEDSIGNATURES)
+
+Construct cable blueprints for each selected formulation. Identical local
+selections share the completed blueprints. Equivalent lossless domains share
+their coefficient matrices.
+The returned outer vector follows formulation order, and each inner vector
+follows design order. Sharing is confined to this construction call.
+"""
+function flatten(engine::LineCableModelsCoaxial, designs::AbstractVector,
+ ::Type{T}, formulations::AbstractVector{<:AbstractFormulation}) where {T <: Real}
+ solutions = NamedTuple[]
+ selections = NamedTuple[]
+ blueprints = Vector{CableBlueprint{T}}[]
+ for formulation in formulations
+ methods = formulation.methods
+ selected = blueprint_dependencies(methods.shunt_model, methods)
+ previous = findfirst(value -> isequal(value, selected), selections)
+ current = previous === nothing ?
+ CableBlueprint{T}[flatten(engine, design, T, selected, solutions, index)
+ for (index, design) in pairs(designs)] :
+ blueprints[previous]
+ push!(selections, selected)
+ push!(blueprints, current)
+ end
+ return blueprints
+end
+
+# Install completed terminal coefficients. No boundary equation is evaluated.
+function _shunt_potential!(destination, blocks::AbstractVector{<:InternalShuntBlock})
+ for block in blocks
+ inner, reference = first(block.terminals), last(block.terminals)
+ # Charge on the inner anchor includes all conductors shielded inside it.
+ @inbounds for j in first(block.assembly):(reference - 1),
+ i in first(block.assembly):(reference - 1)
+
+ destination[i, j] += block.P[max(1, i-inner+1), max(1, j-inner+1)]
+ end
+ end
+ return destination
+end
+
+function _shunt_admittance!(destination, blocks::AbstractVector{<:InternalShuntBlock}, s)
+ for block in blocks
+ first_index, reference = first(block.terminals), last(block.terminals)
+ @inbounds for j in axes(block.C, 2), i in axes(block.C, 1)
+
+ value = s*block.C[i, j]
+ row, column = first_index+i-1, first_index+j-1
+ destination[row, column] += value
+ destination[row, reference] -= value
+ destination[reference, column] -= value
+ destination[reference, reference] += value
+ end
+ end
+ return destination
+end
diff --git a/src/engine/cableconstants.jl b/src/engine/cableconstants.jl
new file mode 100644
index 000000000..596a06759
--- /dev/null
+++ b/src/engine/cableconstants.jl
@@ -0,0 +1,660 @@
+"""
+$(TYPEDEF)
+
+Store earth-free cable constants per unit length for one or more independent
+concentric assemblies.
+
+Every entry of `cores`, `R`, `L`, `C`, and `G` describes one assembly. Values
+are evaluated at `frequency`. `R`, `L`, `C`, and `G` use Ω/m, H/m, F/m, and
+S/m respectively.
+
+$(TYPEDFIELDS)
+"""
+struct CableConstants{T <: Real, D <: ComputationDetails} <: AbstractCoreResult
+ "Innermost active terminal of each concentric assembly."
+ cores::Vector{Symbol}
+ "Series resistance per unit length [Ω/m]."
+ R::Vector{T}
+ "Series inductance per unit length [H/m]."
+ L::Vector{T}
+ "Shunt capacitance per unit length [F/m]."
+ C::Vector{T}
+ "Shunt conductance per unit length [S/m]."
+ G::Vector{T}
+ "Evaluation frequency [Hz]."
+ frequency::T
+
+ "Requested/resolved formulas and blueprint construction diagnostics."
+ details::D
+
+ function CableConstants{T}(
+ cores::Vector{Symbol},
+ R::Vector{T},
+ L::Vector{T},
+ C::Vector{T},
+ G::Vector{T},
+ frequency::T,
+ details::ComputationDetails = ComputationDetails()
+ ) where {T <: Real}
+ count = length(cores)
+ iszero(count) && throw(ArgumentError(
+ "cable constants require at least one concentric assembly",
+ ))
+ all(length(values) == count for values in (R, L, C, G)) ||
+ throw(DimensionMismatch(
+ "cores, R, L, C, and G must contain the same number of entries",
+ ))
+ allunique(cores) || throw(ArgumentError(
+ "cable-constant core names must be unique",
+ ))
+ isfinite(frequency) && frequency > zero(frequency) || throw(DomainError(
+ frequency,
+ "cable-constant frequency must be positive and finite"
+ ))
+ all(isfinite, Iterators.flatten((R, L, C, G))) || throw(DomainError(
+ (R, L, C, G),
+ "cable constants must be finite"
+ ))
+ retained=haskey(details.data,:gridpoint) ? details :
+ completion_details(merge(details.data,(gridpoint=Commons.gridpoint_id(),)))
+ return new{T, typeof(retained)}(cores, R, L, C, G, frequency, retained)
+ end
+end
+
+function CableConstants(
+ cores::AbstractVector{Symbol},
+ R::AbstractVector{<:Real},
+ L::AbstractVector{<:Real},
+ C::AbstractVector{<:Real},
+ G::AbstractVector{<:Real},
+ frequency::Real,
+ details::ComputationDetails = ComputationDetails()
+)
+ T = promote_type(
+ eltype(R), eltype(L), eltype(C), eltype(G), typeof(float(frequency))
+ )
+ return CableConstants{T}(
+ collect(Symbol, cores),
+ T.(R),
+ T.(L),
+ T.(C),
+ T.(G),
+ convert(T, float(frequency)),
+ details
+ )
+end
+
+function CableConstants(
+ R::Real,
+ L::Real,
+ C::Real,
+ G::Real = 0;
+ core::Symbol = :core,
+ frequency::Real = 50
+)
+ values = promote(R, L, C, G, frequency)
+ T = typeof(first(values))
+ return CableConstants{T}(
+ Symbol[core],
+ T[values[1]],
+ T[values[2]],
+ T[values[3]],
+ T[values[4]],
+ values[5]
+ )
+end
+
+function Base.:(==)(left::CableConstants, right::CableConstants)
+ left.cores == right.cores && left.R == right.R && left.L == right.L &&
+ left.C == right.C && left.G == right.G && left.frequency == right.frequency
+end
+
+Base.length(constants::CableConstants) = length(constants.cores)
+details(constants::CableConstants) = constants.details
+Base.size(constants::CableConstants) = (length(constants),)
+function Base.eltype(::Type{CableConstants{T}}) where {T}
+ NamedTuple{
+ (:core, :R, :L, :C, :G),
+ Tuple{Symbol, T, T, T, T}
+ }
+end
+Base.firstindex(constants::CableConstants) = firstindex(constants.cores)
+Base.lastindex(constants::CableConstants) = lastindex(constants.cores)
+
+function Base.getindex(constants::CableConstants, index::Integer)
+ return (
+ core = constants.cores[index],
+ R = constants.R[index],
+ L = constants.L[index],
+ C = constants.C[index],
+ G = constants.G[index]
+ )
+end
+
+function Base.iterate(constants::CableConstants, state::Int = 1)
+ state > length(constants) && return nothing
+ return constants[state], state + 1
+end
+
+observe(constants::CableConstants, ::typeof(R)) = constants.R
+observe(constants::CableConstants, ::typeof(L)) = constants.L
+observe(constants::CableConstants, ::typeof(C)) = constants.C
+observe(constants::CableConstants, ::typeof(G)) = constants.G
+
+R(constants::CableConstants) = observe(constants, R)
+L(constants::CableConstants) = observe(constants, L)
+C(constants::CableConstants) = observe(constants, C)
+G(constants::CableConstants) = observe(constants, G)
+basis(::CableConstants) = :pul
+resistance(constants::CableConstants) = observe(constants, R)
+inductance(constants::CableConstants) = observe(constants, L)
+capacitance(constants::CableConstants) = observe(constants, C)
+conductance(constants::CableConstants) = observe(constants, G)
+observables(::Type{<:CableConstants}) = (R, L, C, G)
+
+
+"""
+$(TYPEDEF)
+
+Define one earth-free cable-constant computation for a completed cable design.
+
+The innermost terminal of every concentric assembly is active and every
+additional outward terminal, when present, is grounded. The computation is
+restricted to the 50 Hz or 60 Hz base frequency used by cable datasheets.
+
+$(TYPEDFIELDS)
+"""
+struct CableConstantsProblem{
+ T <: Real,
+ D <: CableDesign
+} <: AbstractProblemDefinition
+ "Completed physical cable design."
+ design::D
+ "Operating temperature [°C]."
+ temperature::T
+ "Evaluation frequency [Hz]."
+ frequency::T
+
+ function CableConstantsProblem{T, D}(
+ design::D,
+ temperature::T,
+ frequency::T
+ ) where {T <: Real, D <: CableDesign}
+ return validate(new{T, D}(design, temperature, frequency))
+ end
+end
+
+Base.eltype(::CableConstantsProblem{T}) where {T} = T
+Base.eltype(::Type{<:CableConstantsProblem{T}}) where {T} = T
+
+function validate(problem::CableConstantsProblem)
+ validate(problem.design)
+ isfinite(problem.temperature) || throw(DomainError(
+ problem.temperature,
+ "cable-constant temperature must be finite"
+ ))
+ problem.frequency in (oftype(problem.frequency, 50), oftype(problem.frequency, 60)) ||
+ throw(DomainError(
+ problem.frequency,
+ "cable-constant base frequency must be 50 Hz or 60 Hz"
+ ))
+ return problem
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct an earth-free cable-constant problem.
+
+# Keywords
+
+- `temperature=20`: operating temperature [°C].
+- `frequency=50`: base frequency, either 50 Hz or 60 Hz.
+
+# Returns
+
+- A validated [`CableConstantsProblem`](@ref).
+"""
+function CableConstantsProblem(
+ design::CableDesign;
+ temperature::Real = 20,
+ frequency::Real = 50
+)
+ T = promote_type(
+ eltype(design), typeof(float(temperature)), typeof(float(frequency))
+ )
+ value = convert(T, float(frequency))
+ return CableConstantsProblem{T, typeof(design)}(
+ design,
+ convert(T, float(temperature)),
+ value
+ )
+end
+
+"""
+$(TYPEDEF)
+
+Select the conductor and dielectric formulas used by a cable-constant
+computation.
+
+$(TYPEDFIELDS)
+"""
+struct CableConstantsFormulation{
+ M <: NamedTuple,
+ O <: FormulationOptions,
+ D <: NamedTuple
+} <: AbstractFormulation
+ "Registered physical formula selections."
+ methods::M
+ "Cable-constant formulation options."
+ options::O
+ "Requested declarations retained before formula-owner resolution."
+ definitions::D
+end
+
+function formulation_options(
+ ::Type{CableConstantsFormulation},
+ options::FormulationOptions
+)::FormulationOptions
+ isempty(options.data) || throw(ArgumentError(
+ "unknown cable-constant formulation options: $(keys(options.data))"))
+ return FormulationOptions()
+end
+
+description(::Type{<:CableConstantsFormulation}; compact::Bool = false) = "Cable constants"
+function description(::CableConstantsFormulation; compact::Bool = false)
+ description(CableConstantsFormulation; compact)
+end
+formula_id(::Type{<:CableConstantsFormulation}) = :cable_constants
+formula_id(::CableConstantsFormulation) = :cable_constants
+formulation_options(value::CableConstantsFormulation) = value.options
+function Base.pairs(::Type{CableConstantsFormulation}; quantity = nothing)
+ return pairs((;
+ (key=>family
+ for (key, family) in pairs(LineParametersFormulation; quantity)
+ if key ∉ (:earth_impedance, :earth_admittance, :earth_properties))...))
+end
+function description(::Type{CableConstantsFormulation}, slot::Val; compact::Bool=false)
+ description(LineParametersFormulation, slot; compact)
+end
+function Base.pairs(value::CableConstantsFormulation; quantity = nothing)
+ pairs(CableConstantsFormulation,
+ (methods = value.methods, requested = value.definitions,
+ options = value.options.data); quantity)
+end
+function Base.pairs(::Type{CableConstantsFormulation}, retained::NamedTuple; quantity = nothing)
+ pairs(LineParametersFormulation, retained; quantity, owner = CableConstantsFormulation)
+end
+
+"""Expose requested and resolved local formulas for passive scientific records."""
+function Base.NamedTuple(value::CableConstantsFormulation)
+ record = function (selected)
+ selected === nothing && return nothing
+ selected isa Symbol && return NamedTuple(formula(selected))
+ selected isa NamedTuple && return map(record, selected)
+ return NamedTuple(selected)
+ end
+ Record = NamedTuple{(:backend, :requested, :methods, :options),
+ Tuple{Symbol, NamedTuple, NamedTuple, NamedTuple}}
+ return Record((:cable_constants, map(record, value.definitions),
+ map(record, value.methods), value.options.data))
+end
+
+function _constants_formulation(
+ internal_impedance,
+ insulation_impedance,
+ shunt_model,
+ insulation_admittance,
+ semicon_admittance,
+ pipe_impedance,
+ temperature_dependence,
+ options::FormulationOptions
+)
+ methods = (
+ internal_impedance = InternalImpedance.Formula(internal_impedance),
+ insulation_impedance = InsulationImpedance.Formula(insulation_impedance),
+ shunt_model = ShuntModel.Formula(shunt_model),
+ insulation_admittance = InsulationAdmittance.Formula(insulation_admittance),
+ semicon_admittance = SemiconAdmittance.Formula(semicon_admittance),
+ pipe_impedance = PipeImpedance.Formula(pipe_impedance),
+ temperature_dependence = temperature_dependence === nothing ? nothing :
+ TemperatureDependent.Formula(temperature_dependence)
+ )
+ return CableConstantsFormulation(
+ methods,
+ formulation_options(CableConstantsFormulation, options),
+ (; internal_impedance, insulation_impedance, shunt_model, insulation_admittance,
+ semicon_admittance, pipe_impedance, temperature_dependence)
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct the cable-constant formula bundle.
+
+Each formula slot and the complete `options` tuple accepts a scalar selection
+or an explicit [`Grid`](@ref LineCableModels.ParametricBuilder.Grid)/
+[`Gridspace`](@ref LineCableModels.ParametricBuilder.Gridspace) source. Scalar
+inputs return one [`CableConstantsFormulation`](@ref). Varying inputs return a
+`Gridspace{CableConstantsFormulation}` of completed formulations.
+
+# Keywords
+
+- `internal_impedance`: conductor surface-impedance recipe.
+- `insulation_impedance`: longitudinal insulation-impedance recipe.
+- `shunt_model`: local geometry model. `:default`/`:equivalent` uses the equivalent
+ annular layer, `:boundary` explicitly computes lossless open-screen coupling.
+- `insulation_admittance`: insulation constitutive relation.
+- `semicon_admittance`: semiconducting-layer constitutive relation.
+- `pipe_impedance`: pipe-type selection. The coaxial pipe implementation is not
+ yet available. Ordinary concentric assemblies have no additional pipe term.
+- `temperature_dependence`: resistivity law. `:default` is linear and `nothing`
+ retains reference resistivity.
+- `options`: formulation controls. Currently empty.
+- `combine`: `:product` or `:zip` composition among varying fields.
+"""
+function CableConstantsFormulation(;
+ internal_impedance = formula(:default),
+ insulation_impedance = formula(:default),
+ shunt_model = formula(:default),
+ insulation_admittance = formula(:default),
+ semicon_admittance = formula(:default),
+ pipe_impedance = formula(:default),
+ temperature_dependence = formula(:default),
+ options = FormulationOptions(),
+ combine::Symbol = :product
+)
+ values = (
+ internal_impedance,
+ insulation_impedance,
+ shunt_model,
+ insulation_admittance,
+ semicon_admittance,
+ pipe_impedance,
+ temperature_dependence,
+ options
+ )
+ return parameterize(
+ CableConstantsFormulation,
+ (inputs...) -> _constants_formulation(inputs[1:(end - 1)]...,
+ last(inputs) isa NamedTuple ? FormulationOptions(last(inputs)) : last(inputs)),
+ values;
+ combine
+ )
+end
+
+Formulation(::Val{:cable_constants}; kwargs...) = CableConstantsFormulation(; kwargs...)
+
+function computation_options(
+ ::Type{<:CableConstantsFormulation},
+ options::ComputationOptions
+)::ComputationOptions
+ isempty(options.data) || throw(ArgumentError(
+ "CableConstants compute does not accept computation options",
+ ))
+ return ComputationOptions()
+end
+
+"""
+$(TYPEDEF)
+
+Own the local cable arrays, corrected resistivities, and reusable matrices for
+one cable-constant computation. The constructor consumes the completed
+blueprint without retaining a duplicate representation.
+
+$(TYPEDFIELDS)
+"""
+struct CableConstantsWorkspace{T <: Real, L, B}
+ "Concrete array payload used by local primitive assemblers."
+ cable::L
+ "Temperature-corrected conductor resistivities [Ω·m]."
+ rho::Vector{T}
+ "Reusable primitive, reduction, and result storage."
+ buffers::B
+end
+
+function CableConstantsWorkspace(
+ problem::CableConstantsProblem{T},
+ formulation::CableConstantsFormulation,
+ blueprint::CableBlueprint{T}
+) where {T <: Real}
+ return CableConstantsWorkspace(
+ problem,
+ formulation,
+ LocalCableData(blueprint)
+ )
+end
+
+function CableConstantsWorkspace(
+ problem::CableConstantsProblem{T},
+ formulation::CableConstantsFormulation,
+ cable::LocalCableData{T}
+) where {T <: Real}
+ @inbounds for assembly in cable.assemblies
+ isempty(cable.dielectric_ranges[first(assembly)]) && throw(ArgumentError(
+ "assembly core :$(cable.terminals[first(assembly)]) has no radial dielectric path",
+ ))
+ end
+ count = length(cable.terminals)
+ rho = Vector{T}(undef, length(cable.conductor_materials))
+
+ maximum_size = maximum(length, cable.assemblies)
+ removed = maximum_size - 1
+ buffers = (
+ Z = Matrix{Complex{T}}(undef, count, count),
+ Y = Matrix{Complex{T}}(undef, count, count),
+ reduced = Matrix{Complex{T}}(undef, 1, 1),
+ factor = Matrix{Complex{T}}(undef, removed, removed),
+ coupling = Matrix{Complex{T}}(undef, 1, removed),
+ right_hand_side = Matrix{Complex{T}}(undef, removed, 1),
+ indices = collect(1:maximum_size),
+ layer_coefficients = Vector{Complex{T}}(
+ undef, length(cable.dielectric_materials)
+ ),
+ dielectric_admittivity = Vector{Complex{T}}(undef, length(cable.dielectric_materials)),
+ R = Vector{T}(undef, length(cable.assemblies)),
+ L = Vector{T}(undef, length(cable.assemblies)),
+ C = Vector{T}(undef, length(cable.assemblies)),
+ G = Vector{T}(undef, length(cable.assemblies)),
+ observations = nothing
+ )
+ # The internal formula has an expression for each surface impedance that the geometry
+ # needs.
+ for kind in (any(>(0), cable.r_in) ? (:inner, :outer, :transfer) : (:outer,))
+ validate(Expression(formulation.methods.internal_impedance,
+ InternalImpedance.internal_impedance, Val(kind)))
+ end
+ buffers = initialize_buffers(formulation.methods, T, cable, (;), buffers)
+ return CableConstantsWorkspace{T, typeof(cable), typeof(buffers)}(
+ cable, rho, buffers
+ )
+end
+
+function _solve!(
+ workspace::CableConstantsWorkspace{T},
+ problem::CableConstantsProblem{T},
+ formulation::CableConstantsFormulation;
+ physical_inputs=completed_inputs(problem), gridpoint=Commons.gridpoint_id()
+) where {T <: Real}
+ buffers = workspace.buffers
+ ω = 2 * (one(T) * π) * problem.frequency
+ s = complex(zero(T), ω)
+ for (index, material) in pairs(workspace.cable.conductor_materials)
+ workspace.rho[index] = constitutive(formulation.methods.temperature_dependence,
+ material, problem.temperature; workspace)
+ end
+ dielectric!(buffers.dielectric_admittivity, workspace.cable, formulation.methods,
+ problem.frequency, problem.temperature; workspace)
+ cable_impedance!(
+ buffers.Z,
+ workspace.cable,
+ workspace.rho,
+ formulation.methods,
+ s; workspace
+ )
+ cable_admittance!(
+ buffers.Y,
+ workspace.cable,
+ buffers.dielectric_admittivity,
+ s,
+ buffers.layer_coefficients
+ )
+ keep = @view buffers.indices[1:1]
+ @inbounds for (assembly, chain) in pairs(workspace.cable.assemblies)
+ count = length(chain)
+ Z = @view buffers.Z[chain, chain]
+ eliminate = @view buffers.indices[2:count]
+ factor = @view buffers.factor[1:(count - 1), 1:(count - 1)]
+ coupling = @view buffers.coupling[:, 1:(count - 1)]
+ right_hand_side = @view buffers.right_hand_side[1:(count - 1), :]
+ kron_reduce!(
+ Z,
+ keep,
+ eliminate,
+ buffers.reduced,
+ factor,
+ coupling,
+ right_hand_side
+ )
+ equivalent = buffers.reduced[1, 1]
+ buffers.R[assembly] = real(equivalent)
+ buffers.L[assembly] = imag(equivalent) / ω
+
+ Y = buffers.Y[first(chain), first(chain)]
+ iszero(Y) && throw(ArgumentError(
+ "assembly core :$(workspace.cable.terminals[first(chain)]) has no finite radial dielectric path",
+ ))
+ buffers.G[assembly] = real(Y)
+ buffers.C[assembly] = imag(Y) / ω
+ end
+ return CableConstants(
+ Symbol[workspace.cable.terminals[first(chain)]
+ for chain in workspace.cable.assemblies],
+ buffers.R,
+ buffers.L,
+ buffers.C,
+ buffers.G,
+ problem.frequency,
+ ComputationDetails(NamedTuple{
+ (:formulations,:selections,:formulation_fields,:shunt_model,:inputs,:gridpoint),
+ NTuple{6,NamedTuple}}((values(completed_formulation(formulation))...,
+ workspace.cable.shunt_details,physical_inputs,gridpoint)))
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compute earth-free cable constants with the default coaxial formulation.
+"""
+function compute(
+ problem::CableConstantsProblem;
+ options::Union{NamedTuple, ComputationOptions} = ComputationOptions()
+)
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ return compute(
+ LineCableModelsCoaxial(),
+ problem,
+ CableConstantsFormulation();
+ options
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compute earth-free cable constants with an explicit formula bundle.
+"""
+function compute(
+ problem::CableConstantsProblem,
+ formulation::CableConstantsFormulation;
+ options::Union{NamedTuple, ComputationOptions} = ComputationOptions()
+)
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ return compute(LineCableModelsCoaxial(), problem, formulation; options)
+end
+
+function compute(
+ problem::CableConstantsProblem,
+ formulations::AbstractVector{<:CableConstantsFormulation};
+ options::Union{NamedTuple, ComputationOptions} = ComputationOptions()
+)
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ return compute(LineCableModelsCoaxial(), problem, formulations; options)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compute earth-free cable constants through the coaxial backend tag.
+"""
+function compute(
+ engine::LineCableModelsCoaxial,
+ problem::CableConstantsProblem,
+ formulation::CableConstantsFormulation = CableConstantsFormulation();
+ options::Union{NamedTuple, ComputationOptions} = ComputationOptions()
+)
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ values = compute(
+ engine,
+ problem,
+ typeof(formulation)[formulation];
+ options
+ )
+ return first(values)
+end
+
+function compute(
+ engine::LineCableModelsCoaxial,
+ problem::CableConstantsProblem,
+ formulations::AbstractVector{<:CableConstantsFormulation};
+ options::Union{NamedTuple, ComputationOptions} = ComputationOptions()
+)
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ computation_options(CableConstantsFormulation, options)
+ isempty(formulations) && throw(ArgumentError(
+ "cable-constant formulation collections cannot be empty",
+ ))
+ validate(problem)
+ for formulation in formulations
+ validate(problem.design, formulation.methods.pipe_impedance, engine)
+ end
+ blueprints = flatten(engine, [problem.design], eltype(problem), formulations)
+ cables = [LocalCableData(first(blueprints))]
+ for index in 2:length(blueprints)
+ previous = findfirst(other -> other === blueprints[index], blueprints)
+ push!(cables, previous < index ? cables[previous] :
+ LocalCableData(blueprints[index]))
+ end
+ physical_inputs = completed_inputs(problem)
+ source_id = Commons.gridpoint_id().source_id
+ return map(formulations, cables, eachindex(formulations)) do formulation, cable, index
+ workspace = CableConstantsWorkspace(problem, formulation, cable)
+ _solve!(workspace, problem, formulation; physical_inputs,
+ gridpoint=Commons.gridpoint_id(;source_id,formulation_index=index))
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate earth-free constants directly from one completed cable design.
+"""
+function CableConstants(
+ design::CableDesign;
+ temperature::Real = 20,
+ frequency::Real = 50,
+ formulation::CableConstantsFormulation = CableConstantsFormulation(),
+ options::Union{NamedTuple, ComputationOptions} = ComputationOptions()
+)
+ problem = CableConstantsProblem(design; temperature, frequency)
+ return compute(problem, formulation; options)
+end
+
+function computation_details(
+ ::Type{<:CableConstantsFormulation},
+ result::CableConstants
+)::ComputationDetails
+ return details(result)
+end
diff --git a/src/engine/dataframe.jl b/src/engine/dataframe.jl
deleted file mode 100644
index af5579479..000000000
--- a/src/engine/dataframe.jl
+++ /dev/null
@@ -1,292 +0,0 @@
-import DataFrames: DataFrame, metadata!
-
-const _LP_FREQ_COL = :frequency
-
-const _SERIES_DUMMY = SeriesImpedance(zeros(ComplexF64, 1, 1, 1))
-const _SHUNT_DUMMY = ShuntAdmittance(zeros(ComplexF64, 1, 1, 1))
-
-_freq_units_label(unit::Symbol) = unit_text(unit, "Hz")
-
-_length_unit(per::Symbol) = per
-
-function _column_name(meta::ComponentMetadata)
- component = meta.component
- if component in (:resistance, :inductance, :conductance, :capacitance)
- return Symbol(meta.symbol)
- else
- return Symbol(component)
- end
-end
-
-function _normalize_quantity_units(units)
- return normalize_quantity_units(units)
-end
-
-function _frequency_vector(obj, freqs)
- if freqs === nothing
- return float.(collect(axes(obj, 3)))
- else
- f = collect(freqs)
- length(f) == size(obj, 3) ||
- Base.error("Frequency vector length does not match object samples")
- return float.(f)
- end
-end
-
-function _frequency_vector(slice::AbstractVector, freqs::AbstractVector)
- f = collect(freqs)
- length(f) == length(slice) ||
- Base.error("Frequency vector length must match slice length")
- return float.(f)
-end
-
-function _build_dataframe(
- slice,
- freq_raw::Vector{<:Real},
- comps::Vector{ComponentMetadata},
- units::Dict{Symbol, Symbol},
- length_unit::Symbol,
- freq_unit::Symbol,
- tol::Real,
-)
- freq_scale = frequency_scale(freq_unit)
- freq_values = freq_raw .* freq_scale
- unit_map = Dict{Symbol, String}(
- _LP_FREQ_COL => _freq_units_label(freq_unit),
- )
- df = DataFrame(_LP_FREQ_COL => freq_values)
- for meta in comps
- q_prefix = resolve_quantity_prefix(meta.quantity, units)
- scale = quantity_scale(q_prefix)
- l_scale = meta.unit.per_length ? length_scale(length_unit) : 1.0
- raw_vals = component_values(meta.component, slice, freq_raw)
- col_data = map(raw_vals) do x
- _clip_field(x * (scale * l_scale), tol)
- end
- col_name = _column_name(meta)
- df[!, col_name] = col_data
- unit_map[col_name] =
- composite_unit(q_prefix, meta.unit.symbol, meta.unit.per_length, length_unit)
- end
- metadata!(df, "units", unit_map, style = :note)
- return df
-end
-
-function _matrix_dataframes(
- obj,
- freq_raw::Vector{<:Real},
- comps::Vector{ComponentMetadata},
- units::Dict{Symbol, Symbol},
- length_unit::Symbol,
- freq_unit::Symbol,
- tol::Real,
-)
- nx, ny, _ = size(obj.values)
- result = Matrix{DataFrame}(undef, nx, ny)
- for i in 1:nx, j in 1:ny
- slice = @view obj.values[i, j, :]
- result[i, j] = _build_dataframe(
- slice,
- freq_raw,
- comps,
- units,
- length_unit,
- freq_unit,
- tol,
- )
- end
- return result
-end
-
-# function _slice_dataframe(
-# slice::AbstractVector,
-# kind::Symbol,
-# freq_raw::Vector{<:Real},
-# mode::Symbol,
-# coord::Symbol,
-# units::Dict{Symbol, Symbol},
-# length_unit::Symbol,
-# freq_unit::Symbol,
-# tol::Real,
-# )
-# resolved_kind = _resolve_kind(slice, kind, tol)
-# comps =
-# resolved_kind == :series_impedance ?
-# components_for(_SERIES_DUMMY, mode, coord) :
-# components_for(_SHUNT_DUMMY, mode, coord)
-# return _build_dataframe(
-# slice,
-# freq_raw,
-# comps,
-# units,
-# length_unit,
-# freq_unit,
-# tol,
-# )
-# end
-
-"""
- DataFrame(Z::SeriesImpedance; freqs=nothing, mode=:RLCG, coord=:cart,
- freq_unit=:base, length_unit=:kilo, quantity_units=nothing,
- tol=sqrt(eps(Float64)))
-
-Convert the entries of a `SeriesImpedance` object into per-element `DataFrame`s
-indexed by frequency. Returns an `n×n` matrix of `DataFrame`s whose rows
-correspond to conductor indices.
-
-- `freqs`: explicit frequency vector in Hz. Defaults to `1:length(freq axis)`.
-- `mode`: `:RLCG` (default) or `:ZY`. For `:ZY`, `coord` may be `:cart` or `:polar`.
-- `length_unit`: metric prefix for per-length units (e.g. `:kilo` ⇒ per km).
-- `quantity_units`: optional overrides for the quantity metric prefixes used in each column.
-- `tol`: absolute tolerance used to zero-out tiny numerical noise.
-"""
-function DataFrame(
- Z::SeriesImpedance;
- freqs = nothing,
- mode::Symbol = :RLCG,
- coord::Symbol = :cart,
- freq_unit::Symbol = :base,
- length_unit::Symbol = :kilo,
- quantity_units = nothing,
- tol::Real = sqrt(eps(Float64)),
- per_length::Bool = true,
-)
- freq_raw = _frequency_vector(Z, freqs)
- units = _normalize_quantity_units(quantity_units)
- comps = components_for(Z, mode, coord; per_length = per_length)
- return _matrix_dataframes(
- Z,
- freq_raw,
- comps,
- units,
- length_unit,
- freq_unit,
- float(tol),
- )
-end
-
-"""
- DataFrame(Y::ShuntAdmittance; freqs=nothing, mode=:RLCG, coord=:cart,
- freq_unit=:base, length_unit=:kilo, quantity_units=nothing,
- tol=sqrt(eps(Float64)))
-
-Convert the entries of a `ShuntAdmittance` object into per-element `DataFrame`s
-indexed by frequency. Returns an `n×n` matrix of `DataFrame`s.
-
-Keyword arguments mirror those of `DataFrame(::SeriesImpedance)`.
-"""
-function DataFrame(
- Y::ShuntAdmittance;
- freqs = nothing,
- mode::Symbol = :RLCG,
- coord::Symbol = :cart,
- freq_unit::Symbol = :base,
- length_unit::Symbol = :kilo,
- quantity_units = nothing,
- tol::Real = sqrt(eps(Float64)),
- per_length::Bool = true,
-)
- freq_raw = _frequency_vector(Y, freqs)
- units = _normalize_quantity_units(quantity_units)
- comps = components_for(Y, mode, coord; per_length = per_length)
- return _matrix_dataframes(
- Y,
- freq_raw,
- comps,
- units,
- length_unit,
- freq_unit,
- float(tol),
- )
-end
-
-function _clip_field(x::Real, tol)
- isfinite(x) || return x
- return _clip(x, tol)
-end
-
-function _clip_field(m::Measurements.Measurement, tol)
- v = _clip(value(m), tol)
- u = _clip(uncertainty(m), tol)
- return Measurements.measurement(v, u)
-end
-
-_clip_field(x, _) = x
-
-function _resolve_kind(slice, kind::Symbol, tol::Real)
- kind != :auto && return kind
- max_real = 0.0
- max_imag = 0.0
- for z in slice
- r = real(z)
- i = imag(z)
- val_r = _scalar_abs(r)
- val_i = _scalar_abs(i)
- isfinite(val_r) && val_r > max_real && (max_real = val_r)
- isfinite(val_i) && val_i > max_imag && (max_imag = val_i)
- end
- if max_real <= tol && max_imag > tol
- return :shunt_admittance
- else
- return :series_impedance
- end
-end
-
-_scalar_abs(x::Real) = abs(x)
-_scalar_abs(m::Measurements.Measurement) = abs(value(m))
-
-"""
- DataFrame(LP::LineParameters; mode=:RLCG, coord=:cart,
- freq_unit=:base, length_unit=:kilo, quantity_units=nothing,
- tol=sqrt(eps(Float64)))
-Convert `LP.Z` and `LP.Y` to per-element, frequency-indexed `DataFrame`s
-using `LP.f` as the authoritative frequency vector. Returns `(df_z, df_y)`,
-each an `n×n` `Matrix{DataFrame}`.
-"""
-function DataFrame(
- LP::LineParameters;
- mode::Symbol = :RLCG,
- coord::Symbol = :cart,
- freq_unit::Symbol = :base,
- length_unit::Symbol = :kilo,
- quantity_units = nothing,
- tol::Real = sqrt(eps(Float64)),
- per_length::Bool = true,
-)
- # --- validations: LP is the source of truth for frequency samples ----
- @assert eltype(LP.f) <: Real "LP.f must be real-valued frequencies."
- nzx, nzy, nfZ = size(LP.Z.values)
- nyx, nyy, nfY = size(LP.Y.values)
- nfZ == nfY ||
- Base.error("Z and Y have different number of frequency samples: $nfZ ≠ $nfY.")
- length(LP.f) == nfZ || Base.error(
- "Length of LP.f ($(length(LP.f))) does not match samples in Z/Y ($nfZ).",
- )
-
- # --- delegate with LP.f explicitly (no guessing, no manual input) ----
- df_z = DataFrame(
- LP.Z;
- freqs = LP.f,
- mode = mode,
- coord = coord,
- freq_unit = freq_unit,
- length_unit = length_unit,
- quantity_units = quantity_units,
- tol = tol,
- per_length = per_length,
- )
-
- df_y = DataFrame(
- LP.Y;
- freqs = LP.f,
- mode = mode,
- coord = coord,
- freq_unit = freq_unit,
- length_unit = length_unit,
- quantity_units = quantity_units,
- tol = tol,
- per_length = per_length,
- )
-
- return df_z, df_y
-end
diff --git a/src/engine/earthadmittance/EarthAdmittance.jl b/src/engine/earthadmittance/EarthAdmittance.jl
index 8905cb2b2..7b536d316 100644
--- a/src/engine/earthadmittance/EarthAdmittance.jl
+++ b/src/engine/earthadmittance/EarthAdmittance.jl
@@ -1,28 +1,66 @@
"""
- LineCableModels.Engine.EarthAdmittance
+ LineCableModels.Engine.EarthAdmittance
+
+Define earth-return admittance recipes, numerical primitives, and
+formula-owned frequency functors.
# Dependencies
$(IMPORTS)
-# Exports
-
-$(EXPORTS)
"""
module EarthAdmittance
+import ...Commons: FormulationOptions, formulas
# Export public API
-export Papadopoulos
+export Formula, formula_id, earth_potential_coefficient, formulas
# 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")
+#! explicit-imports: off
+# These abbreviations are expanded in this module docstring and included files.
+using DocStringExtensions: IMPORTS, TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+#! explicit-imports: on
+import ...Earth: EquivalentHomogeneous
+import ..Engine: EarthAdmittanceFormulation, formula_id
+#! explicit-imports: off
+# Explicitly included equations share these physical and numerical operations.
+import ...LineCableModels: validate
+import ...Commons: Functor
+import ..Engine: EarthPair
+import ..Engine: EarthPlan, earth!, same_physical_state, layer_index
+using ...Earth: EarthModel
+import ...Commons: initialize_buffers
+import ..Engine: computation_type, EarthImpedanceFormulation, special_besselix,
+ SpectralIntegral, integrate
+using LinearAlgebra: lu!, ldiv!
+import ..EarthImpedance
+import ...LineCableModels: FormulaDefinition, Expression, nominal
+import ..Engine: description, conductivity
+import ..Engine: formulation_options
+import ..Engine: AirVoltageSpectrum, earth_spectral_term, earth_spectral_value,
+ earth_spectral_points!, earth_contour_angle, earth_direct,
+ outgoing_root, bessel_i0m1, bessel_current_ratio
+using ...Commons: vacuum_permittivity
+#! explicit-imports: on
+
+public source_coefficients, earth!
+
+include("interface.jl")
+
+#! explicit-imports: off
+const FORMULAS = (
+ include("formulas/unified.jl"),
+ include("formulas/default.jl"),
+ include("formulas/ideal.jl"),
+ include("formulas/pollaczek1926.jl"),
+ include("formulas/wise1948.jl"),
+ include("formulas/xue2018.jl")
+)
+#! explicit-imports: on
+
+"""
+Return registered earth-admittance identities, including unimplemented equations.
+"""
+formulas(::Type{<:Formula}) = FORMULAS
end # module EarthAdmittance
diff --git a/src/engine/earthadmittance/base.jl b/src/engine/earthadmittance/base.jl
deleted file mode 100644
index 69adbf0a5..000000000
--- a/src/engine/earthadmittance/base.jl
+++ /dev/null
@@ -1,7 +0,0 @@
-@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/formulas/default.jl b/src/engine/earthadmittance/formulas/default.jl
new file mode 100644
index 000000000..dea72e577
--- /dev/null
+++ b/src/engine/earthadmittance/formulas/default.jl
@@ -0,0 +1,12 @@
+"""
+$(TYPEDSIGNATURES)
+
+Route the default selection to the explicit `:unified` implementation.
+No numerical equation is owned by `:default`.
+"""
+description(::Type{<:Formula{:default}}; compact::Bool=false) =
+ compact ? description(Formula{:unified};compact=true) : "Default routing to :unified"
+
+Formula{:default}(; kwargs...) = Formula{:unified}(; kwargs...)
+
+:default
diff --git a/src/engine/earthadmittance/formulas/ideal.jl b/src/engine/earthadmittance/formulas/ideal.jl
new file mode 100644
index 000000000..feded847b
--- /dev/null
+++ b/src/engine/earthadmittance/formulas/ideal.jl
@@ -0,0 +1,88 @@
+function description(::Type{<:Formula{:ideal}}; compact::Bool = false)
+ compact ? "Ideal earth" : "Ideal-earth electrostatic image potential coefficients"
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate Maxwell's electrostatic image potential coefficients above an
+ideal conducting earth plane, in m/F.
+
+# Assumptions
+
+Aerial conductors in layer 1 above an ideal conducting plane, with air
+permittivity ``\\varepsilon_0`` [F/m]. The external potential coefficient is
+zero whenever either conductor is buried, including buried self and mutual
+interactions. Insulation potential coefficients remain separate.
+
+# Expression
+
+```math
+P_{ii}=\\frac{1}{2\\pi\\varepsilon_0}\\ln\\frac{2h_i}{r_i},\\qquad
+P_{ij}=\\frac{1}{2\\pi\\varepsilon_0}\\ln\\frac{D_{ij}}{d_{ij}},
+```
+
+```math
+d_{ij}=\\sqrt{x_{ij}^2+(h_i-h_j)^2},\\qquad
+D_{ij}=\\sqrt{x_{ij}^2+(h_i+h_j)^2}.
+```
+
+Here ``h_i`` is aerial height, ``r_i`` is the exterior radius, and ``x_{ij}``
+is horizontal separation. ``d_{ij}`` and ``D_{ij}`` are distances to the real
+conductor and its image, respectively, all in meters.
+For lossless materials, the complete potential matrix gives purely imaginary
+admittance ``Y=j\\omega P^{-1}``. Capacitance itself is real.
+
+The coaxial backend evaluates these equations directly. PSCAD maps this
+selection to its native potential model. Its direct-integration setting can
+produce nonzero aerial conductance even with lossless insulation. The adapter
+preserves that native deviation rather than changing these equations.
+
+# Reference
+
+PSCAD 5.1 help, *Deriving System Y and Z Matrices*, Eq. (8-25), and
+*Mutual Impedance with Earth Return*, Eq. (8-35).
+"""
+function earth_potential_coefficient(
+ ::Formula{:ideal}, ::Val{:self}, ::Val{1}, ::Val{1},
+ functor, workspace)
+ pair = functor.input.pair
+ ε0 = vacuum_permittivity(typeof(real(functor.input.jω)))
+ return log(2 * pair.heights[1] / pair.radius) / (2 * (one(ε0) * π) * ε0)
+end
+
+function earth_potential_coefficient(
+ ::Formula{:ideal}, ::Val{:mutual}, ::Val{1}, ::Val{1},
+ functor, workspace)
+ pair = functor.input.pair
+ ε0 = vacuum_permittivity(typeof(real(functor.input.jω)))
+ hi, hj = pair.heights
+ D = hypot(pair.separation, hi + hj)
+ d = hypot(pair.separation, hi - hj)
+ return log(D / d) / (2 * (one(ε0) * π) * ε0)
+end
+
+function earth_potential_coefficient(
+ ::Formula{:ideal}, ::Union{Val{:self}, Val{:mutual}}, ::Val{2}, ::Val{2},
+ functor, workspace)
+ return zero(functor.input.jω)
+end
+
+function earth_potential_coefficient(
+ ::Formula{:ideal}, ::Val{:mutual}, ::Val{1}, ::Val{2},
+ functor, workspace)
+ return zero(functor.input.jω)
+end
+
+function earth_potential_coefficient(
+ ::Formula{:ideal}, ::Val{:mutual}, ::Val{2}, ::Val{1},
+ functor, workspace)
+ return zero(functor.input.jω)
+end
+
+function formulation_options(::Expression{
+ <:Formula{:ideal}, typeof(earth_potential_coefficient)})
+ FormulationOptions()
+end
+
+:ideal
diff --git a/src/engine/earthadmittance/formulas/pollaczek1926.jl b/src/engine/earthadmittance/formulas/pollaczek1926.jl
new file mode 100644
index 000000000..a3355de6d
--- /dev/null
+++ b/src/engine/earthadmittance/formulas/pollaczek1926.jl
@@ -0,0 +1,35 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Classical homogeneous-earth underground potential coefficient.
+
+**Availability.** Registered scientific identity. The coaxial implementation is
+not yet implemented. The method fails without a numerical fallback.
+
+**Expression.**
+
+```math
+P_{e,ij}^{11}=\\frac{j\\omega}{2\\pi(\\sigma_1+j\\omega\\varepsilon_1)}
+[K_0(\\gamma_0d_{ij})-K_0(\\gamma_0D_{ij})].
+```
+
+**Reference.** F. Pollaczek, “Über das Feld einer unendlich langen
+wechselstromdurchflossenen Einfachleitung,” *Elektrische Nachrichtentechnik*,
+3, 339–360, 1926. Potential-coefficient transcription follows Ametani et al.,
+IET, 2021.
+"""
+function description(::Type{<:Formula{:pollaczek1926}}; compact::Bool = false)
+ compact ? "Pollaczek" : "Pollaczek underground potential coefficients (1926) — not yet implemented"
+end
+
+function earth_potential_coefficient(
+ ::Formula{:pollaczek1926}, kind::Union{Val{:self}, Val{:mutual}}, ::Val{2}, ::Val{2},
+ functor, workspace
+)
+ throw(ArgumentError("earth_potential_coefficient :pollaczek1926 ($kind), source layer 2, target layer 2: not yet implemented for the coaxial backend"))
+end
+
+formulation_options(::Expression{<:Formula{:pollaczek1926}, typeof(earth_potential_coefficient)}) =
+ FormulationOptions()
+
+:pollaczek1926
diff --git a/src/engine/earthadmittance/formulas/unified.jl b/src/engine/earthadmittance/formulas/unified.jl
new file mode 100644
index 000000000..8acbc40a7
--- /dev/null
+++ b/src/engine/earthadmittance/formulas/unified.jl
@@ -0,0 +1,361 @@
+"""
+$(TYPEDSIGNATURES)
+
+Validate Unified's prescribed longitudinal wavenumber Γ \\[1/m\\]. A scalar
+applies at every frequency. A nonempty vector follows the frequency order. Return `Γ`.
+"""
+function validate(Γ, ::Type{<:Union{EarthImpedance.Formula{:unified}, Formula{:unified}}})
+ Γ isa Union{Number, AbstractVector} || throw(ArgumentError(
+ "unified Γ must be a scalar or frequency-aligned vector [1/m]"))
+ values = Γ isa Number ? (Γ,) : Γ
+ !isempty(values) &&
+ all(value -> value isa Number && !(value isa Bool) && isfinite(value), values) ||
+ throw(ArgumentError("unified Γ must be a finite scalar or nonempty finite vector [1/m]"))
+ return Γ
+end
+
+function formulation_options(
+ owner::Type{<:Union{EarthImpedance.Formula{:unified}, Formula{:unified}}},
+ options::FormulationOptions)
+ argument = validate(get(options.data, :Γ, 0), owner)
+ return FormulationOptions(merge(options.data,
+ (; Γ = argument isa AbstractVector ? copy(argument) : argument)))
+end
+
+# The formula's construction validated Γ. Its expressions take the value as supplied.
+function formulation_options(
+ expression::Expression{<:Union{
+ EarthImpedance.Formula{:unified}, Formula{:unified}}},
+ ::Val{:Γ}, default, supplied)
+ return supplied
+end
+
+function description(::Type{<:Formula{:unified}}; compact::Bool = false)
+ compact ? "Unified" :
+ "Unified circumferential earth potential with complete enclosed-current normalization"
+end
+
+function description(::Type{<:Formula{:unified}}, ::Val{:Γ}, value::Number; compact::Bool = false)
+ "Γ="*string(value)*" m⁻¹"
+end
+function description(::Type{<:Formula{:unified}}, ::Val{:Γ}, value::AbstractVector; compact::Bool = false)
+ "Γ=["*join(value, ", ")*"] m⁻¹ (frequency order)"
+end
+
+function computation_type(::Type{T},
+ selected::Union{EarthImpedance.Formula{:unified}, Formula{:unified}},
+ frequencies) where {T <: Real}
+ argument = selected.options.data.Γ
+ argument isa AbstractVector && length(argument) != length(frequencies) &&
+ throw(DimensionMismatch("unified Γ must contain one value per frequency sample"))
+ argument isa Number &&
+ return promote_type(T, typeof(real(argument)), typeof(imag(argument)))
+ return foldl(argument; init = T) do scalar, value
+ promote_type(scalar, typeof(real(value)), typeof(imag(value)))
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate both source coefficients of a conductor pair for circumferentially averaged fields
+in two homogeneous half-spaces: the axial-field coefficient per source current \\[Ω/m\\] and
+the source-charge coefficient of conductor voltage \\[m/F\\]. It takes the Unified formula of
+either earth family. The exp(jωt) convention and caller-prescribed Γ \\[1/m\\]
+give
+
+```math
+\\widetilde K_{ij}=\\mathcal Z_{ij}-\\Gamma^2\\mathcal P_{\\phi,ij}/(j\\omega).
+```
+
+The electric scalar-potential contribution is retained when Γ is nonzero. Air targets use
+the interface z=0 as voltage reference. Buried targets use deep earth. With source
+amplitudes q̃ and conductor voltages U,
+
+```math
+U=\\widetilde P\\widetilde q,\\qquad P_e T_I=\\widetilde P.
+```
+
+The source-potential coefficient includes the complete-field voltage relation, not just
+scalar potential. The air endpoint and conductor field are combined before quadrature to
+preserve cancellation. Source columns include exp(abs(real(κⱼrⱼ))) scaling, shared by both
+coefficients and the enclosed-current matrices, and it cancels in the physical matrix solve.
+`functor.input.pair` retains source-target geometry \\[m\\]. `functor.state` contains the
+evaluated media of the system, and the workspace buffers store the circumferential factors.
+The complete current map converts the axial-field coefficients to physical series impedance.
+The direct-image term is evaluated once for both coefficients. Quadrature estimates remain
+diagnostic warnings.
+
+# Returns
+
+- `(axial, potential)`: the scaled axial-field coefficient \\[Ω/m\\] and the scaled
+ source-potential coefficient \\[m/F\\].
+
+# Reference
+
+User-supplied manuscript, *Unified circumferentially averaged framework for overhead,
+buried, and mixed conductor systems*: complete-field current relation, voltage and
+source-charge maps.
+"""
+function source_coefficients(
+ formula::Union{EarthImpedance.Formula{:unified}, Formula{:unified}},
+ kind::Union{Val{:self}, Val{:mutual}}, source::Union{Val{1}, Val{2}},
+ target::Union{Val{1}, Val{2}}, functor, workspace)
+ u=functor.state
+ pair=functor.input.pair
+ buffers=workspace.buffers
+ row, column=pair.row, pair.column
+ hp, hq=abs(pair.heights[2]), abs(pair.heights[1])
+ y=pair.separation
+ r=workspace.plan.geometry.radius[row]
+ average=buffers.circumference_average[row]
+ argument=buffers.radial_argument[row]
+ sp, sq=buffers.source_logscale[row], buffers.source_logscale[column]
+ πT=one(u.jω)*π
+ integration=functor.input.options.data.integration
+ context=(
+ formula = :unified, frequency = imag(u.jω)/(2π), receiver = row, source = column)
+ direct=earth_direct(formula, kind, source, target, u, pair, r, average, argument, sp, sq)
+ # The axial field, before the source potential: the integral records keep this order.
+ medium=target === Val(1) ? 1 : 2
+ z=u.jω/πT*average*earth_spectral_term(formula, Val(:Z), target, source, u,
+ hp, hq, y, zero(r), sp+sq,
+ integration.method, integration.options, buffers; context)
+ z+=u.jω*u.mu[medium]/(2πT)*direct
+ phi=zero(z)
+ if !iszero(u.Γ)
+ phi=u.jω/πT*average*earth_spectral_term(formula, Val(:phi), target, source, u,
+ hp, hq, y, zero(r), sp+sq,
+ integration.method, integration.options, buffers; context)
+ phi+=u.jω/(2πT*u.sh[medium])*direct
+ end
+ axial=z-u.Γ^2/u.jω*phi
+ # A buried target takes its voltage from deep earth.
+ if target === Val(2)
+ value=u.jω/(one(u.jω)*π)*average*earth_spectral_term(formula, Val(:voltage), Val(2),
+ source, u, hp, hq, y, zero(r), sp+sq, integration.method,
+ integration.options, buffers; context)
+ return (axial, value+u.jω/(2*(one(u.jω)*π)*u.sh[2])*direct)
+ end
+ # An air target takes its voltage from the interface.
+ if abs(real(nominal(argument)))<300
+ R=typeof(float(nominal(real(u.jω))))
+ padding=hq/2
+ angle=min(R(π)/6, atan(R(nominal(hq))/(4max(R(nominal(y+r)), eps(R)))))
+ angle=earth_contour_angle(u, angle)
+ points=earth_spectral_points!(buffers.earth_spectrum, u, hq-padding, y, r, angle)
+ g=(hp, hq, radius = r, padding, logscale = sq, i0minus = bessel_i0m1(argument))
+ S=source === Val(1) ? 1 : 2
+ kernel=AirVoltageSpectrum{S, typeof(u), typeof(g)}(u, g)
+ scale=max(R(abs(nominal(u.k[2]))), inv(R(nominal(hq))))
+ contour=scale*cis(angle)
+ integral=SpectralIntegral(t->contour*earth_spectral_value(
+ kernel, contour*t, hq-padding, y, zero(r)))
+ points ./= scale
+ push!(points, one(scale))
+ value,
+ _=integrate(integral, integration.method, integration.options,
+ buffers; points, coordinate_type = R,
+ context = merge(context, (term = :air_voltage,)), observations = buffers.observations)
+ return (axial, u.jω/πT*value+u.jω/(2πT*u.sh[1])*direct)
+ end
+ value=u.jω/πT*average*earth_spectral_term(formula, Val(:voltage), Val(1), source, u,
+ hp, hq, y, zero(r), sp+sq, integration.method, integration.options, buffers; context)
+ value+=u.jω/(2πT*u.sh[1])*direct
+ return (axial, value+u.jω/πT*earth_spectral_term(formula, Val(:air_reference), Val(1),
+ source, u, zero(r), hq, y, r, sq, integration.method, integration.options, buffers;
+ context))
+end
+
+function Expression(formula::Union{EarthImpedance.Formula{:unified}, Formula{:unified}},
+ pair::EarthPair)
+ return Expression(formula, source_coefficients,
+ Val(pair.row == pair.column ? :self : :mutual), Val.(layer_index(pair))...)
+end
+
+function formulation_options(::Expression{
+ <:Union{EarthImpedance.Formula{:unified}, Formula{:unified}},
+ typeof(source_coefficients),
+ A}) where {
+ A <: Tuple{
+ Union{Val{:self}, Val{:mutual}}, Union{Val{1}, Val{2}}, Union{Val{1}, Val{2}}}}
+ return FormulationOptions((Γ = 0, integration = (method = :quad, options = (;))))
+end
+
+function validate(reduction::EquivalentHomogeneous.Formula{:bottommost},
+ ::Expression{<:Union{EarthImpedance.Formula{:unified}, Formula{:unified}},
+ typeof(source_coefficients)})
+ return reduction
+end
+
+# The unified calculation computes the whole system, every conductor pair, even when its slot
+# publishes some of them, and both coefficients of each pair: either output needs both. Its
+# formulation options are common to all pairs, and the exterior circumferences must lie in one
+# half-space and must not overlap.
+function EarthPlan(formula::Union{EarthImpedance.Formula{:unified}, Formula{:unified}},
+ earth, model::EarthModel, physical::AbstractVector{<:EarthPair}, indices,
+ geometry::NamedTuple)
+ whole=invoke(EarthPlan,
+ Tuple{Union{EarthImpedanceFormulation, EarthAdmittanceFormulation},
+ Any, EarthModel, AbstractVector{<:EarthPair}, Any, NamedTuple},
+ formula, earth, model, physical, eachindex(physical), geometry)
+ calculation=only(whole.calculations)
+ options=first(calculation.parts).options
+ all(part->isequal(part.options, options), calculation.parts) ||
+ throw(ArgumentError("the unified earth-return calculation requires common formulation options for all conductor pairs"))
+ for entry in calculation.pairs
+ pair=entry.pair
+ target_radius=geometry.radius[pair.row]
+ source_radius=geometry.radius[pair.column]
+ # Physical shapes can be disjoint while their equivalent circles overlap.
+ # The field coefficients integrate these circles, so their applicability
+ # is checked here using the engine's geometry.
+ if pair.row==pair.column
+ target_radius
+ target_radius+source_radius || throw(DomainError((pair.row, pair.column),
+ "exterior circumferences must not overlap"))
+ end
+ end
+ published=(; formula, pairs = collect(indices))
+ impedance=formula isa EarthImpedanceFormulation ? published : nothing
+ admittance=formula isa EarthAdmittanceFormulation ? published : nothing
+ return EarthPlan((merge(calculation, (; impedance, admittance)),))
+end
+
+# Unified's arithmetic reads the pair's geometry and the conductor radii, not its indices.
+function same_physical_state(::Union{EarthImpedance.Formula{:unified}, Formula{:unified}},
+ a::EarthPair, b::EarthPair, geometry::NamedTuple)
+ inputs(pair)=(pair.row==pair.column, pair.layers, pair.heights, pair.separation,
+ geometry.radius[pair.row], geometry.radius[pair.column])
+ return same_physical_state(inputs(a), inputs(b))
+end
+
+# An impedance and an admittance formula of Unified share one calculation when their model
+# parameters and requested equivalent earths agree: they solve the same system.
+function same_physical_state(z::EarthImpedance.Formula{:unified}, p::Formula{:unified})
+ return same_physical_state(z.parameters, p.parameters) &&
+ same_physical_state(z.equivalent_earth, p.equivalent_earth)
+end
+
+function initialize_buffers(
+ selected::Union{EarthImpedance.Formula{:unified}, Formula{:unified}},
+ ::Type{T}, input, plan, buffers) where {T}
+ buffers=initialize_buffers(selected.equivalent_earth, T, input, plan, buffers)
+ buffers=initialize_buffers(SpectralIntegral, Val(:quad), T, input, plan, buffers)
+ haskey(buffers, :current_map) && return buffers
+ R=typeof(float(nominal(one(T))))
+ n=length(plan.geometry.radius)
+ axial_field=Matrix{Complex{T}}(undef, n, n)
+ return merge(buffers,
+ (
+ axial_field, source_potential = similar(axial_field), current_map = similar(axial_field),
+ enclosed_impedance = similar(axial_field), enclosed_potential = similar(axial_field),
+ current_factor = similar(axial_field), current_rhs = similar(axial_field),
+ radial_argument = Vector{Complex{T}}(undef, n), source_logscale = Vector{T}(undef, n),
+ circumference_average = Vector{Complex{T}}(undef, n), radial_current = Vector{Complex{T}}(undef, n),
+ earth_spectrum = (points = sizehint!(R[], 128), seeds = sizehint!(R[], 128),
+ scales = sizehint!(R[], 16))))
+end
+
+# The Functor of the calculation at one frequency. Its state is the system that every pair
+# shares, as plain values. The per-conductor arrays go to Unified's buffers, and the parts
+# write the source coefficients into Unified's own two matrices.
+function Functor(formula::Union{EarthImpedance.Formula{:unified}, Formula{:unified}},
+ input::NamedTuple; workspace)
+ s=input.jω
+ isfinite(s) && !iszero(s) || throw(DomainError(s, "jω must be finite and nonzero"))
+ prescribed=formula.options.data.Γ
+ longitudinal=prescribed isa Number ? prescribed : prescribed[input.frequency]
+ for column in axes(input.rho, 2)
+ validate(@view(input.rho[:, column]), formula,
+ @view(input.epsilon[:, column]), @view(input.mu[:, column]),
+ input.thickness)
+ end
+ for values in (input.rho, input.epsilon, input.mu)
+ for column in axes(values, 2), row in axes(values, 1)
+
+ same_physical_state(values[row, column], values[row, 1]) ||
+ throw(ArgumentError("the unified earth-return calculation requires the same equivalent media for all conductor pairs"))
+ end
+ end
+ Γ=oftype(s, longitudinal)
+ sh=ntuple(m->conductivity(input.rho[m, 1])+s*input.epsilon[m, 1], 2)
+ mu=ntuple(m->input.mu[m, 1], 2)
+ gamma=ntuple(m->sqrt(s*mu[m]*sh[m]), 2)
+ k2=ntuple(m->gamma[m]^2-Γ^2, 2)
+ k=map(outgoing_root, k2)
+ buffers=workspace.buffers
+ geometry=workspace.plan.geometry
+ for i in eachindex(buffers.radial_argument)
+ medium=input.media[i]
+ buffers.radial_argument[i]=k[medium]*geometry.radius[i]
+ buffers.source_logscale[i]=abs(real(buffers.radial_argument[i]))
+ buffers.circumference_average[i]=special_besselix(0, buffers.radial_argument[i])
+ buffers.radial_current[i]=2 * (one(s)*π) * sh[medium] * geometry.radius[i]^2 *
+ bessel_current_ratio(buffers.radial_argument[i])
+ end
+ state=(jω = s, Γ, sh, mu, k2, k)
+ destinations=(buffers.axial_field, buffers.source_potential)
+ return Functor(formula, merge(input, (; destinations)), state)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Convert the completed source coefficients to physical exterior impedance
+\\[Ω/m\\] and potential coefficients \\[m/F\\] using the complete enclosed-current
+map. With s=jω \\[1/s\\], prescribed Γ \\[1/m\\], and radial-current factors Fᵣ
+\\[m/Ω\\],
+
+```math
+T_I=A_r^{-1}-F_r\\widetilde K,\\qquad
+P_e T_I=\\widetilde P,\\qquad
+Z_e T_I=\\widetilde K+\\Gamma^2\\widetilde P/s.
+```
+
+The work arrays contain a common exponential source-column scaling. Specifically, `current_map` stores TᵢD, not the unscaled Tᵢ. `circumference_average`
+contains scaled I₀ values. The same D multiplies both source-coefficient arrays
+and cancels from these right solves. The factorization is reused without
+conjugating transposes. All conductors participate before selected physical
+entries are copied by Engine.
+
+This complete-field conversion includes both scalar- and vector-potential
+contributions. The package then assembles cable contributions and
+reductions before calculating total Y=jωP⁻¹ \\[S/m\\].
+
+# Returns
+
+- Named tuple of the physical `impedance` and `admittance` matrices: the impedance \\[Ω/m\\]
+ and the potential coefficients \\[m/F\\].
+
+# Reference
+
+User-supplied manuscript, *Unified circumferentially averaged framework for
+overhead, buried, and mixed conductor systems*, current and charge maps.
+"""
+function earth!(::Union{EarthImpedance.Formula{:unified}, Formula{:unified}}, functor::Functor,
+ workspace)
+ buffers=workspace.buffers
+ u=functor.state
+ for column in axes(buffers.axial_field, 2), row in axes(buffers.axial_field, 1)
+
+ buffers.current_map[row, column]=(row==column ? inv(buffers.circumference_average[row]) :
+ zero(u.jω))-
+ buffers.radial_current[row]*buffers.axial_field[row, column]
+ end
+ copyto!(buffers.current_factor, transpose(buffers.current_map))
+ factor=lu!(buffers.current_factor)
+ copyto!(buffers.current_rhs, transpose(buffers.source_potential))
+ ldiv!(factor, buffers.current_rhs)
+ copyto!(buffers.enclosed_potential, transpose(buffers.current_rhs))
+ @. buffers.enclosed_impedance=buffers.axial_field+u.Γ^2/u.jω*buffers.source_potential
+ copyto!(buffers.current_rhs, transpose(buffers.enclosed_impedance))
+ ldiv!(factor, buffers.current_rhs)
+ copyto!(buffers.enclosed_impedance, transpose(buffers.current_rhs))
+ return (impedance = buffers.enclosed_impedance, admittance = buffers.enclosed_potential)
+end
+
+:unified
diff --git a/src/engine/earthadmittance/formulas/wise1948.jl b/src/engine/earthadmittance/formulas/wise1948.jl
new file mode 100644
index 000000000..115798e07
--- /dev/null
+++ b/src/engine/earthadmittance/formulas/wise1948.jl
@@ -0,0 +1,41 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Wideband homogeneous-earth overhead potential
+coefficient.
+
+**Availability.** Registered scientific identity. The coaxial implementation is
+not yet implemented. The method fails without a numerical fallback.
+
+**Expression.**
+
+```math
+P_{e,ij}=\\frac{P_{0,ij}+M_{ij}+jN_{ij}}{2\\pi\\varepsilon_0},
+```
+
+```math
+M_{ij}+jN_{ij}=2\\int_0^\\infty
+\\frac{e^{-H\\lambda}\\cos(y_{ij}\\lambda)}
+{(\\gamma_1^2/\\gamma_0^2)\\lambda+
+\\sqrt{\\lambda^2+\\gamma_1^2-\\gamma_0^2}}d\\lambda,
+\\quad P_{0,ij}=\\ln(D_{ij}/d_{ij}).
+```
+
+**Reference.** W. H. Wise, “Potential Coefficients for Ground Return
+Circuits,” *Bell System Technical Journal*, 27, 365–371, 1948.
+"""
+function description(::Type{<:Formula{:wise1948}}; compact::Bool = false)
+ compact ? "Wise" : "Wise homogeneous-earth overhead potential coefficient (1948) — not yet implemented"
+end
+
+function earth_potential_coefficient(
+ ::Formula{:wise1948}, kind::Union{Val{:self}, Val{:mutual}}, ::Val{1}, ::Val{1},
+ functor, workspace
+)
+ throw(ArgumentError("earth_potential_coefficient :wise1948 ($kind), source layer 1, target layer 1: not yet implemented for the coaxial backend"))
+end
+
+formulation_options(::Expression{<:Formula{:wise1948}, typeof(earth_potential_coefficient)}) =
+ FormulationOptions()
+
+:wise1948
diff --git a/src/engine/earthadmittance/formulas/xue2018.jl b/src/engine/earthadmittance/formulas/xue2018.jl
new file mode 100644
index 000000000..c56c86059
--- /dev/null
+++ b/src/engine/earthadmittance/formulas/xue2018.jl
@@ -0,0 +1,45 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Generalized underground potential coefficient. The
+registered expression is referenced to infinite earth depth.
+
+**Availability.** Registered scientific identity. The coaxial implementation is
+not yet implemented. The method fails without a numerical fallback.
+
+**Expression.**
+
+```math
+P_{e,ij}^{\\infty}=\\frac{j\\omega}{2\\pi(\\sigma_1+j\\omega\\varepsilon_1)}
+\\left[K_0(\\gamma_1d_{ij})-K_0(\\gamma_1D_{ij})+2S_{12}^c+
+2\\gamma_1^2S_{13}^c\\right],
+```
+
+```math
+S_{12}^c=\\int_0^\\infty\\frac{e^{-Hu_1}\\lambda^2\\cos(y\\lambda)}
+{(\\lambda^2+\\gamma_1^2)[u_0+(\\gamma_0^2/\\gamma_1^2)u_1]}d\\lambda,
+\\quad
+S_{13}^c=\\int_0^\\infty\\frac{e^{-Hu_1}\\cos(y\\lambda)}
+{(\\lambda^2+\\gamma_1^2)(u_0+u_1)}d\\lambda.
+```
+
+**Reference.** Haoyan Xue, *General Formulation and Accurate Evaluation of
+Earth-Return Parameters for Overhead / Underground Cables*, doctoral thesis,
+Polytechnique Montréal, 2018.
+[Primary source](https://publications.polymtl.ca/3190/1/2018_HaoyanXue.pdf).
+"""
+function description(::Type{<:Formula{:xue2018}}; compact::Bool = false)
+ compact ? "Xue" : "Xue homogeneous-earth underground potential coefficient (2018) — not yet implemented"
+end
+
+function earth_potential_coefficient(
+ ::Formula{:xue2018}, kind::Union{Val{:self}, Val{:mutual}}, ::Val{2}, ::Val{2},
+ functor, workspace
+)
+ throw(ArgumentError("earth_potential_coefficient :xue2018 ($kind), source layer 2, target layer 2: not yet implemented for the coaxial backend"))
+end
+
+formulation_options(::Expression{<:Formula{:xue2018}, typeof(earth_potential_coefficient)}) =
+ FormulationOptions()
+
+:xue2018
diff --git a/src/engine/earthadmittance/homogeneous.jl b/src/engine/earthadmittance/homogeneous.jl
deleted file mode 100644
index 2fbd7b08a..000000000
--- a/src/engine/earthadmittance/homogeneous.jl
+++ /dev/null
@@ -1,228 +0,0 @@
-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/earthadmittance/interface.jl b/src/engine/earthadmittance/interface.jl
new file mode 100644
index 000000000..a38fe71c5
--- /dev/null
+++ b/src/engine/earthadmittance/interface.jl
@@ -0,0 +1,103 @@
+"""
+$(TYPEDEF)
+
+Own one earth potential coefficient formulation, its indexed equation declarations and explicit
+controls. Each interaction is selected by its explicit kind and source-layer and
+target-layer equation signature. The layers in the signatures of its
+`earth_potential_coefficient` methods declare the media it handles: methods up to layer 2 treat air
+and one homogeneous earth. `parameters` stores model data. `options` stores formulation
+choices and numerical controls.
+
+$(TYPEDFIELDS)
+"""
+struct Formula{ID, P <: NamedTuple, O <: FormulationOptions, E} <:
+ EarthAdmittanceFormulation
+ "Explicit physical model parameters."
+ parameters::P
+ "Formulation options. Projected onto required indexed equations during initialization."
+ options::O
+ "Independent equivalent homogeneous-earth reduction, or nothing."
+ equivalent_earth::E
+end
+
+"""
+Evaluate one source-owned earth potential coefficient equation.
+"""
+function earth_potential_coefficient end
+
+"""
+$(TYPEDSIGNATURES)
+
+Resolve a formulation's model parameters and numerical controls. Its concrete
+selection type defines the indexed equation. Material properties are evaluated
+before equation execution.
+Formula-specific arguments are normalized by their selected owner.
+A missing physical case is unsupported.
+"""
+function Formula{ID}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions(), equivalent_earth = nothing) where {ID}
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ options = formulation_options(Formula{ID}, options)
+ isempty(parameters) ||
+ throw(ArgumentError("earth formula :$ID has no configurable physical parameters"))
+ ID in formulas(Formula) || throw(ArgumentError("unknown earthadmittance formula :$ID"))
+ reduction = if equivalent_earth === nothing
+ nothing
+ elseif equivalent_earth isa EquivalentHomogeneous.AbstractSequence
+ equivalent_earth
+ elseif equivalent_earth isa FormulaDefinition
+ EquivalentHomogeneous.AbstractSequence(equivalent_earth)
+ else
+ throw(ArgumentError("equivalent_earth must be a formula definition or explicit reduction sequence"))
+ end
+ return Formula{ID, typeof(parameters), typeof(options), typeof(reduction)}(
+ parameters, options, reduction)
+end
+
+Formula(identifier::Symbol; kwargs...) = Formula(Val(identifier); kwargs...)
+Formula(::Val{ID}; kwargs...) where {ID} = Formula{ID}(; kwargs...)
+Formula(selected::EarthAdmittanceFormulation) = selected
+Formula(::Nothing) = Formula(:default)
+
+# A recipe selects one formula per earth route. Within it, a `nothing` route supplies no
+# expression. Only omitting the whole slot selects the default.
+function Formula(recipe::NamedTuple)
+ routes = (; pairs(Formula)...)
+ all(in(keys(routes)), keys(recipe)) || throw(ArgumentError(
+ "$Formula selections admit only $(join(keys(routes), ", "))"))
+ names = filter(in(keys(recipe)), keys(routes))
+ return NamedTuple{names}(map(name -> recipe[name] === nothing ? nothing :
+ Formula(recipe[name]), names))
+end
+
+function Formula(selection::FormulaDefinition{ID, Order}) where {ID, Order}
+ Order === :default || throw(ArgumentError("order applies only to equivalent_earth"))
+ return Formula{ID}(; parameters = selection.parameters,
+ options = selection.options, equivalent_earth = selection.equivalent_earth)
+end
+
+formula_id(::Formula{ID}) where {ID} = ID
+formula_id(::Type{<:Formula{ID}}) where {ID} = ID
+# Identity-only dispatch also describes retained selections without constructors.
+description(value::Formula; compact::Bool = false) = description(typeof(value); compact)
+formulation_options(value::Formula) = value.options
+# Indexed numerical controls remain deferred to their consuming equations.
+formulation_options(::Type{<:Formula}, options::FormulationOptions) = options
+
+"""
+$(TYPEDSIGNATURES)
+
+Expose the selected identity, model and numerical controls, and explicit
+equivalent-earth reduction as a native record.
+"""
+function Base.NamedTuple(value::Formula)
+ return (identifier = formula_id(value),
+ parameters = value.parameters, options = value.options.data,
+ equivalent_earth = value.equivalent_earth === nothing ? nothing :
+ NamedTuple(value.equivalent_earth))
+end
+
+"""Iterate the independently selectable child slots admitted by this formula family."""
+function Base.pairs(::Type{<:Formula}; quantity = nothing)
+ pairs((air = Formula, earth = Formula, mixed = Formula))
+end
diff --git a/src/engine/earthimpedance/EarthImpedance.jl b/src/engine/earthimpedance/EarthImpedance.jl
index 098ca9e8d..7a8b6d5d1 100644
--- a/src/engine/earthimpedance/EarthImpedance.jl
+++ b/src/engine/earthimpedance/EarthImpedance.jl
@@ -1,28 +1,56 @@
"""
- LineCableModels.Engine.EarthImpedance
+ LineCableModels.Engine.EarthImpedance
+
+Define earth-return impedance recipes, numerical primitives, and formula-owned
+frequency functors.
# Dependencies
$(IMPORTS)
-# Exports
-
-$(EXPORTS)
"""
module EarthImpedance
+import ...Commons: FormulationOptions, formulas
# Export public API
-export Papadopoulos
+export Formula, formula_id, earth_impedance, formulas
# 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")
+#! explicit-imports: off
+# These abbreviations are expanded in this module docstring and included files.
+using DocStringExtensions: IMPORTS, TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+#! explicit-imports: on
+import ...Earth: EquivalentHomogeneous
+import ..Engine: EarthImpedanceFormulation, formula_id
+#! explicit-imports: off
+# Explicitly included equations share these physical and numerical operations.
+import ...LineCableModels: FormulaDefinition, Expression
+import ..Engine: description, conductivity, special_besselk
+import ..Engine: formulation_options
+using ...Commons: vacuum_permeability
+#! explicit-imports: on
+
+include("interface.jl")
+
+#! explicit-imports: off
+const FORMULAS = (
+ include("formulas/unified.jl"),
+ include("formulas/ametani2009.jl"),
+ include("formulas/carson1926.jl"),
+ include("formulas/default.jl"),
+ include("formulas/gary1976.jl"),
+ include("formulas/lucca1994.jl"),
+ include("formulas/pollaczek1926.jl"),
+ include("formulas/saad1996.jl"),
+ include("formulas/wedepohl1973.jl"),
+ include("formulas/wise1934.jl"),
+ include("formulas/xue2018.jl")
+)
+#! explicit-imports: on
+
+"""
+Return registered earth-impedance identities, including unimplemented equations.
+"""
+formulas(::Type{<:Formula}) = FORMULAS
end # module EarthImpedance
diff --git a/src/engine/earthimpedance/base.jl b/src/engine/earthimpedance/base.jl
deleted file mode 100644
index 3df1a4b43..000000000
--- a/src/engine/earthimpedance/base.jl
+++ /dev/null
@@ -1,8 +0,0 @@
-
-@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/formulas/ametani2009.jl b/src/engine/earthimpedance/formulas/ametani2009.jl
new file mode 100644
index 000000000..32f816648
--- /dev/null
+++ b/src/engine/earthimpedance/formulas/ametani2009.jl
@@ -0,0 +1,50 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Homogeneous-earth approximation for mixed overhead-underground pairs.
+
+**Availability.** Registered scientific identity. The coaxial implementation is
+not yet implemented. The method fails without a numerical fallback.
+
+**Expression.** Its distinctive mixed term is
+
+```math
+Z_{e,ij}^{01}=\\frac{j\\omega\\mu_0}{2\\pi}e^{-h_g/h_e}\\ln\\frac{S}{D},
+\\quad h_e=(j\\omega\\mu_0\\sigma_g)^{-1/2},
+```
+
+```math
+S=\\sqrt{(h_a+h_g+2h_e)^2+y_{ij}^2},\\qquad
+D=\\sqrt{(h_a+h_g)^2+y_{ij}^2}.
+```
+
+**Reference.** A. Ametani, T. Yoneda, Y. Baba, and N. Nagaoka, “An
+Investigation of Earth-Return Impedance Between Overhead and Underground
+Conductors and Its Approximation,” *IEEE Transactions on Electromagnetic
+Compatibility*, 51, 860–867, 2009.
+DOI: 10.1109/TEMC.2009.2019953.
+PSCAD's help lists this journal article with a 2005 date. Its journal volume
+and DOI identify the 2009 publication used by this registration.
+"""
+function description(::Type{<:Formula{:ametani2009}}; compact::Bool = false)
+ compact ? "Ametani" : "Ametani mixed-pair homogeneous-earth impedance (2009)"
+end
+
+function earth_impedance(
+ ::Formula{:ametani2009}, kind::Val{:mutual}, ::Val{1}, ::Val{2},
+ functor, workspace
+)
+ throw(ArgumentError("earth_impedance :ametani2009 ($kind), source layer 1, target layer 2: not yet implemented for the coaxial backend"))
+end
+
+function earth_impedance(
+ ::Formula{:ametani2009}, kind::Val{:mutual}, ::Val{2}, ::Val{1},
+ functor, workspace
+)
+ throw(ArgumentError("earth_impedance :ametani2009 ($kind), source layer 2, target layer 1: not yet implemented for the coaxial backend"))
+end
+
+formulation_options(::Expression{<:Formula{:ametani2009}, typeof(earth_impedance)}) =
+ FormulationOptions()
+
+:ametani2009
diff --git a/src/engine/earthimpedance/formulas/carson1926.jl b/src/engine/earthimpedance/formulas/carson1926.jl
new file mode 100644
index 000000000..8de140da7
--- /dev/null
+++ b/src/engine/earthimpedance/formulas/carson1926.jl
@@ -0,0 +1,37 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Classical homogeneous, conductive-earth overhead
+impedance. Displacement currents and longitudinal propagation are neglected.
+
+**Availability.** Registered scientific identity. The coaxial implementation is
+not yet implemented. The method fails without a numerical fallback.
+
+**Expression.**
+
+```math
+Z_{e,ij}=\\frac{j\\omega\\mu_0}{2\\pi}\\left[
+\\ln\\frac{D_{ij}}{d_{ij}}+2\\int_0^\\infty
+\\frac{e^{-H\\lambda}\\cos(y_{ij}\\lambda)}
+{\\lambda+\\sqrt{\\lambda^2+\\gamma_g^2}}d\\lambda\\right],
+\\qquad \\gamma_g^2=j\\omega\\mu_0\\sigma_g.
+```
+
+**Reference.** J. R. Carson, “Wave Propagation in Overhead Wires with Ground
+Return,” *Bell System Technical Journal*, 5, 539–554, 1926.
+"""
+function description(::Type{<:Formula{:carson1926}}; compact::Bool = false)
+ compact ? "Carson" : "Carson homogeneous-earth overhead impedance (1926)"
+end
+
+function earth_impedance(
+ ::Formula{:carson1926}, kind::Union{Val{:self}, Val{:mutual}}, ::Val{1}, ::Val{1},
+ functor, workspace
+)
+ throw(ArgumentError("earth_impedance :carson1926 ($kind), source layer 1, target layer 1: not yet implemented for the coaxial backend"))
+end
+
+formulation_options(::Expression{<:Formula{:carson1926}, typeof(earth_impedance)}) =
+ FormulationOptions()
+
+:carson1926
diff --git a/src/engine/earthimpedance/formulas/default.jl b/src/engine/earthimpedance/formulas/default.jl
new file mode 100644
index 000000000..dea72e577
--- /dev/null
+++ b/src/engine/earthimpedance/formulas/default.jl
@@ -0,0 +1,12 @@
+"""
+$(TYPEDSIGNATURES)
+
+Route the default selection to the explicit `:unified` implementation.
+No numerical equation is owned by `:default`.
+"""
+description(::Type{<:Formula{:default}}; compact::Bool=false) =
+ compact ? description(Formula{:unified};compact=true) : "Default routing to :unified"
+
+Formula{:default}(; kwargs...) = Formula{:unified}(; kwargs...)
+
+:default
diff --git a/src/engine/earthimpedance/formulas/gary1976.jl b/src/engine/earthimpedance/formulas/gary1976.jl
new file mode 100644
index 000000000..ef84ba016
--- /dev/null
+++ b/src/engine/earthimpedance/formulas/gary1976.jl
@@ -0,0 +1,91 @@
+function description(::Type{<:Formula{:gary1976}}; compact::Bool = false)
+ compact ? "Gary" : "Gary complex-depth approximation (1976)"
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate Gary's complex-depth approximation for overhead self and mutual
+exterior impedance, in Ω/m.
+
+# Assumptions
+
+Aerial conductors above homogeneous, nonmagnetic conductive earth, with an
+image plane at a complex depth. Displacement current is neglected.
+
+# Expression
+
+For the positive-time convention ``e^{j\\omega t}``, define
+
+```math
+h_e=(j\\omega\\mu_0\\sigma_g)^{-1/2},\\qquad
+\\mu_0=4\\pi\\,10^{-7}\\ \\mathrm{H/m}.
+```
+
+Soil conductivity ``\\sigma_g`` is in S/m. The principal square root gives
+complex depth ``h_e`` in m. With aerial heights ``h_i,h_j`` and horizontal
+separation ``x``, the direct and complex-image distances are
+
+```math
+d=\\sqrt{x^2+(h_i-h_j)^2},\\qquad
+S=\\sqrt{x^2+(h_i+h_j+2h_e)^2}.
+```
+
+The complete exterior mutual impedance is
+
+```math
+Z_{e,ij}=\\frac{j\\omega\\mu_0}{2\\pi}\\ln\\frac{S}{d}.
+```
+
+For self impedance, the image distance is ``2(h_i+h_e)`` and the direct distance
+is the exterior radius ``r_i``:
+
+```math
+Z_{e,ii}=\\frac{j\\omega\\mu_0}{2\\pi}\\ln\\frac{2(h_i+h_e)}{r_i}.
+```
+
+All geometric lengths are in m. The expression includes the ideal-earth exterior
+term. Returning only the lossy-ground correction ``\\ln(S/D)`` with real-image
+distance ``D`` would omit that term from the package's assembly.
+
+# Reference
+
+C. Gary, “Approche complète de la propagation multifilaire en haute fréquence
+par utilisation des matrices complexes,” *EDF Bulletin de la Direction des
+Études et Recherches*, série B (1976). The implementation follows the
+complex-image expression reproduced in
+[PSCAD's earth-return impedance documentation](https://www.pscad.com/webhelp-v5-ol/EMTDC/Transmission_Lines/Mutual_Impedance_with_Earth_Return.htm),
+Eq. (8-28) and its complex-depth diagram. PSCAD calls its native implementation
+Deri-Semlyen.
+"""
+function earth_impedance(
+ ::Formula{:gary1976}, ::Val{:self}, ::Val{1}, ::Val{1},
+ functor, workspace
+)
+ pair = functor.input.pair
+ s = functor.input.jω
+ μ0 = vacuum_permeability(typeof(real(s)))
+ he = inv(sqrt(s * μ0 * conductivity(functor.input.rho[2])))
+ return s * μ0 / (2 * (one(real(s)) * π)) * log(2 * (pair.heights[1] + he) / pair.radius)
+end
+
+function earth_impedance(
+ ::Formula{:gary1976}, ::Val{:mutual}, ::Val{1}, ::Val{1},
+ functor, workspace
+)
+ pair = functor.input.pair
+ s = functor.input.jω
+ μ0 = vacuum_permeability(typeof(real(s)))
+ he = inv(sqrt(s * μ0 * conductivity(functor.input.rho[2])))
+ hi, hj = pair.heights
+ x = pair.separation
+ S = sqrt((hi + hj + 2he)^2 + x^2)
+ d = hypot(x, hi - hj)
+ return s * μ0 / (2 * (one(real(s)) * π)) * log(S / d)
+end
+
+function formulation_options(::Expression{<:Formula{:gary1976}, typeof(earth_impedance)})
+ FormulationOptions()
+end
+
+:gary1976
diff --git a/src/engine/earthimpedance/formulas/lucca1994.jl b/src/engine/earthimpedance/formulas/lucca1994.jl
new file mode 100644
index 000000000..4bcc0c868
--- /dev/null
+++ b/src/engine/earthimpedance/formulas/lucca1994.jl
@@ -0,0 +1,83 @@
+function description(::Type{<:Formula{:lucca1994}}; compact::Bool = false)
+ compact ? "Lucca" : "Lucca mixed-pair homogeneous-earth impedance (1994)"
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate Lucca's mutual exterior impedance between an aerial and a buried
+conductor, in Ω/m.
+
+# Assumptions
+
+Homogeneous, nonmagnetic conductive earth, neglecting displacement current.
+Both ordered interactions between an aerial conductor and a buried conductor
+use the same reciprocal expression.
+The formula supplies no self or same-half-space interactions. Those remain
+separate selections.
+
+# Expression
+
+With the positive-time convention ``e^{j\\omega t}``, let ``h_a>0`` be aerial
+height, ``h_g>0`` burial depth, and ``x`` horizontal separation, all in m.
+Signed package heights are converted to these positive physical distances.
+With soil conductivity ``\\sigma_g`` [S/m], define
+
+```math
+h_e=(j\\omega\\mu_0\\sigma_g)^{-1/2},\\qquad
+H=h_a+h_g+2h_e,\\qquad
+S=\\sqrt{H^2+x^2},\\qquad
+D=\\sqrt{(h_a+h_g)^2+x^2},
+```
+
+where ``\\mu_0=4\\pi\\,10^{-7}`` H/m and square roots use their principal value.
+The complete mixed coefficient is
+
+```math
+Z_{e,ag}=\\frac{j\\omega\\mu_0}{2\\pi}
+\\left[\\ln\\frac{S}{D}
+-\\frac23\\left(\\frac{h_e}{S^2}\\right)^3 H(H^2-3x^2)\\right].
+```
+
+The correction uses ``(h_e/S^2)^3``, not ``h_e^3/S^3``.
+
+# Reference
+
+G. Lucca, “Mutual Impedance Between an Overhead and a Buried Line with Earth
+Return,” *9th International Conference on Electromagnetic Compatibility* (1994),
+[doi:10.1049/cp:19940679](https://doi.org/10.1049/cp:19940679).
+The implemented expression is reproduced in
+[PSCAD's earth-return impedance documentation](https://www.pscad.com/webhelp-v5-ol/EMTDC/Transmission_Lines/Mutual_Impedance_with_Earth_Return.htm),
+Eq. (8-34), with the geometric definitions immediately below it.
+"""
+function earth_impedance(
+ ::Formula{:lucca1994}, ::Val{:mutual}, ::Val{1}, ::Val{2},
+ functor, workspace
+)
+ pair = functor.input.pair
+ s = functor.input.jω
+ μ0 = vacuum_permeability(typeof(real(s)))
+ he = inv(sqrt(s * μ0 * conductivity(functor.input.rho[2])))
+ vertical = abs(pair.heights[1]) + abs(pair.heights[2])
+ x = pair.separation
+ H = vertical + 2he
+ S2 = H^2 + x^2
+ D = hypot(vertical, x)
+ return s * μ0 / (2 * (one(real(s)) * π)) *
+ (log(sqrt(S2) / D) - 2 * (he / S2)^3 * H * (H^2 - 3x^2) / 3)
+end
+
+# The reciprocal mixed coefficient has the same physical distances in either direction.
+function earth_impedance(
+ selected::Formula{:lucca1994}, kind::Val{:mutual}, ::Val{2}, ::Val{1},
+ functor, workspace
+)
+ return earth_impedance(selected, kind, Val(1), Val(2), functor, workspace)
+end
+
+function formulation_options(::Expression{
+ <:Formula{:lucca1994}, typeof(earth_impedance)})
+ FormulationOptions()
+end
+
+:lucca1994
diff --git a/src/engine/earthimpedance/formulas/pollaczek1926.jl b/src/engine/earthimpedance/formulas/pollaczek1926.jl
new file mode 100644
index 000000000..c7a376066
--- /dev/null
+++ b/src/engine/earthimpedance/formulas/pollaczek1926.jl
@@ -0,0 +1,37 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Classical homogeneous-earth underground integral.
+
+**Availability.** Registered scientific identity. The coaxial implementation is
+not yet implemented. The method fails without a numerical fallback.
+
+**Expression.** The underground term is
+
+```math
+Z_{e,ij}^{11}=\\frac{j\\omega\\mu_0}{2\\pi}\\left[
+K_0(\\gamma_1d_{ij})-K_0(\\gamma_1D_{ij})+2\\int_0^\\infty
+\\frac{e^{-H\\sqrt{\\lambda^2+\\gamma_1^2}}}
+{\\lambda+\\sqrt{\\lambda^2+\\gamma_1^2}}
+\\cos(y_{ij}\\lambda)d\\lambda\\right],
+```
+
+**Reference.** F. Pollaczek, “Über das Feld einer unendlich langen
+wechselstromdurchflossenen Einfachleitung,” *Elektrische Nachrichtentechnik*,
+3, 339–360, 1926.
+"""
+function description(::Type{<:Formula{:pollaczek1926}}; compact::Bool = false)
+ compact ? "Pollaczek" : "Pollaczek homogeneous-earth underground impedance (1926)"
+end
+
+function earth_impedance(
+ ::Formula{:pollaczek1926}, kind::Union{Val{:self}, Val{:mutual}}, ::Val{2}, ::Val{2},
+ functor, workspace
+)
+ throw(ArgumentError("earth_impedance :pollaczek1926 ($kind), source layer 2, target layer 2: not yet implemented for the coaxial backend"))
+end
+
+formulation_options(::Expression{<:Formula{:pollaczek1926}, typeof(earth_impedance)}) =
+ FormulationOptions()
+
+:pollaczek1926
diff --git a/src/engine/earthimpedance/formulas/saad1996.jl b/src/engine/earthimpedance/formulas/saad1996.jl
new file mode 100644
index 000000000..a7a6660bf
--- /dev/null
+++ b/src/engine/earthimpedance/formulas/saad1996.jl
@@ -0,0 +1,136 @@
+function description(::Type{<:Formula{:saad1996}}; compact::Bool = false)
+ compact ? "Saad" : "Saad underground closed form (1996)"
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate Saad, Gaba, and Giroux's closed-form approximation for underground
+self and mutual earth-return impedance, in Ω/m.
+
+# Assumptions
+
+Parallel horizontal cables in homogeneous, linear, isotropic, nonmagnetic
+conductive earth below air. Displacement current is neglected, and the
+wavelength is long compared with the transverse dimensions. Neither air
+propagation nor a nonzero imposed longitudinal propagation constant is retained.
+The earth-return calculation treats cables as filaments, substituting the
+exterior cable radius in the self term. Internal conductor and insulation
+contributions are separate.
+
+# Expression
+
+With the package's positive-time convention ``e^{j\\omega t}``, define
+
+```math
+m=\\sqrt{\\frac{j\\omega\\mu_0}{\\rho_g}},\\qquad
+\\ell=h_i+h_j,\\qquad
+d=\\sqrt{x^2+(h_i-h_j)^2},\\qquad
+D=\\sqrt{x^2+(h_i+h_j)^2}.
+```
+
+Here ``\\rho_g`` is earth resistivity [Ω·m], ``\\mu_0=4\\pi\\,10^{-7}`` H/m,
+and ``m`` has units of inverse meters. Depths ``h_i,h_j`` are positive downward.
+``x`` is horizontal separation, ``d`` is axis distance, and ``D`` is image
+distance, all in meters. The implementation uses the principal square root.
+Its branch and a separate time factor remain unspecified in the source.
+
+The proposed mutual and self expressions, Eqs. (5)-(6), repeated as (26)-(27), are
+
+```math
+Z_{e,ij}=\\frac{j\\omega\\mu_0}{2\\pi}
+\\left[K_0(md)+\\frac{2e^{-m\\ell}}{4+m^2x^2}\\right],
+```
+
+```math
+Z_{e,ii}=\\frac{j\\omega\\mu_0}{2\\pi}
+\\left[K_0(mr_i)+\\frac{2e^{-2mh_i}}{4+m^2r_i^2}\\right].
+```
+
+``K_0`` is the modified Bessel function of the second kind, order zero.
+``r_i`` is the exterior cable radius [m], written ``R`` in the paper.
+The source prefactor ``\\rho_g m^2/(2\\pi)`` equals ``j\\omega\\mu_0/(2\\pi)``.
+The self expression follows the mutual geometry with ``x=r_i`` and
+``h_i=h_j``. The mutual correction denominator uses horizontal separation,
+not axis distance.
+
+# Approximation and limitations
+
+The source starts from the Pollaczek/Wedepohl representations, Eqs. (1)-(4):
+
+```math
+Z_m=\\frac{\\rho_gm^2}{2\\pi}
+\\left[K_0(md)-K_0(mD)+J_m\\right],\\qquad
+J_m=\\int_{-\\infty}^{\\infty}
+\\frac{e^{-\\ell\\sqrt{\\gamma^2+m^2}}}
+{|\\gamma|+\\sqrt{\\gamma^2+m^2}}e^{j\\gamma x}\\,d\\gamma,
+```
+
+```math
+Z_s=\\frac{\\rho_gm^2}{2\\pi}
+\\left[K_0(mR)-K_0\\!\\left(m\\sqrt{R^2+4h^2}\\right)+J_s\\right],\\qquad
+J_s=\\int_{-\\infty}^{\\infty}
+\\frac{e^{-2h\\sqrt{\\gamma^2+m^2}}}
+{|\\gamma|+\\sqrt{\\gamma^2+m^2}}e^{j\\gamma R}\\,d\\gamma.
+```
+
+Here ``\\gamma`` is the Fourier integration variable [1/m], not ``m`` or a
+longitudinal line propagation constant. The source declares these parent
+expressions without attributing new kernels to Saad et al. The paper cites
+Pollaczek's 1931 French publication separately from the 1926 overhead result.
+
+After contour deformation, Eq. (15) approximates
+``\\sqrt{\\delta^2+1}/(\\delta+\\sqrt{\\delta^2+1})`` by
+``(1+e^{-2\\delta})/2``. Equations (20)-(21) then use
+``\\sqrt{\\delta^2+1}\\simeq1`` in the rapidly decaying part. The approximated
+interface integral cancels the separate image Bessel term. The further
+small-argument reduction discussed in the paper is not this implementation.
+
+The contour proof is restricted to ``x/\\ell<1``. The paper reports about 3%
+maximum relative error for the first kernel approximation, errors below about
+1.5% for typical ``x/\\ell<1`` geometries, and negligible error through 10 kHz
+for its ``x/\\ell=5`` examples. These reported test cases do not establish universal
+bounds or runtime acceptance conditions.
+
+# Reference
+
+O. Saad, G. Gaba, and M. Giroux, “A Closed-Form Approximation for Ground Return
+Impedance of Underground Cables,” *IEEE Transactions on Power Delivery*,
+11(3), 1536–1545, 1996. Model and parent expressions: pp. 1536–1537,
+Eqs. (1)-(4). Proposed equations: p. 1537, Eqs. (5)-(6). Derivation:
+pp. 1537–1539, Eqs. (7)-(27). Error discussion: p. 1540 and Figs. 5–6.
+The transcription was checked against the original publication's page images.
+"""
+function earth_impedance(
+ ::Formula{:saad1996}, ::Val{:self}, ::Val{2}, ::Val{2},
+ functor, workspace
+)
+ pair = functor.input.pair
+ s = functor.input.jω
+ μ0 = vacuum_permeability(typeof(real(s)))
+ m = sqrt(s * μ0 / functor.input.rho[2])
+ h, r = abs(pair.heights[1]), pair.radius
+ return s * μ0 / (2 * (one(real(s)) * π)) *
+ (special_besselk(0, m * r) + 2exp(-2m * h) / (4 + (m * r)^2))
+end
+
+function earth_impedance(
+ ::Formula{:saad1996}, ::Val{:mutual}, ::Val{2}, ::Val{2},
+ functor, workspace
+)
+ pair = functor.input.pair
+ s = functor.input.jω
+ μ0 = vacuum_permeability(typeof(real(s)))
+ m = sqrt(s * μ0 / functor.input.rho[2])
+ hi, hj = abs.(pair.heights)
+ x = pair.separation
+ d = hypot(x, hi - hj)
+ return s * μ0 / (2 * (one(real(s)) * π)) *
+ (special_besselk(0, m * d) + 2exp(-m * (hi + hj)) / (4 + (m * x)^2))
+end
+
+function formulation_options(::Expression{<:Formula{:saad1996}, typeof(earth_impedance)})
+ FormulationOptions()
+end
+
+:saad1996
diff --git a/src/engine/earthimpedance/formulas/unified.jl b/src/engine/earthimpedance/formulas/unified.jl
new file mode 100644
index 000000000..fc7c7e28a
--- /dev/null
+++ b/src/engine/earthimpedance/formulas/unified.jl
@@ -0,0 +1,35 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Unified circumferentially averaged earth impedance for overhead, buried
+and mixed conductor systems in two homogeneous half-spaces, with complete enclosed-current
+normalization.
+
+**Expression.** `EarthAdmittance.source_coefficients` computes the axial-field coefficient of
+each conductor pair, together with its source-potential coefficient, for the Unified formula
+of either earth family.
+
+**Validity.** The formula holds within two ranges.
+- A nonzero prescribed Γ lies between the propagation constants of air and earth. It is in
+ range at a frequency where Im γ_air ≤ Im Γ ≤ Im γ_earth and Re Γ ≤ Re γ_earth. Γ = 0, the
+ default, always lies in range.
+- Each receiving conductor is represented by the mean field on its exterior circumference,
+ which holds while |κ_m r_p| ≤ 0.1. Here κ_m is the outgoing root of γ_m² − Γ² in the
+ medium of conductor p, and r_p is its exterior radius, the jacket for an insulated cable.
+ Above the range, the voltage that a thick conductor receives departs from the full-field
+ value, with an error that grows as (κ_m r_p)². Emission from a thick conductor is
+ accurate.
+"""
+function description(::Type{<:Formula{:unified}}; compact::Bool = false)
+ compact ? "Unified" :
+ "Unified circumferential earth impedance with complete enclosed-current normalization"
+end
+
+function description(::Type{<:Formula{:unified}}, ::Val{:Γ}, value::Number; compact::Bool = false)
+ "Γ="*string(value)*" m⁻¹"
+end
+function description(::Type{<:Formula{:unified}}, ::Val{:Γ}, value::AbstractVector; compact::Bool = false)
+ "Γ=["*join(value, ", ")*"] m⁻¹ (frequency order)"
+end
+
+:unified
diff --git a/src/engine/earthimpedance/formulas/wedepohl1973.jl b/src/engine/earthimpedance/formulas/wedepohl1973.jl
new file mode 100644
index 000000000..e89cffe51
--- /dev/null
+++ b/src/engine/earthimpedance/formulas/wedepohl1973.jl
@@ -0,0 +1,88 @@
+function description(::Type{<:Formula{:wedepohl1973}}; compact::Bool = false)
+ compact ? "Wedepohl" : "Wedepohl-Wilcox low-frequency underground approximation (1973)"
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate the Wedepohl-Wilcox low-order approximation for underground
+self and mutual exterior impedance, in Ω/m.
+
+# Assumptions
+
+Buried conductors in homogeneous conductive earth, neglecting displacement
+current. The expansion assumes small penetration-depth products and uses the
+supplied absolute soil permeability. It is distinct from the internal-impedance
+formulation with the same `:wedepohl1973` identifier.
+
+# Expression
+
+For the positive-time convention ``e^{j\\omega t}``, define
+
+```math
+m=\\sqrt{j\\omega\\mu_g/\\rho_g},\\qquad e_c=1.7811.
+```
+
+Here ``\\rho_g`` is resistivity [Ω·m], ``\\mu_g`` is permeability [H/m], and
+``m`` is inverse penetration depth [1/m], using the principal square root.
+The published rounded logarithmic constant ``e_c`` approximates the exponential
+of Euler's constant.
+
+For positive burial depths ``h_i,h_j``, horizontal separation ``x``, exterior
+radius ``r_i``, and axis distance ``d=\\sqrt{x^2+(h_i-h_j)^2}`` (all lengths in m),
+
+```math
+Z_{e,ii}=\\frac{j\\omega\\mu_g}{2\\pi}
+\\left[-\\ln\\left(\\frac{e_cmr_i}{2}\\right)+\\frac12-\\frac43mh_i\\right],
+```
+
+```math
+Z_{e,ij}=\\frac{j\\omega\\mu_g}{2\\pi}
+\\left[-\\ln\\left(\\frac{e_cmd}{2}\\right)+\\frac12-\\frac23m(h_i+h_j)\\right].
+```
+
+The implementation evaluates this approximation at every requested frequency. Coincident mutual axes are invalid
+physical geometry. A vertical pair at distinct depths has ``d>0`` and uses the same equation.
+
+# Reference
+
+L. M. Wedepohl and D. J. Wilcox, “Transient Analysis of Underground
+Power-Transmission Systems: System-Model and Wave-Propagation Characteristics,”
+*Proceedings of the IEE* 120, 253–260 (1973), p. 255, Eqs. (7)-(8),
+[doi:10.1049/piee.1973.0056](https://doi.org/10.1049/piee.1973.0056).
+The equations and rounded constant are also reproduced in
+[PSCAD's earth-return impedance documentation](https://www.pscad.com/webhelp-v5-ol/EMTDC/Transmission_Lines/Mutual_Impedance_with_Earth_Return.htm),
+Eqs. (8-31)-(8-32).
+"""
+function earth_impedance(
+ ::Formula{:wedepohl1973}, ::Val{:self}, ::Val{2}, ::Val{2},
+ functor, workspace
+)
+ pair = functor.input.pair
+ s, μ = functor.input.jω, functor.input.mu[2]
+ m = sqrt(s * μ / functor.input.rho[2])
+ ec = one(real(m)) * 17811 / 10000
+ return s * μ / (2 * (one(real(s)) * π)) *
+ (-log(ec * m * pair.radius / 2) + one(m) / 2 - 4m * abs(pair.heights[1]) / 3)
+end
+
+function earth_impedance(
+ ::Formula{:wedepohl1973}, ::Val{:mutual}, ::Val{2}, ::Val{2},
+ functor, workspace
+)
+ pair = functor.input.pair
+ s, μ = functor.input.jω, functor.input.mu[2]
+ m = sqrt(s * μ / functor.input.rho[2])
+ ec = one(real(m)) * 17811 / 10000
+ hi, hj = abs.(pair.heights)
+ d = hypot(pair.separation, hi - hj)
+ return s * μ / (2 * (one(real(s)) * π)) *
+ (-log(ec * m * d / 2) + one(m) / 2 - 2m * (hi + hj) / 3)
+end
+
+function formulation_options(::Expression{
+ <:Formula{:wedepohl1973}, typeof(earth_impedance)})
+ FormulationOptions()
+end
+
+:wedepohl1973
diff --git a/src/engine/earthimpedance/formulas/wise1934.jl b/src/engine/earthimpedance/formulas/wise1934.jl
new file mode 100644
index 000000000..a6e731eba
--- /dev/null
+++ b/src/engine/earthimpedance/formulas/wise1934.jl
@@ -0,0 +1,38 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Homogeneous-earth wideband overhead integral retaining
+earth displacement current and magnetic permeability.
+
+**Availability.** Registered scientific identity. The coaxial implementation is
+not yet implemented. The method fails without a numerical fallback.
+
+**Expression.**
+
+```math
+Z_{e,ij}=\\frac{j\\omega\\mu_0}{2\\pi}\\left[
+\\ln\\frac{D_{ij}}{d_{ij}}+2\\int_0^\\infty
+\\frac{\\mu_1e^{-\\lambda H}}
+{\\lambda\\mu_1+a_1\\mu_0}\\cos(y_{ij}\\lambda)d\\lambda\\right],
+\\quad a_1=\\sqrt{\\lambda^2+\\gamma_1^2-\\gamma_0^2}.
+```
+
+**Reference.** W. H. Wise, “Propagation of High-Frequency Currents in Ground
+Return Circuits,” *Proceedings of the Institute of Radio Engineers*, 22,
+522–527, 1934.
+"""
+function description(::Type{<:Formula{:wise1934}}; compact::Bool = false)
+ compact ? "Wise" : "Wise homogeneous-earth overhead impedance (1934) — not yet implemented"
+end
+
+function earth_impedance(
+ ::Formula{:wise1934}, kind::Union{Val{:self}, Val{:mutual}}, ::Val{1}, ::Val{1},
+ functor, workspace
+)
+ throw(ArgumentError("earth_impedance :wise1934 ($kind), source layer 1, target layer 1: not yet implemented for the coaxial backend"))
+end
+
+formulation_options(::Expression{<:Formula{:wise1934}, typeof(earth_impedance)}) =
+ FormulationOptions()
+
+:wise1934
diff --git a/src/engine/earthimpedance/formulas/xue2018.jl b/src/engine/earthimpedance/formulas/xue2018.jl
new file mode 100644
index 000000000..fda967fb3
--- /dev/null
+++ b/src/engine/earthimpedance/formulas/xue2018.jl
@@ -0,0 +1,44 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Generalized homogeneous-earth underground wideband
+impedance.
+
+**Availability.** Registered scientific identity. The coaxial implementation is
+not yet implemented. The method fails without a numerical fallback.
+
+**Expression.**
+
+```math
+Z_{e,ij}=\\frac{j\\omega\\mu_0}{2\\pi}\\left[
+K_0(\\gamma_1d_{ij})-K_0(\\gamma_1D_{ij})+2S_{11}^c+
+2\\gamma_1^2S_{13}^c\\right],
+```
+
+```math
+S_{11}^c=\\int_0^\\infty\\frac{e^{-Hu_1}\\lambda^2\\cos(y\\lambda)}
+{(\\lambda^2+\\gamma_1^2)(u_0+u_1)}d\\lambda,\\qquad
+S_{13}^c=\\int_0^\\infty\\frac{e^{-Hu_1}\\cos(y\\lambda)}
+{(\\lambda^2+\\gamma_1^2)(u_0+u_1)}d\\lambda.
+```
+
+**Reference.** Haoyan Xue, *General Formulation and Accurate Evaluation of
+Earth-Return Parameters for Overhead / Underground Cables*, doctoral thesis,
+Polytechnique Montréal, 2018.
+[Primary source](https://publications.polymtl.ca/3190/1/2018_HaoyanXue.pdf).
+"""
+function description(::Type{<:Formula{:xue2018}}; compact::Bool = false)
+ compact ? "Xue" : "Xue homogeneous-earth underground impedance (2018) — not yet implemented"
+end
+
+function earth_impedance(
+ ::Formula{:xue2018}, kind::Union{Val{:self}, Val{:mutual}}, ::Val{2}, ::Val{2},
+ functor, workspace
+)
+ throw(ArgumentError("earth_impedance :xue2018 ($kind), source layer 2, target layer 2: not yet implemented for the coaxial backend"))
+end
+
+formulation_options(::Expression{<:Formula{:xue2018}, typeof(earth_impedance)}) =
+ FormulationOptions()
+
+:xue2018
diff --git a/src/engine/earthimpedance/homogeneous.jl b/src/engine/earthimpedance/homogeneous.jl
deleted file mode 100644
index c4caa6624..000000000
--- a/src/engine/earthimpedance/homogeneous.jl
+++ /dev/null
@@ -1,206 +0,0 @@
-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/earthimpedance/interface.jl b/src/engine/earthimpedance/interface.jl
new file mode 100644
index 000000000..86c99196f
--- /dev/null
+++ b/src/engine/earthimpedance/interface.jl
@@ -0,0 +1,103 @@
+"""
+$(TYPEDEF)
+
+Own one earth impedance formulation, its indexed equation declarations and explicit
+controls. Each interaction is selected by its explicit kind and source-layer and
+target-layer equation signature. The layers in the signatures of its
+`earth_impedance` methods declare the media it handles: methods up to layer 2 treat air
+and one homogeneous earth. `parameters` stores model data. `options` stores formulation
+choices and numerical controls.
+
+$(TYPEDFIELDS)
+"""
+struct Formula{ID, P <: NamedTuple, O <: FormulationOptions, E} <:
+ EarthImpedanceFormulation
+ "Explicit physical model parameters."
+ parameters::P
+ "Formulation options. Projected onto required indexed equations during initialization."
+ options::O
+ "Independent equivalent homogeneous-earth reduction, or nothing."
+ equivalent_earth::E
+end
+
+"""
+Evaluate one source-owned earth impedance equation.
+"""
+function earth_impedance end
+
+"""
+$(TYPEDSIGNATURES)
+
+Resolve a formulation's model parameters and numerical controls. Its concrete
+selection type defines the indexed equation. Material properties are evaluated
+before equation execution.
+Formula-specific arguments are normalized by their selected owner.
+A missing physical case is unsupported.
+"""
+function Formula{ID}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions(), equivalent_earth = nothing) where {ID}
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ options = formulation_options(Formula{ID}, options)
+ isempty(parameters) ||
+ throw(ArgumentError("earth formula :$ID has no configurable physical parameters"))
+ ID in formulas(Formula) || throw(ArgumentError("unknown earthimpedance formula :$ID"))
+ reduction = if equivalent_earth === nothing
+ nothing
+ elseif equivalent_earth isa EquivalentHomogeneous.AbstractSequence
+ equivalent_earth
+ elseif equivalent_earth isa FormulaDefinition
+ EquivalentHomogeneous.AbstractSequence(equivalent_earth)
+ else
+ throw(ArgumentError("equivalent_earth must be a formula definition or explicit reduction sequence"))
+ end
+ return Formula{ID, typeof(parameters), typeof(options), typeof(reduction)}(
+ parameters, options, reduction)
+end
+
+Formula(identifier::Symbol; kwargs...) = Formula(Val(identifier); kwargs...)
+Formula(::Val{ID}; kwargs...) where {ID} = Formula{ID}(; kwargs...)
+Formula(selected::EarthImpedanceFormulation) = selected
+Formula(::Nothing) = Formula(:default)
+
+# A recipe selects one formula per earth route. Within it, a `nothing` route supplies no
+# expression. Only omitting the whole slot selects the default.
+function Formula(recipe::NamedTuple)
+ routes = (; pairs(Formula)...)
+ all(in(keys(routes)), keys(recipe)) || throw(ArgumentError(
+ "$Formula selections admit only $(join(keys(routes), ", "))"))
+ names = filter(in(keys(recipe)), keys(routes))
+ return NamedTuple{names}(map(name -> recipe[name] === nothing ? nothing :
+ Formula(recipe[name]), names))
+end
+
+function Formula(selection::FormulaDefinition{ID, Order}) where {ID, Order}
+ Order === :default || throw(ArgumentError("order applies only to equivalent_earth"))
+ return Formula{ID}(; parameters = selection.parameters,
+ options = selection.options, equivalent_earth = selection.equivalent_earth)
+end
+
+formula_id(::Formula{ID}) where {ID} = ID
+formula_id(::Type{<:Formula{ID}}) where {ID} = ID
+# Identity-only dispatch also describes retained selections without constructors.
+description(value::Formula; compact::Bool = false) = description(typeof(value); compact)
+formulation_options(value::Formula) = value.options
+# Indexed numerical controls remain deferred to their consuming equations.
+formulation_options(::Type{<:Formula}, options::FormulationOptions) = options
+
+"""
+$(TYPEDSIGNATURES)
+
+Expose the selected identity, model and numerical controls, and explicit
+equivalent-earth reduction as a native record.
+"""
+function Base.NamedTuple(value::Formula)
+ return (identifier = formula_id(value),
+ parameters = value.parameters, options = value.options.data,
+ equivalent_earth = value.equivalent_earth === nothing ? nothing :
+ NamedTuple(value.equivalent_earth))
+end
+
+"""Iterate the independently selectable child slots admitted by this formula family."""
+function Base.pairs(::Type{<:Formula}; quantity = nothing)
+ pairs((air = Formula, earth = Formula, mixed = Formula))
+end
diff --git a/src/engine/earthkernels.jl b/src/engine/earthkernels.jl
new file mode 100644
index 000000000..ee9ba7c0f
--- /dev/null
+++ b/src/engine/earthkernels.jl
@@ -0,0 +1,354 @@
+"""
+Select the outgoing square root for the exp(jωt) convention.
+"""
+@inline function outgoing_root(z)
+ root = sqrt(complex(z))
+ return real(root) < 0 || (iszero(real(root)) && imag(root) < 0) ? -root : root
+end
+
+@inline root_difference(k2, a, λ) = iszero(k2) ? zero(a) : k2/(a+λ)
+
+# Keep the decay and oscillation in the same exponential on a complex ray.
+@inline earth_cosine(height, separation, λ) = (exp(-(height+im*separation)*λ)+exp(-(height-im*separation)*λ))/2
+
+function earth_spectral_value(kernel, λ, height, separation, radius)
+ if iszero(radius)
+ return earth_combined_weight(kernel) ?
+ (earth_weighted_spectrum(kernel, λ, -(height+im*separation)*λ) +
+ earth_weighted_spectrum(kernel, λ, -(height-im*separation)*λ))/2 :
+ kernel(λ)*earth_cosine(height, separation, λ)
+ end
+ growth=abs(imag(radius*λ))
+ weighted=if earth_combined_weight(kernel)
+ (earth_weighted_spectrum(kernel, λ, -(height+im*separation)*λ+growth) +
+ earth_weighted_spectrum(kernel, λ, -(height-im*separation)*λ+growth))/2
+ else
+ kernel(λ)*(exp(-(height+im*separation)*λ+growth) +
+ exp(-(height-im*separation)*λ+growth))/2
+ end
+ isfinite(weighted) ||
+ throw(DomainError((λ, weighted), "nonfinite weighted earth kernel"))
+ iszero(numerical_magnitude(weighted)) && return weighted
+ return weighted*special_besseljx(0, radius*λ)
+end
+
+"""
+Evaluate I₀(z)−1 without subtracting two nearly equal numbers.
+"""
+function bessel_i0m1(z)
+ if abs(nominal(z)) > 0.5
+ return special_besselix(0, z)*exp(abs(real(z)))-one(z)
+ end
+ term=z*z/4
+ result=term
+ for n in 2:1000
+ term *= (z/(2n))^2
+ next=result+term
+ next == result && return next
+ result=next
+ end
+ return result
+end
+
+@inline bessel_j0m1(z) = bessel_i0m1(im*z)
+
+# Combine the air receiver and its interface endpoint before integration.
+# The kernel is ag*(I0*exp(-hp*a0)-J0)/(a0*ds).
+struct AirVoltageSpectrum{Q, S, G}
+ state::S
+ geometry::G
+end
+
+function earth_weighted_spectrum(kernel::AirVoltageSpectrum{Q},
+ λ, logweight) where {Q}
+ u=kernel.state
+ g=kernel.geometry
+ a0=outgoing_root(λ^2+u.k2[1])
+ ag=outgoing_root(λ^2+u.k2[2])
+ ap=a0
+ aq=(a0, ag)[Q]
+ decay=exp(g.logscale-g.hq*root_difference(u.k2[Q], aq, λ)+logweight)
+ difference=if iszero(ap)
+ argument=g.radius*λ
+ j0=exp(-g.padding*λ+abs(imag(argument)))*special_besseljx(0, argument)
+ -g.hp*j0
+ elseif abs(nominal(g.radius*λ))<0.5
+ jminus=bessel_j0m1(g.radius*λ)
+ envelope=exp(-g.padding*λ)
+ envelope*(g.i0minus*exp(-g.hp*ap)+expm1(-g.hp*ap)-jminus)/ap
+ else
+ argument=g.radius*λ
+ envelope=exp(-g.padding*λ+abs(imag(argument)))
+ scaled=iszero(numerical_magnitude(envelope)) ? zero(envelope) :
+ envelope*special_besseljx(0, argument)
+ ((one(g.i0minus)+g.i0minus)*exp(-g.padding*λ-g.hp*ap)-scaled)/ap
+ end
+ return decay*ag*difference/(u.sh[2]*a0+u.sh[1]*ag)
+end
+(kernel::AirVoltageSpectrum)(λ) = earth_weighted_spectrum(kernel, λ, zero(λ))
+function earth_combined_weight(kernel::AirVoltageSpectrum)
+ g=kernel.geometry
+ return nominal(g.logscale+g.hq*maximum(abs, kernel.state.k))>300
+end
+
+"""
+Evaluate the removable I₁(z)/(z I₀(z)) limit.
+"""
+@inline function bessel_current_ratio(z)
+ if abs(nominal(z)) < 1e-3
+ z2=z*z
+ return one(z)/2-z2/16+z2*z2/96-11z2^3/6144
+ end
+ return special_besselix(1, z)/(z*special_besselix(0, z))
+end
+
+# Each term excludes exp(-height*λ), cos(yλ), and optional J₀(rλ).
+# Kind and the two ordered media dispatch outside the spectral loop.
+struct EarthSpectrum{Kind, Receiver, Source, S, G}
+ state::S
+ geometry::G
+end
+
+function EarthSpectrum{Kind, P, Q}(state::S, geometry::G) where {Kind, P, Q, S, G}
+ EarthSpectrum{Kind, P, Q, S, G}(state, geometry)
+end
+
+@inline function earth_weighted_spectrum(kernel::EarthSpectrum{:Z, P, Q}, λ, logweight) where {
+ P, Q}
+ u=kernel.state
+ g=kernel.geometry
+ a0=outgoing_root(λ^2+u.k2[1])
+ ag=outgoing_root(λ^2+u.k2[2])
+ a=(a0, ag)
+ dm=u.mu[2]*a0+u.mu[1]*ag
+ decay=exp(g.logscale-g.hp*root_difference(u.k2[P], a[P], λ) -
+ g.hq*root_difference(u.k2[Q], a[Q], λ)+logweight)
+ return decay*u.mu[1]*u.mu[2]/dm
+end
+
+@inline function earth_weighted_spectrum(kernel::EarthSpectrum{:phi, P, Q}, λ, logweight) where {
+ P, Q}
+ u=kernel.state
+ g=kernel.geometry
+ a0=outgoing_root(λ^2+u.k2[1])
+ ag=outgoing_root(λ^2+u.k2[2])
+ a=(a0, ag)
+ dm=u.mu[2]*a0+u.mu[1]*ag
+ ds=u.sh[2]*a0+u.sh[1]*ag
+ decay=exp(g.logscale-g.hp*root_difference(u.k2[P], a[P], λ) -
+ g.hq*root_difference(u.k2[Q], a[Q], λ)+logweight)
+ return decay*(u.mu[1]*a0+u.mu[2]*ag)/(dm*ds)
+end
+
+@inline function earth_weighted_spectrum(kernel::EarthSpectrum{:voltage, 1, Q}, λ, logweight) where {Q}
+ u=kernel.state
+ g=kernel.geometry
+ a0=outgoing_root(λ^2+u.k2[1])
+ ag=outgoing_root(λ^2+u.k2[2])
+ a=(a0, ag)
+ ds=u.sh[2]*a0+u.sh[1]*ag
+ decay=exp(g.logscale-g.hp*root_difference(u.k2[1], a[1], λ) -
+ g.hq*root_difference(u.k2[Q], a[Q], λ)+logweight)
+ return decay*(ag/a0)/ds
+end
+
+@inline function earth_weighted_spectrum(kernel::EarthSpectrum{:voltage, 2, Q}, λ, logweight) where {Q}
+ u=kernel.state
+ g=kernel.geometry
+ a0=outgoing_root(λ^2+u.k2[1])
+ ag=outgoing_root(λ^2+u.k2[2])
+ a=(a0, ag)
+ ds=u.sh[2]*a0+u.sh[1]*ag
+ decay=exp(g.logscale-g.hp*root_difference(u.k2[2], a[2], λ) -
+ g.hq*root_difference(u.k2[Q], a[Q], λ)+logweight)
+ return decay*(a0/ag)/ds
+end
+
+@inline function earth_weighted_spectrum(
+ kernel::EarthSpectrum{
+ :air_reference, 1, Q}, λ, logweight) where {Q}
+ u=kernel.state
+ g=kernel.geometry
+ a0=outgoing_root(λ^2+u.k2[1])
+ ag=outgoing_root(λ^2+u.k2[2])
+ a=(a0, ag)
+ ds=u.sh[2]*a0+u.sh[1]*ag
+ decay=exp(g.logscale-g.hp*root_difference(u.k2[1], a[1], λ) -
+ g.hq*root_difference(u.k2[Q], a[Q], λ)+logweight)
+ return -decay*ag/(a0*ds)
+end
+
+@inline (kernel::EarthSpectrum)(λ) = earth_weighted_spectrum(kernel, λ, zero(λ))
+function earth_combined_weight(kernel::EarthSpectrum)
+ g=kernel.geometry
+ return nominal(g.logscale+(g.hp+g.hq)*maximum(abs, kernel.state.k))>300
+end
+
+# Evaluate the averaged direct-image term of the Unified formula `formula` with the
+# source-column scaling. Both Unified families call it.
+function earth_direct(::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation},
+ ::Union{Val{:self}, Val{:mutual}}, ::Val{1}, ::Val{2}, state,
+ pair, radius, average, argument, target_scale, source_scale)
+ zero(state.jω)
+end
+function earth_direct(::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation},
+ ::Union{Val{:self}, Val{:mutual}}, ::Val{2}, ::Val{1}, state,
+ pair, radius, average, argument, target_scale, source_scale)
+ zero(state.jω)
+end
+
+function earth_direct(::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation},
+ ::Val{:self}, ::Val{M}, ::Val{M}, u,
+ pair::EarthPair, r, average, xp, sp, sq) where {M}
+ h=abs(pair.heights[2])+abs(pair.heights[1])
+ d=hypot(pair.separation, h)
+ D=r
+ k=u.k[M]
+ if abs(nominal(k*d)) < 0.5
+ difference=iszero(k) ? log(d/D) : special_besselk(0, k*D)-special_besselk(0, k*d)
+ correction=iszero(k) ? zero(k) : bessel_i0m1(xp)*special_besselk(0, k*d)
+ return exp(sq)*(difference-correction)
+ end
+ direct=special_besselkx(0, k*r)*exp(sq-k*r)
+ image=average*special_besselkx(0, k*d)*exp(sp+sq-k*d)
+ return direct-image
+end
+
+function earth_direct(::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation},
+ ::Val{:mutual}, ::Val{M}, ::Val{M}, u,
+ pair::EarthPair, r, average, xp, sp, sq) where {M}
+ h=abs(pair.heights[2])+abs(pair.heights[1])
+ d=hypot(pair.separation, h)
+ D=hypot(pair.separation, pair.heights[2]-pair.heights[1])
+ k=u.k[M]
+ if abs(nominal(k*d)) < 0.5
+ difference=iszero(k) ? log(d/D) : special_besselk(0, k*D)-special_besselk(0, k*d)
+ return average*exp(sp+sq)*difference
+ end
+ return average*(special_besselkx(0, k*D)*exp(sp+sq-k*D) -
+ special_besselkx(0, k*d)*exp(sp+sq-k*d))
+end
+
+# Subdivision decisions use nominal branch positions, including degenerate
+# equal-media denominators. Physical kernel evaluations retain uncertainty.
+function earth_denominator_pole(state)
+ sh=map(nominal, state.sh)
+ k2=map(nominal, state.k2)
+ denominator=sh[2]^2-sh[1]^2
+ iszero(denominator) && return nothing
+ return outgoing_root((sh[1]^2*k2[2]-sh[2]^2*k2[1])/denominator)
+end
+function earth_valid_pole(state, candidate, ::Type{R}) where {R}
+ sh=map(nominal, state.sh)
+ k2=map(nominal, state.k2)
+ a=sh[2]*outgoing_root(candidate^2+k2[1])
+ b=sh[1]*outgoing_root(candidate^2+k2[2])
+ return abs(a+b)<=sqrt(eps(R))*(abs(a)+abs(b))
+end
+
+# The medium and denominator scales are independent. Retain both, even when
+# their ratio spans many decades. Bridge them without prescribing a fixed grid.
+function earth_spectral_points!(arrays, state, height, separation, radius, angle)
+ R=typeof(float(nominal(real(state.jω))))
+ scales=empty!(arrays.scales)
+ for k in state.k
+ iszero(nominal(k)) || push!(scales, R(abs(nominal(k))))
+ end
+ for (p, q) in ((1, 2), (2, 1))
+ !iszero(nominal(state.sh[q])) &&
+ push!(scales, R(abs(nominal(state.k[q]*state.sh[p]/state.sh[q]))))
+ end
+ decay=R(nominal(height))*cos(angle)-R(nominal(separation+radius))*sin(angle)
+ decay>0 || throw(DomainError(angle, "spectral contour has a growing geometric weight"))
+ push!(scales, inv(decay))
+ filter!(x->isfinite(x)&&x>zero(R), scales)
+ unique!(sort!(scales; alg = Base.Sort.QuickSort))
+ points=empty!(arrays.points)
+ push!(points, zero(R))
+ for scale in scales
+ append!(points, (scale/2, scale, 2scale))
+ end
+ rotation=cis(angle)
+ for k in state.k, sign in (-1, 1)
+
+ earth_spectral_neighbourhood!(points, nominal(sign*im*k)/rotation)
+ end
+ pole=earth_denominator_pole(state)
+ if pole!==nothing
+ for candidate in (pole, -pole)
+ earth_valid_pole(state, candidate, R) || continue
+ earth_spectral_neighbourhood!(points, nominal(candidate)/rotation)
+ end
+ end
+ unique!(sort!(points; alg = Base.Sort.QuickSort))
+ # Extra intervals only bridge gaps between physically declared scales.
+ seeds=empty!(arrays.seeds)
+ append!(seeds, points)
+ for i in 2:(length(seeds) - 1)
+ x=4seeds[i]
+ while x0 || return points
+ width=max(width, 64eps(R)*center)
+ for offset in (-4, -1, 0, 1, 4)
+ point=center+offset*width
+ point>0&&isfinite(point) && push!(points, point)
+ end
+ return points
+end
+
+# A prescribed Γ can move a branch point into the first quadrant. Keep the
+# contour below it. The usual passive Γ=0 roots do not restrict the upper ray.
+function earth_contour_angle(state, proposed)
+ result=proposed
+ for k in state.k, sign in (-1, 1)
+
+ branch=nominal(sign*im*k)
+ real(branch)>0&&imag(branch)>0 || continue
+ result=min(result, atan(imag(branch), real(branch))/2)
+ end
+ # Squaring the electric denominator supplies pole candidates. Only a
+ # candidate satisfying the original, unsquared denominator is a pole.
+ pole=earth_denominator_pole(state)
+ if pole!==nothing
+ for candidate in (pole, -pole)
+ real(candidate)>0&&imag(candidate)>0 || continue
+ earth_valid_pole(state, candidate, typeof(proposed)) || continue
+ result=min(result, atan(imag(candidate), real(candidate))/2)
+ end
+ end
+ return typeof(proposed)(result)
+end
+
+function earth_spectral_term(::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation},
+ kind::Val{Kind}, ::Val{P}, ::Val{Q}, state, hp, hq, y, radius, logscale,
+ method, controls, numerical; context = (;)) where {Kind, P, Q}
+ R=typeof(float(nominal(real(state.jω))))
+ h=hp+hq
+ angle=min(R(π)/6, atan(R(nominal(h))/(2max(R(nominal(y+radius)), eps(R)))))
+ angle=earth_contour_angle(state, angle)
+ points=earth_spectral_points!(numerical.earth_spectrum, state, h, y, radius, angle)
+ scale=max(R(abs(nominal(state.k[1]))), R(abs(nominal(state.k[2]))), inv(R(nominal(h))))
+ g=(; hp, hq, logscale)
+ kernel=EarthSpectrum{Kind, P, Q, typeof(state), typeof(g)}(state, g)
+ contour=scale*cis(angle)
+ integral=SpectralIntegral(t->contour*earth_spectral_value(
+ kernel, contour*t, h, y, radius))
+ points ./= scale
+ push!(points, one(scale))
+ value,
+ _ = integrate(integral, method, controls,
+ numerical;
+ points = points, coordinate_type = R, context = merge(context, (term = Kind,)),
+ observations = numerical.observations)
+ return value
+end
diff --git a/src/engine/earthplan.jl b/src/engine/earthplan.jl
new file mode 100644
index 000000000..0866b1c62
--- /dev/null
+++ b/src/engine/earthplan.jl
@@ -0,0 +1,260 @@
+"""
+$(TYPEDEF)
+
+Hold the earth calculations of a line-parameter computation, fixed before the frequency loop.
+The workspace stores it as `plan.earth`, beside its arrays in `buffers.earth`. An earth
+calculation is a record with these fields.
+
+- `impedance` and `admittance`: each is `(formula, pairs)`, the formula of that quantity and
+ the conductor pairs whose values it publishes. It is `nothing` when the calculation does
+ not serve the quantity.
+- `earth`: the layered earth model or the reduction that its expressions consume.
+- `reductions`: on a reduced earth, each is `(expression, options, pairs)`, one expression of
+ the reduction, its normalized options and the conductor pairs it serves. It is `nothing` on
+ the layered earth.
+- `parts`: each is `(expression, options, pairs)`, one expression of the formula, its
+ normalized options and the conductor pairs it serves. The expression returns one
+ coefficient per destination of the calculation, a number for one destination and a tuple
+ for several.
+- `pairs`: each is `(pair, physical, reuse_from)`. It holds the pair on the decided earth and
+ the physical pair. `reuse_from` is the earlier pair with the same inputs whose computed value
+ this pair takes when their media agree, or its own index when the pair is computed.
+- `media`: the medium of each conductor on the decided earth.
+
+The plan of one slot serves only that slot's quantity. The merge of the impedance and the
+admittance plans gives one calculation both quantities when it can serve them.
+
+$(TYPEDFIELDS)
+"""
+struct EarthPlan
+ "Earth calculations. Their record types differ, and the frequency loop dispatches on them once."
+ calculations::Tuple
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the calculation of the earth formula `formula` for the physical conductor pairs
+`physical[indices]`, on the decided `earth`: the layered earth `model`, or a reduction of it.
+The constructor checks each pair and the geometric restrictions of each expression. It checks
+that `formula` defines its expressions for the layers of `model`. On a reduction, it checks
+that the reduction admits each expression and defines its own expression for each physical
+pair. The distinct expressions, with the options that `formula` declares for each, become the
+parts.
+`geometry` holds the conductor radii and the physical layer of each conductor.
+
+A formula that computes the whole system adds a method on its own type.
+"""
+function EarthPlan(formula::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation},
+ earth, model::EarthModel, physical::AbstractVector{<:EarthPair}, indices,
+ geometry::NamedTuple)
+ earth === model && validate(model, formula)
+ decided = [EarthPair(physical[index], earth) for index in indices]
+ expressions = map(decided) do pair
+ validate(pair)
+ expression = Expression(formula, pair)
+ validate(pair, expression)
+ expression
+ end
+ projected = formulation_options(formula, expressions)
+ for expression in projected.expressions
+ validate(expression, model.layers)
+ end
+ # The reduction's own expressions, their options and the physical pairs each serves.
+ reductions = if earth === model
+ nothing
+ else
+ rule = EquivalentHomogeneous.rule(earth)
+ foreach(expression -> validate(rule, expression), expressions)
+ reduced = [Expression(rule, physical[index]) for index in indices]
+ normalized = formulation_options(rule, reduced)
+ foreach(validate, normalized.expressions)
+ Tuple(map(normalized.expressions, normalized.options) do expression, options
+ (; expression, options, pairs = findall(==(expression), reduced))
+ end)
+ end
+ # The pairs that each distinct expression serves.
+ served = [findall(==(expression), expressions) for expression in projected.expressions]
+ # A pair takes the value of the nearest earlier pair of its part with the same inputs.
+ reuse_from = collect(eachindex(decided))
+ for members in served, ordinal in eachindex(members)
+ position = members[ordinal]
+ for earlier in (ordinal - 1):-1:1
+ candidate = members[earlier]
+ if same_physical_state(formula, decided[position], decided[candidate], geometry)
+ reuse_from[position] = candidate
+ break
+ end
+ end
+ end
+ parts = map(projected.expressions, projected.options, served) do expression, options, pairs
+ (; expression, options, pairs)
+ end
+ pairs = [(pair = decided[position], physical = physical[index],
+ reuse_from = reuse_from[position]) for (position, index) in enumerate(indices)]
+ media = earth === model ? geometry.layers :
+ [layer == 1 ? 1 : 2 for layer in geometry.layers]
+ published = (; formula, pairs = collect(eachindex(decided)))
+ impedance = formula isa EarthImpedanceFormulation ? published : nothing
+ admittance = formula isa EarthAdmittanceFormulation ? published : nothing
+ calculation = (; impedance, admittance, earth, reductions, parts = Tuple(parts), pairs,
+ media)
+ return EarthPlan((calculation,))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the earth slot that holds the formula `formula`, for the physical conductor pairs
+`physical` of an earth `model`. The slot decides its earth once: an explicit
+`equivalent_earth` applies. Without one, a formula that does not admit any layer from 3 to N
+consumes the `:default` reduction of a model with N > 2 layers, and every other formula sees
+the layered earth.
+"""
+function EarthPlan(formula::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation},
+ model::EarthModel, physical::AbstractVector{<:EarthPair}, geometry::NamedTuple)
+ layers = length(model.layers)
+ earth = if formula.equivalent_earth !== nothing
+ formula.equivalent_earth
+ elseif layers > 2 &&
+ !any(k -> _admits_layer(Expression(formula, first(physical)), k), 3:layers)
+ EquivalentHomogeneous.AbstractSequence(LineCableModels.formula(:default))
+ else
+ model
+ end
+ return EarthPlan(formula, earth, model, physical, eachindex(physical), geometry)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the earth slot that holds a recipe of homogeneous formulas, for the physical conductor
+pairs `physical` of an earth `model`. The slot decides its earth once: the explicit
+reduction that its formulas agree on, or else the `:default` reduction on more than two
+layers. Each pair then takes the recipe's formula for its layers on that earth, and each
+distinct formula builds one calculation.
+
+# Errors
+
+- Throws `ArgumentError` when a formula of the recipe admits a layer above 2, or when the
+ formulas give different equivalent earths.
+"""
+function EarthPlan(recipe::NamedTuple, model::EarthModel,
+ physical::AbstractVector{<:EarthPair}, geometry::NamedTuple)
+ selected = Tuple(leaf for leaf in recipe if leaf !== nothing)
+ layers = length(model.layers)
+ for leaf in selected
+ expression = Expression(leaf, first(physical))
+ any(k -> _admits_layer(expression, k), 3:max(3, layers)) && throw(ArgumentError(
+ "formula :$(formula_id(leaf)) is multilayer and is used alone"))
+ end
+ explicit = unique(leaf.equivalent_earth for leaf in selected
+ if leaf.equivalent_earth !== nothing)
+ length(explicit) > 1 && throw(ArgumentError(
+ "the formulas of an earth recipe give different equivalent earths"))
+ earth = !isempty(explicit) ? only(explicit) :
+ layers > 2 ? EquivalentHomogeneous.AbstractSequence(LineCableModels.formula(:default)) :
+ model
+ leaves = [Expression(recipe, EarthPair(pair, earth)).selection for pair in physical]
+ calculations = NamedTuple[]
+ for leaf in unique(leaves)
+ indices = findall(value -> value === leaf, leaves)
+ append!(calculations,
+ EarthPlan(leaf, earth, model, physical, indices, geometry).calculations)
+ end
+ return EarthPlan(Tuple(calculations))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Merge the earth plans of the impedance and the admittance slot. The first plan's calculations
+compute the impedance and the second plan's the admittance. An impedance and an admittance
+calculation become one calculation serving both when their formulas have the same physical
+state and their parts the same options.
+"""
+function EarthPlan(impedance::EarthPlan, admittance::EarthPlan)
+ calculations = NamedTuple[]
+ remaining = collect(NamedTuple, admittance.calculations)
+ for calculation in impedance.calculations
+ index = findfirst(remaining) do candidate
+ same_physical_state(calculation.impedance.formula, candidate.admittance.formula) &&
+ same_physical_state(first(calculation.parts).options.data,
+ first(candidate.parts).options.data)
+ end
+ if index === nothing
+ push!(calculations, calculation)
+ else
+ push!(calculations,
+ merge(calculation, (admittance = remaining[index].admittance,)))
+ deleteat!(remaining, index)
+ end
+ end
+ append!(calculations, remaining)
+ return EarthPlan(Tuple(calculations))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Whether `formula` gives the conductor pairs `a` and `b` the same value when their media
+agree. `geometry` holds the conductor radii. By default the pairs' destination indices take
+part, so distinct pairs are computed separately. A formula whose arithmetic does not read
+them compares the inputs it reads instead.
+"""
+function same_physical_state(
+ ::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation},
+ a::EarthPair, b::EarthPair, geometry::NamedTuple)
+ return same_physical_state((a.row, a.column), (b.row, b.column))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Extend `buffers` with `earth`, the arrays of the earth calculations of `earth`.
+
+- `static` and `evaluated` hold the earth's layer properties, static and at each frequency.
+ `evaluated` is `nothing` unless a calculation consumes the evaluated layers.
+- `calculations` holds, for each calculation, the `rho`, `epsilon` and `mu` of the media that
+ each of its conductor pairs sees, one column per pair, and the layer thicknesses of a
+ layered earth.
+- `pairs` holds, for the conductor pairs, the pair whose computed value each one took at the
+ current frequency, the ranges of its integrals and warnings, and the warning records.
+
+The integration warnings, when a formula integrates, share the warning records: the
+formulas of the calculations provision their own arrays first, through the formulation's
+methods.
+"""
+function initialize_buffers(earth::EarthPlan, ::Type{T}, input, plan, buffers) where {T}
+ layers = input.earth.layers
+ static = (rho = collect(getproperty.(layers, :rho)),
+ eps_r = collect(getproperty.(layers, :eps_r)),
+ mu_r = collect(getproperty.(layers, :mu_r)))
+ needed = any(calculation -> !(calculation.earth isa EquivalentHomogeneous.BeforeFD),
+ earth.calculations)
+ evaluated = needed ?
+ map(_ -> Matrix{eltype(input.freq)}(undef, length(layers), input.n_frequencies),
+ static) : nothing
+ Evaluated = NamedTuple{(:rho, :eps_r, :mu_r), NTuple{3, Matrix{eltype(input.freq)}}}
+ # The layered earth keeps every physical layer, and its thicknesses when it has interior
+ # layers. A reduction keeps air and one equivalent medium.
+ calculations = map(earth.calculations) do calculation
+ count = calculation.earth isa EarthModel ? length(layers) : 2
+ columns = length(calculation.pairs)
+ thickness = count > 2 ? T[layer.thickness for layer in layers] : nothing
+ (rho = Matrix{T}(undef, count, columns), epsilon = Matrix{T}(undef, count, columns),
+ mu = Matrix{T}(undef, count, columns), thickness)
+ end
+ pairs = (representatives = zeros(Int, input.n_cables^2),
+ integral_ranges = Vector{UnitRange{Int}}(undef, input.n_cables^2),
+ warning_ranges = Vector{UnitRange{Int}}(undef, input.n_cables^2),
+ warnings = NamedTuple[])
+ # The record type does not depend on the calculations, nor on whether the evaluated
+ # layers are needed.
+ Record = NamedTuple{(:static, :evaluated, :calculations, :pairs),
+ Tuple{typeof(static), Union{Nothing, Evaluated}, Tuple, typeof(pairs)}}
+ buffers = merge(buffers, (earth = Record((static, evaluated, calculations, pairs)),))
+ haskey(buffers, :quadrature) || return buffers
+ return merge(buffers, (quadrature = merge(buffers.quadrature,
+ (warnings = buffers.earth.pairs.warnings,)),))
+end
diff --git a/src/engine/earthreturn.jl b/src/engine/earthreturn.jl
new file mode 100644
index 000000000..c310bb5f1
--- /dev/null
+++ b/src/engine/earthreturn.jl
@@ -0,0 +1,136 @@
+function earth!(workspace::LineParametersWorkspace, frequency::Int,
+ calculations::Tuple = workspace.plan.earth.calculations,
+ materials::Tuple = workspace.buffers.earth.calculations)
+ foreach(calculations, materials) do calculation, material
+ earth!(calculation, material, workspace, frequency)
+ end
+ return workspace
+end
+
+# Build the calculation's Functor at the frequency, evaluate its parts into the destinations,
+# convert them when the formula requires it, then publish each served quantity's pairs.
+function earth!(calculation::NamedTuple, materials::NamedTuple, workspace, frequency::Int)
+ formula = something(calculation.impedance, calculation.admittance).formula
+ functor = Functor(formula, (; jω = workspace.input.jω[frequency], frequency,
+ materials.rho, materials.epsilon, materials.mu, materials.thickness,
+ calculation.media); workspace)
+ earth!(functor.input.destinations, calculation, functor, workspace)
+ physical = earth!(formula, functor, workspace)
+ if calculation.impedance !== nothing && haskey(physical, :impedance)
+ destination = workspace.buffers.Zearth
+ for index in calculation.impedance.pairs
+ pair = calculation.pairs[index].pair
+ destination[pair.row, pair.column] = physical.impedance[pair.row, pair.column]
+ end
+ end
+ if calculation.admittance !== nothing && haskey(physical, :admittance)
+ destination = workspace.buffers.Pearth
+ for index in calculation.admittance.pairs
+ pair = calculation.pairs[index].pair
+ destination[pair.row, pair.column] = physical.admittance[pair.row, pair.column]
+ end
+ end
+ return workspace
+end
+
+# The parts of a formula that does not solve a whole system wrote the physical coefficients
+# into `Zearth` or `Pearth`, so the conversion does not return any matrix.
+earth!(::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation}, ::Functor, workspace) = (;)
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate the parts of an earth calculation at the frequency of `functor` and distribute their
+scalar coefficients into the aligned matrices `destinations`. A part gives its expression and
+options. The expression returns one coefficient per destination, a number for one destination
+and a tuple for several. The calculation's pairs give each pair's source-target geometry and
+the earlier pair with the same inputs, `reuse_from`.
+
+A pair takes the value of that earlier pair only when their current media agree under
+`same_physical_state`. `buffers.earth.pairs` records, at each frequency, the pair whose
+computed value each pair took. Every logical integral and warning retains its receiving row
+and source column.
+"""
+function earth!(destinations::Tuple{Vararg{AbstractMatrix}}, calculation::NamedTuple,
+ functor::Functor, workspace)
+ computed = workspace.buffers.earth.pairs
+ length(computed.representatives) >= length(calculation.pairs) ||
+ throw(DimensionMismatch("earth interaction scratch is too small"))
+ empty!(computed.warnings)
+ foreach(calculation.parts) do part
+ earth!(destinations, part, calculation, functor, workspace)
+ end
+ empty!(computed.warnings)
+ return destinations
+end
+
+function earth!(destinations, part, calculation, functor, workspace)
+ computed = workspace.buffers.earth.pairs
+ observations = workspace.buffers.observations
+ materials = (functor.input.rho, functor.input.epsilon, functor.input.mu)
+ for index in part.pairs
+ entry = calculation.pairs[index]
+ pair = entry.pair
+ # Follow the earlier pairs with the same inputs until one whose media agree.
+ current = index
+ source = entry.reuse_from
+ while source != current
+ same = let source = source
+ all(
+ values -> same_physical_state(@view(values[:, index]),
+ @view(values[:, source])),
+ materials)
+ end
+ same && break
+ current = source
+ source = calculation.pairs[source].reuse_from
+ end
+ if source == current
+ computed.representatives[index] = index
+ first_integral = observations === nothing ? 1 : length(observations)+1
+ first_warning = length(computed.warnings)+1
+ point = Functor(functor, (; pair, entry.physical,
+ rho = @view(functor.input.rho[:, index]),
+ epsilon = @view(functor.input.epsilon[:, index]),
+ mu = @view(functor.input.mu[:, index]), part.options))
+ validate(point.input, functor.formula)
+ coefficients = part.expression(point, workspace)
+ values = coefficients isa Tuple ? coefficients : (coefficients,)
+ all(value -> value isa Number && isfinite(value), values) || throw(DomainError(
+ values, "earth coefficients must be finite scalars"))
+ converted = map(value -> oftype(functor.input.jω, value), values)
+ length(converted) == length(destinations) ||
+ throw(DimensionMismatch("earth coefficients must match their destinations"))
+ foreach(destinations, converted) do destination, value
+ destination[pair.row, pair.column] = value
+ end
+ computed.integral_ranges[index] = first_integral:(observations === nothing ? 0 :
+ length(observations))
+ computed.warning_ranges[index] = first_warning:length(computed.warnings)
+ else
+ representative = computed.representatives[source]
+ computed.representatives[index] = representative
+ previous_pair = calculation.pairs[representative].pair
+ for destination in destinations
+ destination[pair.row, pair.column] = destination[previous_pair.row, previous_pair.column]
+ end
+ context = (receiver = pair.row, source = pair.column)
+ if observations !== nothing
+ for position in computed.integral_ranges[representative]
+ record = observations[position]
+ integral_context = record.context isa NamedTuple ?
+ merge(record.context, context) : record.context
+ push!(observations, merge(record, (context = integral_context,)))
+ end
+ end
+ for position in computed.warning_ranges[representative]
+ record = computed.warnings[position]
+ integral_context = record.context isa NamedTuple ?
+ merge(record.context, context) : record.context
+ record_integral!(nothing, nothing, record.value, record.estimated_error,
+ record.controls, integral_context)
+ end
+ end
+ end
+ return destinations
+end
diff --git a/src/engine/ehem/EHEM.jl b/src/engine/ehem/EHEM.jl
deleted file mode 100644
index bf63e9c83..000000000
--- a/src/engine/ehem/EHEM.jl
+++ /dev/null
@@ -1,26 +0,0 @@
-"""
- 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
deleted file mode 100644
index 86da5f684..000000000
--- a/src/engine/ehem/enforcelayer.jl
+++ /dev/null
@@ -1,136 +0,0 @@
-"""
-$(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
deleted file mode 100644
index 8b1b18052..000000000
--- a/src/engine/fem/FEM.jl
+++ /dev/null
@@ -1,73 +0,0 @@
-"""
- 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 ...Commons: PhaseDomain, ModalDomain, LineParamsDomain, domain
-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
-using Makie: Point, Point2f # otherwise will require adding GeometryBasics as a dependency
-# 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/base.jl b/src/engine/fem/base.jl
deleted file mode 100644
index 47b415fd7..000000000
--- a/src/engine/fem/base.jl
+++ /dev/null
@@ -1,2 +0,0 @@
-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
deleted file mode 100644
index d540f4307..000000000
--- a/src/engine/fem/cable.jl
+++ /dev/null
@@ -1,699 +0,0 @@
-"""
-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
- r_in = to_nominal(part.r_in)
- r_ex = to_nominal(part.r_ex)
-
- # 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(r_in, r_ex, 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.r_in)
- next_radius_ext = to_nominal(next_part.r_ex)
- 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 r_in ≈ 0
- # Solid disk
- _, _, marker, _ =
- draw_disk(x_center, y_center, r_ex, mesh_size, num_points_circumference)
- else
- # Annular shape
- _, _, marker, _ = draw_annular(
- x_center,
- y_center,
- r_in,
- r_ex,
- 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 `CircStrands` parts.
-
-# Arguments
-
-- `workspace`: The [`FEMWorkspace`](@ref) containing the model parameters.
-- `part`: The [`CircStrands`](@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::CircStrands,
- 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
- r_in = to_nominal(part.r_in)
- r_ex = to_nominal(part.r_ex)
-
- 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(r_in, r_ex, 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.r_in)
- next_radius_ext = to_nominal(next_part.r_ex)
- 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 CircStrands))
-
-
-
- num_points_circumference = workspace.formulation.points_per_circumference
-
- # Calculate wire positions
- function _calc_circstrands_coords(
- num_wires::Number,
- # radius_wire::Number,
- r_in::Number,
- r_ex::Number;
- C = (0.0, 0.0),
- )
- wire_coords = [] # Global coordinates of all wires
-
- lay_radius = num_wires == 1 ? 0 : (r_in + r_ex) / 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_circstrands_coords(num_wires, r_in, r_ex, 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 CircStrands outermost boundary
- mesh_size = (r_ex - r_in)
- if !(next_part isa CircStrands) && !isnothing(next_part)
- # step_angle = 2 * pi / num_wires
- add_mesh_points(
- r_in = r_ex,
- r_ex = r_ex,
- 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 CircStrands
- # Skip ONLY for single wire when next part is not a CircStrands
- 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, r_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
-
-"""
-$(TYPEDSIGNATURES)
-
-Specialized method to create the geometry for a `Sector` conductor.
-"""
-function _make_cablepart!(workspace::FEMWorkspace, part::Sector,
- cable_idx::Int, comp_idx::Int, comp_id::String,
- phase::Int, layer_idx::Int)
-
- # Get the cable's center coordinates from the workspace
- cable_position = workspace.problem_def.system.cables[cable_idx]
- x_center = to_nominal(cable_position.horz)
- y_center = to_nominal(cable_position.vert)
-
- # The vertices in the Sector object are already rotated and relative to the cable's origin.
- # We just need to translate them to the cable's position in the system.
- translated_vertices = [Point(v[1] + x_center, v[2] + y_center) for v in part.vertices]
-
- # Calculate mesh size for this part
- num_elements = workspace.formulation.elements_per_length_conductor
- mesh_size = _calc_mesh_size(part.r_in, part.r_ex, part.material_props, num_elements, workspace)
-
- # Create the polygon in Gmsh
- @debug "the translated vertices are $translated_vertices \n they are of type $(typeof(translated_vertices))"
- surface_tag, marker = draw_polygon(translated_vertices, mesh_size)
-
- # --- The rest of this function is similar to the other _make_cablepart! methods ---
-
- # Get material group (1 for conductor)
- material_group = get_material_group(part)
- material_id = get_or_register_material_id(workspace, part.material_props)
-
- # Create physical tag
- physical_group_tag = encode_physical_group_tag(1, cable_idx, comp_idx, material_group, material_id)
-
- # Create a descriptive name for the entity
- elementary_name = create_cable_elementary_name(
- cable_idx=cable_idx, component_id=comp_id, group_type=material_group,
- part_type="sector", layer_idx=layer_idx, phase=phase
- )
-
- # Create the entity data and add it to the workspace's unassigned entities
- core_data = CoreEntityData(physical_group_tag, elementary_name, mesh_size)
- entity_data = CablePartEntity(core_data, part)
- workspace.unassigned_entities[marker] = entity_data
-
- # Register the physical group
- register_physical_group!(workspace, physical_group_tag, part.material_props)
-end
-
-
-"""
-$(TYPEDSIGNATURES)
-
-Specialized method to create the geometry for a `SectorInsulator`.
-"""
-function _make_cablepart!(workspace::FEMWorkspace, part::SectorInsulator,
- cable_idx::Int, comp_idx::Int, comp_id::String,
- phase::Int, layer_idx::Int)
-
- cable_position = workspace.problem_def.system.cables[cable_idx]
- x_center = to_nominal(cable_position.horz)
- y_center = to_nominal(cable_position.vert)
-
- # Translate the vertices for both outer and inner boundaries
- outer_vertices_translated = [Point(v[1] + x_center, v[2] + y_center) for v in part.outer_vertices]
- inner_vertices_translated = [Point(v[1] + x_center, v[2] + y_center) for v in part.inner_sector.vertices]
-
- # Calculate mesh size for this part
- num_elements = workspace.formulation.elements_per_length_insulator
- mesh_size = _calc_mesh_size(part.r_in, part.r_ex, part.material_props, num_elements, workspace)
-
- # Create the polygon with a hole using our new drawing primitive
- surface_tag, marker = draw_polygon_with_hole(outer_vertices_translated, inner_vertices_translated, mesh_size)
-
- # --- The rest is similar to the Sector method ---
-
- material_group = get_material_group(part)
- material_id = get_or_register_material_id(workspace, part.material_props)
- physical_group_tag = encode_physical_group_tag(1, cable_idx, comp_idx, material_group, material_id)
-
- elementary_name = create_cable_elementary_name(
- cable_idx=cable_idx, component_id=comp_id, group_type=material_group,
- part_type="sector_insulator", layer_idx=layer_idx, phase=phase
- )
-
- core_data = CoreEntityData(physical_group_tag, elementary_name, mesh_size)
- entity_data = CablePartEntity(core_data, part)
- workspace.unassigned_entities[marker] = entity_data
-
- register_physical_group!(workspace, physical_group_tag, part.material_props)
-end
-
-"""
-Create a cable part entity for all tubular shapes. This function is specialized for `Tubular` parts.
-
-# Arguments
-
-- `workspace`: The [`FEMWorkspace`](@ref) containing the model parameters.
-- `part`: The [`Tubular`](@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::Tubular,
- 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
- r_in = to_nominal(part.r_in)
- r_ex = to_nominal(part.r_ex)
-
- # 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(r_in, r_ex, 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.r_in)
- next_radius_ext = to_nominal(next_part.r_ex)
- 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 r_in ≈ 0
- # Solid disk
- _, _, marker, _ =
- draw_disk(x_center, y_center, r_ex, mesh_size, num_points_circumference)
- else
- # Annular shape
- _, _, marker, _ = draw_annular(
- x_center,
- y_center,
- r_in,
- r_ex,
- mesh_size,
- num_points_circumference,
- )
-
- # Define the inner region as an insulator (air)
- air_material = get_air_material(workspace)
- air_material_id = get_or_register_material_id(workspace, air_material)
- air_physical_group_tag = encode_physical_group_tag(1, cable_idx, comp_idx, 2, air_material_id)
- air_elementary_name = create_cable_elementary_name(
- cable_idx=cable_idx, component_id=comp_id, group_type=2,
- part_type="tubular_inner_air", layer_idx=layer_idx, phase=phase
- )
-
- # Place a marker in the inner region
- inner_marker_x = x_center
- inner_marker_y = y_center
- inner_marker = [inner_marker_x, inner_marker_y, 0.0]
-
- core_data_air = CoreEntityData(air_physical_group_tag, air_elementary_name, mesh_size)
- entity_data_air = SurfaceEntity(core_data_air, air_material)
- workspace.unassigned_entities[inner_marker] = entity_data_air
- register_physical_group!(workspace, air_physical_group_tag, air_material)
-
- 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
diff --git a/src/engine/fem/drawing.jl b/src/engine/fem/drawing.jl
deleted file mode 100644
index 654d85a46..000000000
--- a/src/engine/fem/drawing.jl
+++ /dev/null
@@ -1,863 +0,0 @@
-"""
-Primitive drawing functions for the FEMTools.jl module.
-These functions handle the creation of geometric entities in Gmsh.
-"""
-
-function add_mesh_points(;
- r_ex::Number,
- theta_0::Number,
- theta_1::Number,
- mesh_size::Number,
- r_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 = r_ex # 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 = r_in + t_rad * (r_ex - r_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 = r_in + t_rad * (r_ex - r_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(
- r_in = -radius,
- r_ex = 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(
- r_in = radius,
- r_ex = 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\\].
-- `r_in`: Inner radius of the annular shape \\[m\\].
-- `r_ex`: 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,
- r_in::Number,
- r_ex::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, r_ex, r_ex)
-
- # Create inner disk
- inner_disk = gmsh.model.occ.add_disk(x, y, 0.0, r_in, r_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(
- r_in = r_ex,
- r_ex = r_ex,
- 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(
- r_in = r_in,
- r_ex = r_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 + (r_in + 0.99 * (r_ex - r_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 + r_ex * cos(theta_mid)
- mid_y = y + r_ex * 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(
- r_in = radii[1],
- r_ex = 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(
- r_in = radii[i],
- r_ex = 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 draw_polygon(vertices::Vector{<:Point})
- @debug "Drawing a polygon for a Point vertices"
- # Create points
- points = [gmsh.model.occ.add_point(v[1], v[2], 0.0) for v in vertices]
-
- # Create lines
- lines = [gmsh.model.occ.add_line(points[i], points[i % length(points) + 1]) for i in 1:length(points)]
-
- # Create curve loop and surface
- curve_loop = gmsh.model.occ.add_curve_loop(lines)
- surface_tag = gmsh.model.occ.add_plane_surface([curve_loop])
-
- # Synchronize to make the new entity available for calculations
- gmsh.model.occ.synchronize()
-
- # calculate the centroid of the vertices as a marker
- if isempty(vertices)
- error("Cannot calculate centroid of empty vertex list.")
- end
- avg_x = sum(v[1] for v in vertices) / length(vertices)
- avg_y = sum(v[2] for v in vertices) / length(vertices)
- marker = [avg_x, avg_y, 0.0]
-
- return surface_tag, marker
-end
-
-"""
-$(TYPEDSIGNATURES)
-
-Draw a polygon with a hole.
-
-# Arguments
-- `outer_vertices`: A vector of (x,y) coordinates for the outer boundary.
-- `inner_vertices`: A vector of (x,y) coordinates for the inner boundary (the hole).
-
-# Returns
-- A tuple containing the Gmsh surface tag and a marker point `[x, y, z]`.
-"""
-function _densify_vertices(vertices::Vector{<:Point}, max_len::Number)
- new_vertices = Point[]
- if isempty(vertices)
- return new_vertices
- end
- for i in 1:length(vertices)
- p1 = vertices[i]
- p2 = vertices[i % length(vertices) + 1]
-
- push!(new_vertices, p1)
-
- edge_vec = p2 - p1
- edge_len = norm(edge_vec)
-
- if edge_len > max_len
- num_segments = ceil(Int, edge_len / max_len)
- for j in 1:(num_segments - 1)
- intermediate_point = p1 + (j / num_segments) * edge_vec
- push!(new_vertices, intermediate_point)
- end
- end
- end
- return new_vertices
-end
-
-function draw_polygon_with_hole(outer_vertices::Vector{<:Point}, inner_vertices::Vector{<:Point}, max_edge_length::Number)
-
- new_outer_vertices = _densify_vertices(outer_vertices, max_edge_length)
- new_inner_vertices = _densify_vertices(inner_vertices, max_edge_length)
-
- # Create outer boundary
- outer_points = [gmsh.model.occ.add_point(v[1], v[2], 0.0) for v in new_outer_vertices]
- outer_lines = [gmsh.model.occ.add_line(outer_points[i], outer_points[i % length(outer_points) + 1]) for i in 1:length(outer_points)]
- outer_loop = gmsh.model.occ.add_curve_loop(outer_lines)
-
- # Create inner boundary (hole)
- inner_points = [gmsh.model.occ.add_point(v[1], v[2], 0.0) for v in new_inner_vertices]
- inner_lines = [gmsh.model.occ.add_line(inner_points[i], inner_points[i % length(inner_points) + 1]) for i in 1:length(inner_points)]
- inner_loop = gmsh.model.occ.add_curve_loop(inner_lines)
-
- # Create surface with hole
- surface_tag = gmsh.model.occ.add_plane_surface([outer_loop, inner_loop])
-
- # Synchronize to make the new entity available for calculations
- gmsh.model.occ.synchronize()
-
- # A marker point must be inside the insulator, but outside the conductor.
- # A point halfway between the inner and outer boundaries along one of the vertices should work.
- marker_point = (outer_vertices[1] + inner_vertices[1]) / 2
- marker = [marker_point[1], marker_point[2], 0.0]
-
- return surface_tag, marker
-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.r_ex
-
- insulator_radius_in = last_component.insulator_group.layers[end].r_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\\].
-- `r_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, r_in::Number)
- markers = Vector{Vector{Float64}}()
-
- lay_radius = r_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
-
-function draw_polygon(vertices::Vector{<:Point}, max_edge_length::Number)
- if isempty(vertices)
- error("Cannot draw a polygon with no vertices.")
- end
-
- new_vertices = _densify_vertices(vertices, max_edge_length)
-
- # Create points
- points = [gmsh.model.occ.add_point(v[1], v[2], 0.0) for v in new_vertices]
-
- # Create lines
- lines = [gmsh.model.occ.add_line(points[i], points[i % length(points) + 1]) for i in 1:length(points)]
-
- # Create curve loop and surface
- curve_loop = gmsh.model.occ.add_curve_loop(lines)
- surface_tag = gmsh.model.occ.add_plane_surface([curve_loop])
-
- # Synchronize to make the new entity available for calculations
- gmsh.model.occ.synchronize()
-
- # calculate the centroid of the original vertices as a marker
- if isempty(vertices)
- error("Cannot calculate centroid of empty vertex list.")
- end
- avg_x = sum(v[1] for v in vertices) / length(vertices)
- avg_y = sum(v[2] for v in vertices) / length(vertices)
- marker = [avg_x, avg_y, 0.0]
-
- return surface_tag, marker
-end
-
-
diff --git a/src/engine/fem/encoding.jl b/src/engine/fem/encoding.jl
deleted file mode 100644
index 93da41c08..000000000
--- a/src/engine/fem/encoding.jl
+++ /dev/null
@@ -1,485 +0,0 @@
-"""
-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(circstrands) # 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
diff --git a/src/engine/fem/helpers.jl b/src/engine/fem/helpers.jl
deleted file mode 100644
index 0de6a980f..000000000
--- a/src/engine/fem/helpers.jl
+++ /dev/null
@@ -1,315 +0,0 @@
-"""
-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.")
-end
\ No newline at end of file
diff --git a/src/engine/fem/identification.jl b/src/engine/fem/identification.jl
deleted file mode 100644
index b5c61b060..000000000
--- a/src/engine/fem/identification.jl
+++ /dev/null
@@ -1,215 +0,0 @@
-"""
-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
diff --git a/src/engine/fem/lineparamopts.jl b/src/engine/fem/lineparamopts.jl
deleted file mode 100644
index e8eba978f..000000000
--- a/src/engine/fem/lineparamopts.jl
+++ /dev/null
@@ -1,34 +0,0 @@
-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
deleted file mode 100644
index 9599143bc..000000000
--- a/src/engine/fem/materialprops.jl
+++ /dev/null
@@ -1,127 +0,0 @@
-"""
-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
diff --git a/src/engine/fem/mesh.jl b/src/engine/fem/mesh.jl
deleted file mode 100644
index e621fec3e..000000000
--- a/src/engine/fem/mesh.jl
+++ /dev/null
@@ -1,351 +0,0 @@
-"""
-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
- r_in = to_nominal(part.r_in)
- r_ex = to_nominal(part.r_ex)
- thickness = r_ex - r_in
-
- # Extract formulation parameters
- formulation = workspace.formulation
-
- # Calculate mesh size based on part type and properties
- scale_length = thickness
- if part isa CircStrands
- # 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(
- r_in::Number,
- r_ex::Number,
- material::Material,
- num_elements::Int,
- workspace::FEMWorkspace,
-)
- # Extract geometric properties
- thickness = r_ex - r_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
-end
diff --git a/src/engine/fem/meshtransitions.jl b/src/engine/fem/meshtransitions.jl
deleted file mode 100644
index 1ef9bed8c..000000000
--- a/src/engine/fem/meshtransitions.jl
+++ /dev/null
@@ -1,108 +0,0 @@
-"""
-$(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
diff --git a/src/engine/fem/problemdefs.jl b/src/engine/fem/problemdefs.jl
deleted file mode 100644
index 70b6022f3..000000000
--- a/src/engine/fem/problemdefs.jl
+++ /dev/null
@@ -1,190 +0,0 @@
-
-# @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
diff --git a/src/engine/fem/solver.jl b/src/engine/fem/solver.jl
deleted file mode 100644
index baed15ab4..000000000
--- a/src/engine/fem/solver.jl
+++ /dev/null
@@ -1,872 +0,0 @@
-
-function make_fem_problem!(
- fem_formulation::Union{AbstractImpedanceFormulation, AbstractAdmittanceFormulation},
- frequency::Float64,
- workspace::FEMWorkspace,
-)
-
- 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)
-end
-
-function define_jacobian!(problem::GetDP.Problem, workspace::FEMWorkspace)
- # Initialize Jacobian
- jac = Jacobian()
-
- Rint = workspace.formulation.domain_radius
- Rext = workspace.formulation.domain_radius_inf
-
- # Add Vol Jacobian
- vol = add!(jac, "Vol")
- add!(vol;
- Region = "DomainInf",
- Jacobian = VolSphShell(
- Rint = Rint,
- Rext = Rext,
- center_X = 0.0,
- center_Y = 0.0,
- center_Z = 0.0,
- ),
- )
- add!(vol; Region = "All", Jacobian = "Vol")
-
- # Add Sur Jacobian
- sur = add!(jac, "Sur")
- add!(sur;
- Region = "All",
- Jacobian = "Sur",
- )
-
- # Add Jacobian to problem
- problem.jacobian = jac
-end
-
-function define_integration!(problem::GetDP.Problem)
- # Initialize Integration
- integ = Integration()
- i1 = add!(integ, "I1")
- case = add!(i1)
- geo_case = add_nested_case!(case; type = "Gauss")
- add!(geo_case; GeoElement = "Point", NumberOfPoints = 1)
- add!(geo_case; GeoElement = "Line", NumberOfPoints = 4)
- add!(geo_case; GeoElement = "Triangle", NumberOfPoints = 4)
- add!(geo_case; GeoElement = "Quadrangle", NumberOfPoints = 4)
- problem.integration = integ
-
-end
-
-function define_material_props!(problem::GetDP.Problem, workspace::FEMWorkspace)
- # Create material properties function
- func = GetDP.Function()
-
- for (tag, mat) in workspace.physical_groups
- if tag > 10^8
- # Add material properties for this region
- add_comment!(
- func,
- "Material properties for region $(tag): $(create_physical_group_name(workspace, tag))",
- false,
- )
- add_space!(func)
- add!(func, "nu", expression = 1 / (mat.mu_r * μ₀), region = [tag])
- add!(
- func,
- "sigma",
- expression = isinf(mat.rho) ? 0.0 : 1 / mat.rho,
- region = [tag],
- )
- add!(func, "epsilon", expression = mat.eps_r * ε₀, region = [tag])
- end
- end
-
- push!(problem.function_obj, func)
-end
-
-function define_constants!(
- 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
-
-function define_domain_groups!(
- problem::GetDP.Problem,
- fem_formulation::Union{AbstractImpedanceFormulation, AbstractAdmittanceFormulation},
- 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[]
- add_raw_code!(problem,
- """
- DefineConstant[
- active_con = {1, Choices{1,9999}, Name "Input/Active conductor", Visible 1}];
- """)
- 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
- 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"),
- ("DomainActive", ["Con~{active_con}"], "Sources"),
- (
- "DomainInactive",
- ["Conductors - Con~{active_con}"],
- "Conductors set to zero energization",
- ),
- ]
-
- 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 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")
- add!(group, "Sur_Dirichlet_Mag", boundary_reg, "Region")
- end
-
- problem.group = group
-end
-
-function define_constraint!(
- problem::GetDP.Problem,
- fem_formulation::Union{AbstractImpedanceFormulation, AbstractAdmittanceFormulation},
- workspace::FEMWorkspace,
-)
- constraint = GetDP.Constraint()
-
- # num_cores = workspace.problem_def.system.num_cables
-
- if fem_formulation isa AbstractAdmittanceFormulation
- # ScalarPotential_2D
- esp = assign!(constraint, "ScalarPotential_2D")
- case!(esp, "DomainInactive", value = "0.0")
- case!(esp, "Con~{active_con}", value = "UnitAmplitude")
- case!(esp, "Sur_Dirichlet_Ele", value = "0.0")
-
- charge = assign!(constraint, "Charge_2D")
- else
- # 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")
-
- case!(current, "DomainInactive", value = "0.0")
- case!(current, "Con~{active_con}", value = "UnitAmplitude")
- end
-
- problem.constraint = constraint
-
-end
-
-function define_resolution!(
- problem::GetDP.Problem,
- formulation::Electrodynamics,
- workspace::FEMWorkspace,
-)
- resolution_name = formulation.resolution_name
- num_sources = workspace.problem_def.system.num_cables
-
- # FunctionSpace section
- functionspace = FunctionSpace()
- fs1 = add!(functionspace, "Hgrad_v_Ele", nothing, nothing, Type = "Form0")
- add_basis_function!(
- functionspace,
- "sn",
- "vn",
- "BF_Node";
- Support = "Domain_Ele",
- Entity = "NodesOf[ All, Not Conductors ]",
- )
- add_basis_function!(
- functionspace,
- "sf",
- "vf",
- "BF_GroupOfNodes";
- Support = "Domain_Ele",
- Entity = "GroupsOfNodesOf[ Conductors ]",
- )
- add_global_quantity!(functionspace, "U", "AliasOf"; NameOfCoef = "vf")
- add_global_quantity!(functionspace, "Q", "AssociatedWith"; NameOfCoef = "vf")
- add_constraint!(functionspace, "U", "Region", "ScalarPotential_2D")
- add_constraint!(functionspace, "Q", "Region", "Charge_2D")
- add_constraint!(functionspace, "vn", "NodesOf", "ScalarPotential_2D")
-
- problem.functionspace = functionspace
-
- # Formulation section
- formulation = Formulation()
- form = add!(formulation, "Electrodynamics_v", "FemEquation")
- add_quantity!(form, "v", Type = "Local", NameOfSpace = "Hgrad_v_Ele")
- add_quantity!(form, "U", Type = "Global", NameOfSpace = "Hgrad_v_Ele [U]")
- add_quantity!(form, "Q", Type = "Global", NameOfSpace = "Hgrad_v_Ele [Q]")
-
- eq = add_equation!(form)
- add!(
- eq,
- "Galerkin",
- "[ sigma[] * Dof{d v} , {d v} ]",
- In = "Domain_Ele",
- Jacobian = "Vol",
- Integration = "I1",
- )
- add!(
- eq,
- "Galerkin",
- "DtDof[ epsilon[] * Dof{d v} , {d v} ]",
- In = "DomainCC",
- Jacobian = "Vol",
- Integration = "I1",
- ) #CHECKME
- add!(eq, "GlobalTerm", "[ Dof{Q} , {U} ]", In = "Conductors")
-
- problem.formulation = formulation
-
- # Resolution section
- 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",
- Operation = [
- "CreateDir[\"$(output_dir)\"]",
- "Generate[Sys_Ele]",
- "Solve[Sys_Ele]",
- "SaveSolution[Sys_Ele]",
- "PostOperation[LineParams]",
- ])
-
- problem.resolution = resolution
-
- # PostProcessing section
- postprocessing = PostProcessing()
- pp = add!(postprocessing, "EleDyn_v", "Electrodynamics_v")
-
- # Add field maps quantities
- for (name, expr, options) in [
- ("v", "{v}", Dict()),
- ("e", "-{d v}", Dict()),
- ("em", "Norm[-{d v}]", Dict()),
- ("d", "-epsilon[] * {d v}", Dict()),
- ("dm", "Norm[-epsilon[] * {d v}]", Dict()),
- ("j", "-sigma[] * {d v}", Dict()),
- ("jm", "Norm[-sigma[] * {d v}]", Dict()),
- ]
- q = add!(pp, name)
- add!(q, "Term", expr; In = "Domain_Ele", Jacobian = "Vol", options...)
- end
-
- # Add jtot (combination of j and d)
- q = add!(pp, "jtot")
- add!(
- q,
- "Term",
- "-sigma[] * {d v}";
- Type = "Global",
- In = "Domain_Ele",
- Jacobian = "Vol",
- )
- add!(
- q,
- "Term",
- "-epsilon[] * Dt[{d v}]";
- Type = "Global",
- In = "Domain_Ele",
- Jacobian = "Vol",
- )
-
- q = add!(pp, "U")
- add!(q, "Term", "{U}"; In = "Domain_Ele")
-
- q = add!(pp, "Q")
- add!(q, "Term", "{Q}"; In = "Domain_Ele")
-
- q = add!(pp, "Y")
- add!(q, "Term", "-{Q}"; In = "Domain_Ele")
-
- problem.postprocessing = postprocessing
-
- # PostOperation section
- postoperation = PostOperation()
-
- # Field_Maps
- po1 = add!(postoperation, "Field_Maps", "EleDyn_v")
- op1 = add_operation!(po1)
- add_operation!(
- op1,
- "Print[ v, OnElementsOf Domain_Ele, File StrCat[ \"$(joinpath(output_dir,"v_"))\", Sprintf(\"%g\",active_con), \".pos\" ] ];",
- )
- add_operation!(
- op1,
- "Print[ em, OnElementsOf Domain_Ele, Name \"|E| [V/m]\", File StrCat[ \"$(joinpath(output_dir,"em_"))\", Sprintf(\"%g\",active_con), \".pos\" ] ];",
- )
- add_operation!(
- op1,
- "Print[ dm, OnElementsOf Domain_Ele, Name \"|D| [A/m²]\", File StrCat[ \"$(joinpath(output_dir,"dm_"))\", Sprintf(\"%g\",active_con), \".pos\" ] ];",
- )
- add_operation!(
- op1,
- "Print[ e, OnElementsOf Domain_Ele, Name \"E [V/m]\", File StrCat[ \"$(joinpath(output_dir,"e_"))\", Sprintf(\"%g\",active_con), \".pos\" ] ];",
- )
-
- # LineParams
- po2 = add!(postoperation, "LineParams", "EleDyn_v")
- op2 = add_operation!(po2)
- add_operation!(
- op2,
- "Print[ Y, OnRegion Conductors, Format Table, File \"$(joinpath(output_dir,"Y.dat"))\", AppendToExistingFile (active_con > 1 ? 1 : 0) ];",
- )
-
- problem.postoperation = postoperation
-
-end
-
-function define_resolution!(
- problem::GetDP.Problem,
- formulation::Darwin,
- workspace::FEMWorkspace,
-)
-
- 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")
-
- 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, "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[] * Dof{a} , {a} ]",
- In = "DomainC",
- Jacobian = "Vol",
- Integration = "I1",
- )
- add!(
- eq,
- "Galerkin",
- "[ sigma[] * Dof{ur}, {a} ]",
- In = "DomainC",
- Jacobian = "Vol",
- Integration = "I1",
- )
- add!(
- eq,
- "Galerkin",
- "DtDof [ sigma[] * Dof{a} , {ur} ]",
- In = "DomainC",
- Jacobian = "Vol",
- Integration = "I1",
- )
- add!(
- eq,
- "Galerkin",
- "[ sigma[] * 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
-
- # Add the formulation to the problem
- problem.formulation = formulation
-
- # Define Resolution
- resolution = Resolution()
-
- # Add a resolution
- 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",
- Operation = [
- "CreateDir[\"$(output_dir)\"]",
- "InitSolution[Sys_Mag]",
- "Generate[Sys_Mag]",
- "Solve[Sys_Mag]",
- "SaveSolution[Sys_Mag]",
- "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!(pp, "a")
- add!(q, "Term", "{a}"; In = "Domain_Mag", Jacobian = "Vol")
- q = add!(pp, "az")
- add!(q, "Term", "CompZ[{a}]"; In = "Domain_Mag", Jacobian = "Vol")
- q = add!(pp, "b")
- add!(q, "Term", "{d a}"; In = "Domain_Mag", Jacobian = "Vol")
- q = add!(pp, "bm")
- add!(q, "Term", "Norm[{d a}]"; In = "Domain_Mag", Jacobian = "Vol")
- q = add!(pp, "j")
- add!(q, "Term", "-sigma[]*(Dt[{a}]+{ur})"; In = "DomainC", Jacobian = "Vol")
- q = add!(pp, "jz")
- add!(q, "Term", "CompZ[-sigma[]*(Dt[{a}]+{ur})]"; In = "DomainC", Jacobian = "Vol")
- q = add!(pp, "jm")
- add!(q, "Term", "Norm[-sigma[]*(Dt[{a}]+{ur})]"; In = "DomainC", Jacobian = "Vol")
- q = add!(pp, "d")
- add!(q, "Term", "epsilon[] * Dt[Dt[{a}]+{ur}]"; In = "DomainC", Jacobian = "Vol")
- q = add!(pp, "dz")
- add!(q, "Term", "CompZ[epsilon[] * Dt[Dt[{a}]+{ur}]]"; In = "DomainC", Jacobian = "Vol")
- q = add!(pp, "dm")
- add!(q, "Term", "Norm[epsilon[] * Dt[Dt[{a}]+{ur}]]"; In = "DomainC", Jacobian = "Vol")
- q = add!(pp, "rhoj2")
- add!(q, "Term", "0.5*sigma[]*SquNorm[Dt[{a}]+{ur}]"; In = "DomainC", Jacobian = "Vol")
-
- q = add!(pp, "U")
- add!(q, "Term", "{U}"; In = "DomainC")
- q = add!(pp, "I")
- add!(q, "Term", "{I}"; In = "DomainC")
- q = add!(pp, "Z")
- add!(q, "Term", "-{U}"; In = "DomainC")
-
- problem.postprocessing = postprocessing
-
- # PostOperation section
- postoperation = PostOperation()
-
- # Add post-operation items
- po1 = add!(postoperation, "Field_Maps", "Darwin_a_2D")
- op1 = add_operation!(po1)
-
- add_operation!(
- op1,
- "Print[ az, OnElementsOf Domain_Mag, Smoothing 1, Name \"flux lines: Az [T m]\", File StrCat[ \"$(joinpath(output_dir,"az_"))\", Sprintf(\"%g\",active_con), \".pos\" ] ];",
- )
- add_operation!(
- op1,
- "Print[ b, OnElementsOf Domain_Mag, Smoothing 1, Name \"B [T]\", File StrCat[ \"$(joinpath(output_dir,"b_"))\", Sprintf(\"%g\",active_con), \".pos\" ] ];",
- )
- add_operation!(
- op1,
- "Print[ bm, OnElementsOf Domain_Mag, Smoothing 1, Name \"|B| [T]\", File StrCat[ \"$(joinpath(output_dir,"bm_"))\", Sprintf(\"%g\",active_con), \".pos\" ] ];",
- )
- add_operation!(
- op1,
- "Print[ jz, OnElementsOf Region[{DomainC}], Smoothing 1, Name \"jz [A/m²] Conducting domain\", File StrCat[ \"$(joinpath(output_dir,"jz_"))\", Sprintf(\"%g\",active_con), \".pos\" ] ];",
- )
- add_operation!(
- op1,
- "Print[ rhoj2, OnElementsOf Region[{DomainC}], Smoothing 1, Name \"Power density\", File StrCat[ \"$(joinpath(output_dir,"rhoj2_"))\", Sprintf(\"%g\",active_con), \".pos\" ] ];",
- )
- add_operation!(
- op1,
- "Print[ jm, OnElementsOf DomainC, Smoothing 1, Name \"|j| [A/m²] Conducting domain\", File StrCat[ \"$(joinpath(output_dir,"jm_"))\", Sprintf(\"%g\",active_con), \".pos\" ] ];",
- )
- add_operation!(
- op1,
- "Print[ dm, OnElementsOf DomainC, Smoothing 1, Name \"|D| [A/m²]\", File StrCat[ \"$(joinpath(output_dir,"dm_"))\", Sprintf(\"%g\",active_con), \".pos\" ] ];",
- )
-
- po2 = add!(postoperation, "LineParams", "Darwin_a_2D")
- op2 = add_operation!(po2)
- 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::AbstractFormulationSet)
- # 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.problem_def.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)
-
- # 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)"
-
- # Log the current solve attempt
- @info "Solving for source conductor $i... (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 for source conductor $i... (Resolution = $(fem_formulation.resolution_name))"
-
- 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)"
-
- gmsh.onelab.run("GetDP", post_cmd)
- end
-
- @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
-
- # Return true only if all solves were successful
- return all_success
-end
-
-using LinearAlgebra: BLAS, BlasFloat
-
-function run_solver!(workspace::FEMWorkspace)
- problem = workspace.problem_def
- formulation = workspace.formulation
-
- 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)
-
-
- # --- 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
- return dest
- end
-
- # --- big loop ---
- 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)
-
- # REORDER → Z
- _reorder_into!(Zbuf, workspace.Z, perm, k)
- # symtrans!(Zbuf)
-
- # 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)
- # symtrans!(Ybuf)
-
- # 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.opts.keep_run_files
- archive_frequency_results(workspace, frequency)
- end
- end
-
- lp = LineParameters(PhaseDomain, Zr, Yr, workspace.freq)
-
- return lp
-end
-
-
-function _do_run_solver!(freq_idx::Int,
- workspace::FEMWorkspace) # 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
-
- # 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)
-end
-
-"""
-$(TYPEDSIGNATURES)
-
-Main function to run the FEM simulation workflow for a cable system.
-
-# Arguments
-
-- `cable_system`: Cable system to simulate.
-- `formulation`: Problem definition parameters.
-- `solver`: Solver parameters.
-- `frequency`: Simulation frequency \\[Hz\\]. Default: 50.0.
-
-# Returns
-
-- A [`FEMWorkspace`](@ref) instance with the simulation results.
-
-# Examples
-
-```julia
-# Run a FEM simulation
-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).
- if make_mesh!(workspace)
- return workspace, nothing
- end
-
- # Solving phase - always runs unless mesh_only
- @info "Starting FEM solver"
- ZY = run_solver!(workspace)
-
- @info "FEM computation completed successfully"
- return workspace, ZY
-end
-
diff --git a/src/engine/fem/space.jl b/src/engine/fem/space.jl
deleted file mode 100644
index 51b771ea9..000000000
--- a/src/engine/fem/space.jl
+++ /dev/null
@@ -1,367 +0,0 @@
-"""
-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
diff --git a/src/engine/fem/types.jl b/src/engine/fem/types.jl
deleted file mode 100644
index 1710f6e3c..000000000
--- a/src/engine/fem/types.jl
+++ /dev/null
@@ -1,141 +0,0 @@
-
-"""
-$(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
diff --git a/src/engine/fem/visualization.jl b/src/engine/fem/visualization.jl
deleted file mode 100644
index b884e4030..000000000
--- a/src/engine/fem/visualization.jl
+++ /dev/null
@@ -1,160 +0,0 @@
-"""
-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
-end
\ No newline at end of file
diff --git a/src/engine/fem/workspace.jl b/src/engine/fem/workspace.jl
deleted file mode 100644
index eaf54bd4e..000000000
--- a/src/engine/fem/workspace.jl
+++ /dev/null
@@ -1,282 +0,0 @@
-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.r_in)
- r_ext[idx] = to_nominal(component.conductor_group.r_ex)
- r_ins_in[idx] = to_nominal(component.insulator_group.r_in)
- r_ins_ext[idx] = to_nominal(component.insulator_group.r_ex)
-
- # 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
diff --git a/src/engine/formulations.jl b/src/engine/formulations.jl
new file mode 100644
index 000000000..526bce7ce
--- /dev/null
+++ b/src/engine/formulations.jl
@@ -0,0 +1,630 @@
+# Engine-owned formulation hierarchy.
+"""
+$(TYPEDEF)
+
+Select the LineCableModels backend for concentric coaxial cable assemblies.
+
+Series impedance uses the equivalent concentric representation supplied by
+DataModel. The default local shunt model uses the equivalent annular layer. Explicit
+`shunt_model=:boundary` resolves eligible open wires and finite tapes in a
+lossless, radially layered circular shielded domain during blueprint
+construction. Other geometry and material selections retain their
+equivalent-coaxial treatment.
+"""
+struct LineCableModelsCoaxial end
+
+"""Identify the coaxial backend without executing or configuring a computation."""
+description(::Type{LineCableModelsCoaxial}; compact::Bool = false) = "coaxial"
+function description(::LineCableModelsCoaxial; compact::Bool = false)
+ description(LineCableModelsCoaxial; compact)
+end
+formula_id(::Type{LineCableModelsCoaxial}) = :coaxial
+formula_id(::LineCableModelsCoaxial) = :coaxial
+
+"""
+$(TYPEDEF)
+
+Select the Julia-native Gmsh/GetDP finite-element backend.
+
+`options` stores the field-model choice and matrix reductions. Set
+`options=(physics=:quasi_tem,)` (default) or `(physics=:quasi_fw,)`.
+Execution controls belong to `compute(...; options=(...))` and are validated
+by [`computation_options`](@ref) for `LineCableModelsFEM`.
+
+The quasi-TEM model solves independent axial ``A_z/u`` and scalar electric
+Helmholtz blocks in one factorization. The magnetic excitation is one ampere.
+The electric excitation is one ampere per meter. Both models retain conduction
+and displacement through ``κ=σ+jωε`` \\[S/m\\], where ``ω`` is angular
+frequency \\[rad/s\\], ``σ`` conductivity \\[S/m\\], and ``ε`` permittivity \\[F/m\\].
+
+The quasi-full-wave model uses phasors ``e^{jωt-Γz}`` and expands
+``A_z=a``, ``A_t=Γb``, ``φ=Γv`` before taking ``Γ→0``. Here ``Γ`` is
+the longitudinal propagation constant \\[1/m\\], ``a`` has units \\[T m\\],
+``b`` \\[T m²\\], and ``v`` \\[V m\\]. In the media outside the equipotential
+metal terminals, the retained equations are
+
+```math
+-\\nabla_t\\!\\cdot(\\nu\\nabla_t a)+j\\omega\\kappa a=0,
+\\qquad
+C^*(\\nu Cb)+j\\omega\\kappa b+\\kappa\\nabla_t v-\\nu\\nabla_t a=0,
+\\qquad
+-\\nabla_t\\!\\cdot[\\kappa(j\\omega b+\\nabla_t v)]+j\\omega\\kappa a=0.
+```
+
+Here ``\\nu=1/\\mu`` \\[m/H\\], ``μ`` is permeability \\[H/m\\],
+``Cb=∂_x b_y-∂_y b_x`` and ``C^*h=(∂_y h,-∂_x h)``.
+The finite-conductivity axial ``a/u`` block supplies both the series voltage
+drop and the normalized transverse-current source. There is no independent
+electric excitation. A tree gauge removes the gradient freedom of ``b``.
+Its tangential trace vanishes on every terminal contour and the entire outer
+boundary. ``v=0`` on the earth-side outer reference, with natural electric
+conditions on the air side.
+
+Terms of order ``Γ^2`` in the axial equation are omitted. The transverse
+Ampère equation remains at the order used to extract shunt response: continuity
+alone constrains a divergence and cannot supply that vector balance across a
+material interface. Choosing a smaller numerical ``Γ`` cannot restore it.
+
+Terminal voltage includes the vector potential. For a path ``ℓ_i`` oriented
+from the earth reference to terminal ``i``, the inverse-admittance matrix is
+
+```math
+P_{ij}=\\frac{v_i-v_{\\rm ref}+j\\omega\\int_{\\ell_i}b\\cdot d\\ell}{I_j},
+\\qquad Y=P^{-1},\\qquad P_e=j\\omega P.
+```
+
+``I_j`` is the imposed axial current \\[A\\], ``P`` has units \\[Ω m\\],
+``Y`` \\[S/m\\], and the analytical potential coefficient ``P_e`` \\[m/F\\].
+The backend uses physical vertical paths to the lowest mesh node of each
+terminal contour, with zero transverse field inside equipotential metal.
+It integrates the pulled-back edge field through the infinite shell.
+The scalar trace ``v_i/I_j`` alone is gauge dependent and is saved separately
+as `Pscalar.tsv` diagnostics. Series impedance is ``Z_{ij}=-U_i/I_j`` \\[Ω/m\\],
+where ``U_i`` is the axial electric unknown \\[V/m\\].
+
+This is a two-dimensional first-order reduction of Maxwell's potential
+equations, retaining transverse induction and displacement. It does not solve
+for a finite propagation constant or constitute the Darwin approximation.
+The full-vector potential equations and the role of terminal conditions and
+gauging are described by G. Ciuprina and R. V. Sabriego, *Electric circuit
+element boundary conditions for electromagneto-quasistatic and full wave
+models in A, φ potentials and their finite element implementation*, Journal
+of Mathematics in Industry **14**, 27 (2024),
+[doi:10.1186/s13362-024-00165-6](https://doi.org/10.1186/s13362-024-00165-6),
+Sect. 4 and Appendix B. The longitudinal reduction and vertical voltage-path
+convention above are specific to this backend. The paper validates 3D models.
+
+$(TYPEDFIELDS)
+"""
+struct LineCableModelsFEM{M <: NamedTuple, O <: FormulationOptions, D <: NamedTuple} <:
+ AbstractFormulation
+ "Shared scientific formula selections, independent of FEM execution controls."
+ methods::M
+ "Field model and line-parameter matrix reductions."
+ options::O
+ "Requested formula definitions."
+ definitions::D
+end
+
+"""Identify the FEM backend without loading meshing or solver packages."""
+description(::Type{<:LineCableModelsFEM}; compact::Bool = false) = "FEM"
+function description(::LineCableModelsFEM; compact::Bool = false)
+ description(LineCableModelsFEM; compact)
+end
+formula_id(::Type{<:LineCableModelsFEM}) = :fem
+formula_id(::LineCableModelsFEM) = :fem
+formulation_options(value::LineCableModelsFEM) = value.options
+function Base.pairs(value::LineCableModelsFEM; quantity = nothing)
+ pairs(LineCableModelsFEM,
+ (methods = value.methods,
+ requested = value.definitions,
+ options = value.options.data);
+ quantity)
+end
+
+"""FEM's coupled field equations retain all four constitutive selections."""
+function Base.pairs(::Type{LineCableModelsFEM}; quantity = nothing)
+ return pairs((insulation_admittance = InsulationAdmittance.Formula,
+ semicon_admittance = SemiconAdmittance.Formula,
+ earth_properties = Earth.FrequencyDependent.Formula,
+ temperature_dependence = TemperatureDependent.Formula))
+end
+function description(::Type{LineCableModelsFEM}, slot::Val; compact::Bool=false)
+ description(LineParametersFormulation, slot; compact)
+end
+function Base.pairs(::Type{LineCableModelsFEM}, retained::NamedTuple; quantity = nothing)
+ pairs(LineParametersFormulation, retained; quantity, owner = LineCableModelsFEM)
+end
+
+"""
+$(TYPEDEF)
+
+Report a failure in finite-element model construction, meshing, solving, or result validation.
+
+$(TYPEDFIELDS)
+"""
+struct LineCableModelsFEMError <: Exception
+ "Failure category."
+ category::Symbol
+ "Stable identifier of the object associated with the failure."
+ object_id::String
+ "Field or derived datum that failed validation."
+ field::Symbol
+ "Human-readable failure description."
+ message::String
+ "Retained run directory, or `nothing` before run creation."
+ run_directory::Union{Nothing, String}
+end
+
+function LineCableModelsFEMError(
+ category::Symbol,
+ object_id,
+ field::Symbol,
+ message::AbstractString;
+ run_directory::Union{Nothing, AbstractString} = nothing
+)
+ path = run_directory === nothing ? nothing : String(run_directory)
+ return LineCableModelsFEMError(
+ category, String(object_id), field, String(message), path
+ )
+end
+
+function Base.showerror(io::IO, error::LineCableModelsFEMError)
+ print(
+ io,
+ "LineCableModelsFEMError(",
+ error.category,
+ ", object=",
+ repr(error.object_id),
+ ", field=:",
+ error.field,
+ "): ",
+ error.message
+ )
+ error.run_directory === nothing || print(
+ io, "; retained run directory: ", error.run_directory
+ )
+end
+
+"""
+$(TYPEDEF)
+
+Supertype for Engine impedance formulations.
+"""
+abstract type AbstractImpedanceFormulation <: AbstractFormulation end
+"""
+Select equations for the surface impedance of conductors [Ω/m]. Concrete subtypes build, in a
+`Functor` method, the values that inner, outer and transfer surface impedances share, from
+the conductor dimensions and material properties. They implement
+`InternalImpedance.internal_impedance` for their supported `inner`, `outer`, and `transfer`
+cases.
+"""
+abstract type InternalImpedanceFormulation <: AbstractImpedanceFormulation end
+"""
+Select the pipe contribution and its backend and topology applicability. Concrete
+subtypes extend `validate(design, selected, backend)`, which admits the topology of
+`design` or throws. An impedance equation must be supplied separately from admission.
+"""
+abstract type PipeImpedanceFormulation <: AbstractImpedanceFormulation end
+"""
+Select magnetic impedance across an insulation annulus [Ω/m]. Concrete subtypes
+implement `InsulationImpedance.insulation_impedance` on their selected type.
+"""
+abstract type InsulationImpedanceFormulation <: AbstractImpedanceFormulation end
+"""
+Select earth-return impedance equations [Ω/m]. Concrete subtypes implement
+indexed `EarthImpedance.earth_impedance` methods on evaluated material inputs.
+Air, earth, and mixed selections refer to conductor locations.
+"""
+abstract type EarthImpedanceFormulation <: AbstractImpedanceFormulation end
+
+"""Supertype for local shunt geometry and dielectric and earth admittance selections."""
+abstract type AbstractAdmittanceFormulation <: AbstractFormulation end
+"""
+Select cable-local shunt geometry, independently of material admittivity.
+Concrete subtypes implement `internal_shunt_response` during blueprint construction.
+"""
+abstract type ShuntModelFormulation <: AbstractAdmittanceFormulation end
+"""
+Select insulation admittivity [S/m]. Concrete subtypes implement
+`InsulationAdmittance.insulation_material`. Radial geometry is applied separately.
+"""
+abstract type InsulationAdmittanceFormulation <: AbstractAdmittanceFormulation end
+"""
+Select semiconducting-layer admittivity [S/m]. Concrete subtypes implement
+`SemiconAdmittance.semicon_material`. Radial geometry is applied separately.
+"""
+abstract type SemiconAdmittanceFormulation <: AbstractAdmittanceFormulation end
+"""
+Select earth potential-coefficient equations [m/F]. Concrete subtypes implement
+indexed `EarthAdmittance.earth_potential_coefficient` methods on evaluated
+material inputs. Matrix assembly converts
+the potential coefficients to shunt admittance [S/m].
+"""
+abstract type EarthAdmittanceFormulation <: AbstractAdmittanceFormulation end
+
+"""
+Validate the admittivity [S/m] that a dielectric law returns, before radial aggregation
+represents it as `Complex{T}`. A result requiring a wider scalar type than `T` is rejected
+instead of silently discarding precision or uncertainty. Return `value`.
+"""
+function validate(value, ::Union{InsulationAdmittanceFormulation, SemiconAdmittanceFormulation},
+ ::Type{T}) where {T <: Real}
+ value isa Number && !(value isa Bool) && isfinite(value) || throw(DomainError(
+ value, "a dielectric material law must return a finite scalar admittivity [S/m]"))
+ promote_type(T, typeof(real(value))) === T || throw(ArgumentError(
+ "dielectric admittivity requires scalar type $(typeof(real(value))); " *
+ "use a material and problem scalar type that preserves its precision and uncertainty"))
+ return value
+end
+
+# The expression of an earth formula for one interaction: its kind and its source and target
+# layers.
+function Expression(formula::EarthImpedanceFormulation, pair::EarthPair)
+ return Expression(formula, EarthImpedance.earth_impedance,
+ Val(pair.row == pair.column ? :self : :mutual), Val.(layer_index(pair))...)
+end
+function Expression(formula::EarthAdmittanceFormulation, pair::EarthPair)
+ return Expression(formula, EarthAdmittance.earth_potential_coefficient,
+ Val(pair.row == pair.column ? :self : :mutual), Val.(layer_index(pair))...)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the expression of an earth recipe for `pair`: the `air` formula for layers (1, 1), the
+`earth` formula for (2, 2) and the `mixed` formula for (1, 2) and (2, 1). The pair's layers
+are the decided ones: a recipe describes a homogeneous earth.
+
+# Errors
+
+- Throws `ArgumentError` when the recipe has no formula for the pair's route, or for another
+ layer pair.
+"""
+function Expression(recipe::NamedTuple, pair::EarthPair)
+ source, target = layer_index(pair)
+ route = (source, target) == (1, 1) ? :air : (source, target) == (2, 2) ? :earth :
+ (source, target) in ((1, 2), (2, 1)) ? :mixed :
+ throw(ArgumentError("homogeneous selection is not defined for source in layer " *
+ "$source and target in layer $target"))
+ selected = get(recipe, route, nothing)
+ selected === nothing &&
+ throw(ArgumentError("explicit equation recipe has no requested :$route case"))
+ return Expression(selected, pair)
+end
+
+# Equation-specific geometric restrictions extend the existing validation protocol.
+validate(pair::EarthPair, ::Expression) = pair
+
+# Whether the formula of `expression` admits earth layer `k`. It does when a method of its
+# operation accepts `Val{k}` in the source or the target position, with the `Functor` and the
+# workspace that the evaluation passes, whatever the types of the other arguments. A layer
+# left generic, such as `::Val{S}`, admits every layer. The earth decision, the error message
+# of `validate(expression, layers)` and the standalone formula call read the layers here.
+function _admits_layer(expression::Expression, k::Int)
+ F = typeof(expression.selection)
+ return !isempty(methods(expression.method, Tuple{F, Any, Val{k}, Any, Functor, Any})) ||
+ !isempty(methods(expression.method, Tuple{F, Any, Any, Val{k}, Functor, Any}))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Check that the formula of `expression` defines it for an earth with one entry of `layers` per
+medium, before evaluating it: the layers of an earth model, or the resistivities of the
+standalone formula call. The signatures of a formula's methods declare the earth layers it
+handles, whatever the types of their runtime arguments. In layers that the formula admits,
+`validate(expression)` checks the expression. Return `expression`.
+
+# Errors
+
+- Throws `ArgumentError` with the layer count N and the highest layer up to N that the
+ formula admits, when the formula does not admit the source or target layer.
+- Throws the `ArgumentError` of `validate(expression)` for a missing expression in layers
+ that the formula admits.
+
+When the formula admits layer N + 1, the message reads "defined beyond layer N" instead.
+"""
+function validate(expression::Expression{<:Union{EarthImpedanceFormulation, EarthAdmittanceFormulation}},
+ layers::Union{Tuple, AbstractVector})
+ selected = expression.selection
+ signature = Tuple{typeof(selected), map(typeof, expression.arguments)..., Functor, Any}
+ hasmethod(expression.method, signature) && return expression
+ interaction(::Val{K}, ::Val{S}, ::Val{T}) where {K, S, T} = (K, S, T)
+ kind, source, target = interaction(expression.arguments...)
+ if _admits_layer(expression, source) && _admits_layer(expression, target)
+ validate(expression)
+ return expression
+ end
+ N = length(layers)
+ defined = _admits_layer(expression, N + 1) ? "defined beyond layer $N" :
+ "defined up to layer $(something(findlast(k -> _admits_layer(expression, k), 1:N), 0))"
+ throw(ArgumentError(
+ "the earth model has $N layers and formula " *
+ ":$(formula_id(selected)) is $defined; it has no expression " *
+ "for a $kind interaction from layer $source to layer $target"))
+end
+
+function validate(earth::EarthModel, formula::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation})
+ validate(earth)
+ earth.vertical_layers &&
+ throw(ArgumentError("earth-return equations require horizontal interfaces or an explicit EquivalentHomogeneous reduction"))
+ return earth
+end
+
+function validate(rho::AbstractVector,
+ formula::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation},
+ epsilon::AbstractVector, mu::AbstractVector, thickness)
+ length(rho) == length(epsilon) == length(mu) ||
+ throw(DimensionMismatch("material vectors must align"))
+ all(x -> x > 0 && !isnan(x), rho) ||
+ throw(DomainError(rho, "resistivities must be positive, including infinite air resistivity"))
+ all(x -> isfinite(x) && !iszero(x), epsilon) && all(x -> isfinite(x) && x > 0, mu) ||
+ throw(DomainError((epsilon, mu),
+ "permittivities must be nonzero and finite; permeabilities positive and finite"))
+ epsilon[1] > 0 || throw(DomainError(epsilon[1], "air permittivity must be positive"))
+ all(>(0), epsilon) ||
+ throw(DomainError(epsilon,
+ "formula :$(formula_id(formula)) requires positive permittivity; the earth-material data type permits artificial negative values"))
+ if thickness === nothing
+ length(rho) == 2 || throw(DimensionMismatch(
+ "material vectors without layer thicknesses describe air and one earth medium"))
+ else
+ length(thickness) == length(rho) ||
+ throw(DimensionMismatch("layer thicknesses must align with materials"))
+ isinf(first(thickness)) && isinf(last(thickness)) &&
+ all(x -> isfinite(x) && x > 0, @view(thickness[2:(end - 1)])) ||
+ throw(DomainError(thickness,
+ "air and bottom half-spaces must be infinite; internal layers positive and finite"))
+ end
+ return rho
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the normalized options of the surface impedance `kind` of the internal-impedance
+formula `formula`: the section of its options for this kind, or empty options.
+"""
+function formulation_options(formula::InternalImpedanceFormulation, ::Val{Kind}) where {Kind}
+ return FormulationOptions(get(formula.options.data, Kind, (;)))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the Functor of an insulation-impedance formula for one insulation annulus at one
+frequency, after checking its radii, its relative permeability and `jω`. The state is empty.
+"""
+function Functor(formula::InsulationImpedanceFormulation, input::NamedTuple;
+ workspace = nothing)
+ (; r_in, r_ex, mu_r, jω) = input
+ T = typeof(r_in)
+ isfinite(r_in) && isfinite(r_ex) && zero(T) <= r_in <= r_ex ||
+ throw(DomainError((r_in, r_ex), "insulation radii must satisfy 0 ≤ r_in ≤ r_ex [m]"))
+ isfinite(mu_r) && mu_r > zero(T) || throw(DomainError(mu_r,
+ "relative insulation permeability must be positive and finite"))
+ isfinite(jω) || throw(DomainError(jω, "jω must be finite"))
+ return Functor(formula, input, (;))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the Functor of an insulation admittivity law for one material at one frequency and
+temperature, after checking both. The state is empty.
+"""
+function Functor(formula::InsulationAdmittanceFormulation, input::NamedTuple;
+ workspace = nothing)
+ (; frequency, temperature) = input
+ isfinite(frequency) && frequency > zero(frequency) || throw(DomainError(
+ frequency, "insulation constitutive frequency must be positive and finite"))
+ isfinite(temperature) || throw(DomainError(
+ temperature, "insulation constitutive temperature must be finite"))
+ return Functor(formula, input, (;))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the Functor of a semiconductor admittivity law for one material at one frequency and
+temperature, after checking both. The state is empty.
+"""
+function Functor(formula::SemiconAdmittanceFormulation, input::NamedTuple;
+ workspace = nothing)
+ (; frequency, temperature) = input
+ isfinite(frequency) && frequency > zero(frequency) || throw(DomainError(
+ frequency, "semicon constitutive frequency must be positive and finite"))
+ isfinite(temperature) || throw(DomainError(
+ temperature, "semicon constitutive temperature must be finite"))
+ return Functor(formula, input, (;))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the Functor of an earth calculation of the impedance formula `formula` at one
+frequency, whose parts write the impedance coefficients of each conductor pair into `Zearth`
+of the workspace buffers, with an empty state.
+"""
+function Functor(formula::EarthImpedanceFormulation, input::NamedTuple; workspace)
+ return Functor(formula, merge(input, (destinations = (workspace.buffers.Zearth,),)), (;))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the Functor of an earth calculation of the admittance formula `formula` at one
+frequency, whose parts write the potential coefficients of each conductor pair into `Pearth`
+of the workspace buffers, with an empty state.
+"""
+function Functor(formula::EarthAdmittanceFormulation, input::NamedTuple; workspace)
+ return Functor(formula, merge(input, (destinations = (workspace.buffers.Pearth,),)), (;))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Check the input of one conductor pair before an earth formula evaluates it: a finite nonzero
+`jω`, the aligned material vectors of the pair, and, on a layered earth, the pair's heights
+against the layer thicknesses. Return `input`.
+"""
+function validate(input::NamedTuple,
+ formula::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation})
+ isfinite(input.jω) && !iszero(input.jω) ||
+ throw(DomainError(input.jω, "jω must be finite and nonzero"))
+ validate(input.rho, formula, input.epsilon, input.mu, input.thickness)
+ input.thickness === nothing || validate(input.pair, input.thickness)
+ return input
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate the earth coefficient of one indexed conductor pair at fixed angular frequency.
+Material vectors list air and every physical earth layer when layer thicknesses are given,
+and exactly `(air,soil)` otherwise. The call runs the checks of a computation: the pair and
+its geometry, the expression for its kind and layers on an earth with these media, the
+formula's options for that expression, and the pair's input. Return the coefficient as
+`Complex{T}`.
+
+# Arguments
+
+- `resistivity`: aligned resistivities [Ω·m].
+- `permittivity`: absolute permittivities [F/m].
+- `permeability`: absolute permeabilities [H/m].
+- `jω`: imaginary angular frequency [1/s].
+- `pair`: indexed conductor interaction. Lengths [m].
+
+# Keywords
+
+- `thickness`: aligned layer thicknesses [m] for a layered earth.
+- `physical_pair`: the physical pair before an equivalent-earth reduction.
+- `workspace`: the computation workspace, when the expression reads its buffers.
+"""
+function (formula::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation})(
+ resistivity::AbstractVector{T}, permittivity::AbstractVector{T},
+ permeability::AbstractVector{T}, jω::Complex{T}, pair::EarthPair;
+ thickness = nothing, physical_pair = pair, workspace = nothing
+) where {T <: Real}
+ validate(pair)
+ expression = Expression(formula, pair)
+ validate(pair, expression)
+ validate(expression, resistivity)
+ options = only(formulation_options(formula, (expression,)).options)
+ # One pair does not need destinations. The plain constructor gives the empty state of a
+ # formula that does not share values. Unified, whose state is its whole system, runs only in
+ # a computation.
+ functor = Functor(formula, (; jω, thickness, pair, physical = physical_pair,
+ rho = resistivity, epsilon = permittivity, mu = permeability, options), (;))
+ validate(functor.input, formula)
+ value = expression(functor, workspace)
+ value isa Number && isfinite(value) ||
+ throw(DomainError((value,), "earth coefficients must be finite scalars"))
+ return oftype(jω, value)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the backend formulation whose identity is `tag`: `:coaxial`, `:cable_constants`,
+`:fem` or `:pscad`. `Formulation(::Val{tag}; kwargs...)` forwards `kwargs` to the
+backend's constructor. PSCAD defines its own method.
+
+# Errors
+
+- Throws `ArgumentError` for an unknown `tag`.
+"""
+Formulation(tag::Symbol; kwargs...) = Formulation(Val(tag); kwargs...)
+
+Formulation(::Val{tag}; kwargs...) where {tag} =
+ throw(ArgumentError("unknown backend formulation :$tag"))
+
+function _fem_formulation(
+ insulation_admittance, semicon_admittance, earth_properties, temperature_dependence,
+ options::FormulationOptions
+)
+ methods = (
+ insulation_admittance = InsulationAdmittance.Formula(insulation_admittance),
+ semicon_admittance = SemiconAdmittance.Formula(semicon_admittance),
+ earth_properties = earth_properties === nothing ? nothing :
+ Earth.FrequencyDependent.Formula(earth_properties),
+ temperature_dependence = temperature_dependence === nothing ? nothing :
+ TemperatureDependent.Formula(temperature_dependence)
+ )
+ definitions = (; insulation_admittance, semicon_admittance, earth_properties,
+ temperature_dependence)
+ return LineCableModelsFEM(methods, formulation_options(LineCableModelsFEM, options),
+ definitions)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct the Gmsh/GetDP finite-element formulation. FEM defines its field equations
+and selects four material laws. Each law and `options` accepts a
+scalar or an explicit `Grid`/`Gridspace`. Varying inputs return a
+`Gridspace{LineCableModelsFEM}`.
+
+# Keywords
+
+- `insulation_admittance`: insulation admittivity law. `:default` routes to
+ `:lossless`.
+- `semicon_admittance`: semicon admittivity law. `:default` routes to
+ `:lossless`.
+- `earth_properties`: soil frequency-dependent constitutive law. `:default`
+ routes to the explicit `:constant` pass-through, while `nothing` preserves
+ the declared static soil. Equivalent-earth reductions are unsupported. Air
+ uses its declared static properties.
+- `temperature_dependence`: cable-material resistivity law. `:default` selects
+ the linear law and `nothing` retains reference resistivity. Operating
+ temperature belongs to `LineParametersProblem`.
+- `options=(;)`: field model (`physics=:quasi_tem` or `:quasi_fw`) and bundle,
+ Kron, and ideal-transposition reductions. Hyphenated strings and symbols
+ are also accepted for `physics`. Julia parses `:quasi-fw` as subtraction.
+ use `:quasi_fw` or `Symbol("quasi-fw")`.
+ Pass execution controls to `compute(...; options=(...))`.
+- `combine=:product`: product or zip composition among varying inputs.
+
+Analytical impedance and admittance kernel keywords are rejected. Supported enclosure
+geometry is represented directly in the FEM domain.
+"""
+function LineCableModelsFEM(;
+ insulation_admittance = formula(:default),
+ semicon_admittance = formula(:default),
+ earth_properties = formula(:default),
+ temperature_dependence = formula(:default),
+ options = FormulationOptions(),
+ combine::Symbol = :product
+)
+ return parameterize(
+ LineCableModelsFEM,
+ (inputs...) -> _fem_formulation(inputs[1:(end - 1)]...,
+ last(inputs) isa NamedTuple ? FormulationOptions(last(inputs)) : last(inputs)),
+ (insulation_admittance, semicon_admittance, earth_properties,
+ temperature_dependence, options);
+ combine
+ )
+end
+
+Formulation(::Val{:fem}; kwargs...) = LineCableModelsFEM(; kwargs...)
+
+# An earth equation admits an equivalent-earth reduction only through its own method.
+function validate(reduction::EquivalentHomogeneous.AbstractRule,
+ expression::Expression{<:Union{EarthImpedanceFormulation, EarthAdmittanceFormulation}})
+ throw(ArgumentError("$expression does not admit equivalent-earth reduction :$(formula_id(reduction))"))
+end
+
+"""Expose FEM constitutive and admittance selections, field model, and reductions."""
+function Base.NamedTuple(value::LineCableModelsFEM)
+ record = function (selected)
+ selected === nothing && return nothing
+ selected isa Symbol && return NamedTuple(formula(selected))
+ selected isa NamedTuple && return map(record, selected)
+ return NamedTuple(selected)
+ end
+ Record=NamedTuple{(:backend, :requested, :methods, :options),
+ Tuple{Symbol, NamedTuple, NamedTuple, NamedTuple}}
+ return Record((
+ :fem, map(record, value.definitions), map(record, value.methods), value.options.data))
+end
diff --git a/src/engine/helpers.jl b/src/engine/helpers.jl
deleted file mode 100644
index 586fc5b20..000000000
--- a/src/engine/helpers.jl
+++ /dev/null
@@ -1,182 +0,0 @@
-"""
-$(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.r_in,
- component.conductor_group.r_ex,
- component.insulator_group.r_in,
- component.insulator_group.r_ex,
- 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
-
diff --git a/src/engine/impedance.jl b/src/engine/impedance.jl
new file mode 100644
index 000000000..32b76ebb7
--- /dev/null
+++ b/src/engine/impedance.jl
@@ -0,0 +1,109 @@
+"""
+$(TYPEDSIGNATURES)
+
+Assemble the earth-free primitive series-impedance matrix of independent
+concentric cable assemblies.
+
+The selected internal- and insulation-impedance formulas contribute their
+outer, inner, transfer, and longitudinal-insulation terms directly to
+`destination`. The matrix remains unreduced.
+
+# Arguments
+
+- `destination`: reusable primitive series-impedance matrix [Ω/m].
+- `input`: concrete local cable arrays.
+- `rho_cond`: temperature-corrected conductor resistivities [Ω·m].
+- `methods`: resolved formulation methods.
+- `s`: complex angular frequency ``jω`` [1/s].
+
+# Returns
+
+- `destination`, overwritten with the assembled local matrix.
+"""
+function cable_impedance!(
+ destination::AbstractMatrix{Complex{T}},
+ input::LocalCableData{T},
+ rho_cond::AbstractVector{T},
+ methods::NamedTuple,
+ s::Complex{T}; workspace = nothing
+) where {T <: Real}
+ fill!(destination, zero(Complex{T}))
+ @inbounds for conductors in input.assemblies
+ count = length(conductors)
+ inside = zero(eltype(destination))
+ for position in count:-1:1
+ index = conductors[position]
+ surfaces = InternalImpedance.surface_impedances(methods.internal_impedance,
+ input.r_in[index] > 0 ? Val((:outer, :transfer, :inner)) : Val((:outer,)),
+ input.r_in[index],
+ input.r_ext[index],
+ rho_cond[index],
+ input.mu_r_cond[index],
+ s; workspace
+ )
+ outside = surfaces.outer
+ transfer = position > 1 ? surfaces.transfer : zero(outside)
+ insulation = methods.insulation_impedance(
+ input.r_ext[index],
+ input.r_ins_ext[index],
+ input.mu_r_ins[index],
+ s; workspace
+ )
+ loop = outside + inside + insulation
+ if position > 1
+ for row in 1:(position - 1), column in 1:(position - 1)
+
+ destination[conductors[row], conductors[column]] += loop - 2 * transfer
+ end
+ for row in 1:(position - 1)
+ destination[index, conductors[row]] += loop - transfer
+ destination[conductors[row], index] += loop - transfer
+ end
+ end
+ destination[index, index] += loop
+ # Reuse this wall's evaluated state when its inner surface is needed
+ # by the next contained conductor.
+ position > 1 && (inside = surfaces.inner)
+ end
+ end
+ return destination
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Add the already calculated exterior impedance to the cable-local primitive
+matrix \\[Ω/m\\] and retain its optional trace at `frequency`. No equation or
+material law is evaluated here. Return the mutated `destination`.
+"""
+function impedance!(
+ destination::AbstractMatrix{Complex{T}},
+ workspace::LineParametersWorkspace{T},
+ frequency::Int
+) where {T <: Real}
+ input = workspace.input
+ indices = workspace.plan.cable_indices
+ earth_matrix = workspace.buffers.Zearth
+ trace = workspace.trace
+
+ @inbounds for cable in 1:input.n_cables
+ conductors = indices[cable]
+ self = earth_matrix[cable, cable]
+ for row in conductors, column in conductors
+
+ destination[row, column] += self
+ end
+ end
+ @inbounds for left in 1:(input.n_cables - 1)
+ for right in (left + 1):input.n_cables
+ mutual = earth_matrix[left, right]
+ for row in indices[left], column in indices[right]
+
+ destination[row, column] += mutual
+ destination[column, row] += earth_matrix[right, left]
+ end
+ end
+ end
+ _stash!(trace, :Z, frequency, destination)
+ return destination
+end
diff --git a/src/engine/input.jl b/src/engine/input.jl
new file mode 100644
index 000000000..f42f0ba8c
--- /dev/null
+++ b/src/engine/input.jl
@@ -0,0 +1,454 @@
+"""
+$(TYPEDEF)
+
+Own the numerical input and reusable storage for one coaxial line-parameter
+computation.
+
+The constructor adapts a completed physical system once and validates the aligned numerical representation. It constructs cable and reduction indices, and
+allocates every matrix used by the frequency loop. Each `compute` call uses an independent workspace. Constant fields fix
+its input and buffer bindings. Reference identity avoids copying this large
+record when dispatching heterogeneous equation groups.
+
+Bound earth calculations and their material arrays are stored as tuples.
+The complete scan specializes on their concrete types once, before frequency
+traversal. Conductor layout is stored as runtime data.
+
+$(TYPEDFIELDS)
+"""
+mutable struct LineParametersWorkspace{
+ T <: Real,
+ N <: NamedTuple,
+ P <: NamedTuple,
+ B <: NamedTuple,
+ C
+}
+ # Immutable numerical input derived from the problem and formulation.
+ const input::N
+ # Physical values and index maps invariant across the frequency loop.
+ const plan::P
+ # Mutable numerical storage allocated once for the computation.
+ const buffers::B
+ # Optional retained diagnostic arrays, or `nothing`.
+ const trace::C
+
+ function LineParametersWorkspace{T, N, P, B, C}(
+ input::N,
+ plan::P,
+ buffers::B,
+ trace::C
+ ) where {T <: Real, N <: NamedTuple, P <: NamedTuple, B <: NamedTuple, C}
+ return validate(new{T, N, P, B, C}(
+ input,
+ plan,
+ buffers,
+ trace
+ ))
+ end
+end
+
+Base.eltype(::LineParametersWorkspace{T}) where {T} = T
+Base.eltype(::Type{<:LineParametersWorkspace{T}}) where {T} = T
+
+function validate(workspace::LineParametersWorkspace)
+ input = workspace.input
+ cable = input.cable
+ n = input.n_phases
+ input.n_frequencies == length(input.freq) || throw(DimensionMismatch(
+ "frequency count differs from the frequency vector"
+ ))
+ input.n_cables == maximum(input.cable_map) || throw(DimensionMismatch(
+ "cable count differs from the cable map"
+ ))
+ for values in (
+ input.horz, input.vert, input.phase_map, input.cable_map,
+ input.design_map, cable.terminals, cable.positions, cable.r_in,
+ cable.r_ext, cable.r_ins_in, cable.r_ins_ext, cable.conductor_materials,
+ cable.mu_r_cond, cable.mu_r_ins,
+ cable.dielectric_ranges
+ )
+ length(values) == n || throw(DimensionMismatch(
+ "engine input arrays must have $n component entries"
+ ))
+ end
+ n_layers = length(cable.dielectric_materials)
+ for values in (
+ cable.r_layer_in, cable.r_layer_ext,
+ workspace.buffers.layer_coefficients
+ )
+ length(values) == n_layers || throw(DimensionMismatch(
+ "dielectric-layer arrays must contain $n_layers entries"
+ ))
+ end
+ sort(vcat(
+ cable.insulation_indices,
+ cable.semicon_indices
+ )) == collect(1:n_layers) || throw(DimensionMismatch(
+ "insulation and semicon indices must partition the dielectric layers"
+ ))
+ size(input.horz_sep) == (n, n) || throw(DimensionMismatch(
+ "horizontal separation matrix must be $n×$n"
+ ))
+ length(workspace.plan.cable_indices) == input.n_cables || throw(
+ DimensionMismatch("cable indices must align with the cable count")
+ )
+ all(!isempty, workspace.plan.cable_indices) || throw(ArgumentError(
+ "every cable must contain one retained primitive conductor"
+ ))
+ size(workspace.buffers.Zprimitive) == (n, n) || throw(DimensionMismatch(
+ "primitive impedance storage must be $n×$n"
+ ))
+ size(workspace.buffers.Pprimitive) == (n, n) || throw(DimensionMismatch(
+ "primitive potential-coefficient storage must be $n×$n"
+ ))
+ return workspace
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct the formulation-independent coaxial input for one validated
+line-parameter problem.
+
+The selected designs have already been flattened into frequency-independent
+blueprints. This step constructs local cable arrays, physical geometry,
+terminal indices, and frequency coordinates once.
+
+# Arguments
+
+- `problem`: completed line-parameter problem.
+- `blueprints`: one frequency-independent blueprint per selected design.
+
+# Returns
+
+- A read-only named tuple shared by independent formulation workspaces.
+"""
+function lineinput(
+ problem::LineParametersProblem{T},
+ blueprints::Vector{CableBlueprint{T}}
+) where {T <: Real}
+ system = problem.system
+ length(blueprints) == length(system.designs) || throw(DimensionMismatch(
+ "line-parameter blueprints must align with the selected system designs",
+ ))
+ cable = LocalCableData(blueprints)
+ n_frequencies = length(problem.frequencies)
+ n_phases = length(system.terminal_order)
+ length(cable.terminals) == n_phases || throw(DimensionMismatch(
+ "DataModel terminal order differs from the cable blueprint count"
+ ))
+ n_cables = length(cable.assemblies)
+
+ freq = copy(problem.frequencies)
+ jω = Complex{T}.(im .* (2 * (one(first(freq)) * π) .* freq))
+ horz = Vector{T}(undef, n_phases)
+ horz_sep = Matrix{T}(undef, n_phases, n_phases)
+ vert = Vector{T}(undef, n_phases)
+ phase_map = copy(system.connection_order)
+ design_map = Int[entry.cable for entry in system.terminal_order]
+ cable_map = Vector{Int}(undef, n_phases)
+ @inbounds for (assembly, indices) in pairs(cable.assemblies), index in indices
+
+ cable_map[index] = assembly
+ end
+
+ @inbounds for index in eachindex(cable.terminals)
+ terminal = system.terminal_order[index]
+ terminal.terminal === cable.terminals[index] || throw(DimensionMismatch(
+ "DataModel terminal order is not aligned with the cable blueprint"
+ ))
+ design_index = design_map[index]
+ cable.assembly_designs[cable_map[index]] == design_index ||
+ throw(DimensionMismatch(
+ "blueprint assembly ownership differs from system terminal order"
+ ))
+ position = system.positions[design_index]
+ local_x, local_y = cable.positions[index]
+ horz[index] = position.x + cos(position.φ) * local_x -
+ sin(position.φ) * local_y
+ vert[index] = position.y + sin(position.φ) * local_x +
+ cos(position.φ) * local_y
+ end
+ horizontal_separation!(
+ horz_sep,
+ horz,
+ cable.r_ext,
+ cable.r_ins_ext,
+ cable_map
+ )
+ return (
+ freq,
+ jω,
+ horz,
+ horz_sep,
+ vert,
+ cable,
+ phase_map,
+ cable_map,
+ design_map,
+ earth = problem.earth_props,
+ temperature = problem.temperature,
+ line_length = system.line_length,
+ n_frequencies,
+ n_phases,
+ n_cables
+ )
+end
+
+function lineinput(::Type{T}, input::NamedTuple) where {T <: Real}
+ T === eltype(input.freq) && return input
+ return merge(input,
+ (
+ freq = T.(input.freq), jω = Complex{T}.(input.jω),
+ horz = T.(input.horz), vert = T.(input.vert), horz_sep = T.(input.horz_sep),
+ cable = convert(LocalCableData{T}, input.cable),
+ earth = convert(EarthModel{T}, input.earth),
+ temperature = convert(T, input.temperature),
+ line_length = convert(T, input.line_length)))
+end
+
+function LineParametersWorkspace(
+ problem::LineParametersProblem{T},
+ formulation::LineParametersFormulation,
+ execution::ComputationOptions,
+ blueprints::Vector{CableBlueprint{T}}
+) where {T <: Real}
+ return LineParametersWorkspace(
+ problem,
+ formulation,
+ execution,
+ lineinput(problem, blueprints)
+ )
+end
+
+function LineParametersWorkspace(
+ problem::LineParametersProblem{T},
+ formulation::LineParametersFormulation,
+ execution::ComputationOptions,
+ input::NamedTuple
+) where {T <: Real}
+ cable = input.cable
+ horz = input.horz
+ horz_sep = input.horz_sep
+ vert = input.vert
+ phase_map = input.phase_map
+ cable_indices = [collect(indices) for indices in cable.assemblies]
+ cable_representatives = first.(cable_indices)
+ physical_pairs = earth_pairs(
+ cable_representatives,
+ horz,
+ vert,
+ horz_sep,
+ problem.earth_props
+ )
+ geometry = (radius = _outer_radii(input.cable_map, cable.r_ext, cable.r_ins_ext),
+ layers = [layer_index(pair)[1] for pair in physical_pairs if pair.row == pair.column])
+ # Each slot decides its earth once. A recipe then picks each pair's formula on the decided
+ # layers. Impedance and admittance calculations that solve the same system merge.
+ earth = EarthPlan(
+ EarthPlan(formulation.methods.earth_impedance, problem.earth_props, physical_pairs,
+ geometry),
+ EarthPlan(formulation.methods.earth_admittance, problem.earth_props, physical_pairs,
+ geometry))
+ options = formulation.options.data
+ reduction = ReductionPlan(phase_map; options.reduce_bundle, options.kron_reduction,
+ options.ideal_transposition)
+ plan = (; cable_indices, reduction, earth, geometry)
+ # The earth formulas that the computation uses: each slot's formula, or the formulas of a
+ # recipe that a calculation uses, the others `nothing`. They widen the scalar type and
+ # provision their arrays.
+ formulas = map(formulation.methods[(:earth_impedance, :earth_admittance)]) do selected
+ selected isa NamedTuple || return selected
+ map(selected) do leaf
+ any(calculation -> any(entry -> entry !== nothing && entry.formula === leaf,
+ (calculation.impedance, calculation.admittance)), earth.calculations) ?
+ leaf : nothing
+ end
+ end
+ scalar = computation_type(T, formulas, input.freq)
+ return LineParametersWorkspace{scalar}(problem, formulation, execution,
+ lineinput(scalar, input), plan, formulas)
+end
+
+function LineParametersWorkspace{T}(
+ problem::LineParametersProblem,
+ formulation::LineParametersFormulation,
+ execution::ComputationOptions,
+ input::NamedTuple,
+ plan::NamedTuple,
+ formulas::NamedTuple
+) where {T <: Real}
+ cable = input.cable
+ n_phases, n_cables, n_frequencies = input.n_phases, input.n_cables, input.n_frequencies
+ n_layers = length(cable.dielectric_materials)
+ cable_indices = plan.cable_indices
+ nkeep = length(plan.reduction.keep)
+ # The plan keeps the conductor radii in the computation's scalar type.
+ plan = merge(plan, (geometry = (radius = convert(Vector{T}, plan.geometry.radius),
+ layers = plan.geometry.layers),))
+ rho_cond = Vector{T}(undef, length(cable.conductor_materials))
+
+ Zprimitive = Matrix{Complex{T}}(undef, n_phases, n_phases)
+ Pprimitive = similar(Zprimitive)
+ Zout = Array{Complex{T}, 3}(undef, nkeep, nkeep, n_frequencies)
+ Yout = similar(Zout)
+ Zearth = Matrix{Complex{T}}(undef, n_cables, n_cables)
+ Pearth = similar(Zearth)
+ trace = if execution.data.trace isa Val{true}
+ (Zin = Array{Complex{T}, 3}(undef, n_phases, n_phases, n_frequencies),
+ Pin = Array{Complex{T}, 3}(undef, n_phases, n_phases, n_frequencies),
+ Zg = Array{Complex{T}, 3}(undef, n_cables, n_cables, n_frequencies),
+ Pg = Array{Complex{T}, 3}(undef, n_cables, n_cables, n_frequencies),
+ Z = Array{Complex{T}, 3}(undef, n_phases, n_phases, n_frequencies),
+ P = Array{Complex{T}, 3}(undef, n_phases, n_phases, n_frequencies),
+ integrals = NamedTuple[])
+ else
+ nothing
+ end
+ observations = trace === nothing ? nothing : trace.integrals
+ largest_cable = maximum(length, cable_indices)
+ coefficients = Vector{Complex{T}}(undef, largest_cable)
+ tails = similar(coefficients)
+ layer_coefficients = Vector{Complex{T}}(undef, n_layers)
+ dielectric_admittivity = similar(layer_coefficients)
+ buffers = (;
+ rho_cond,
+ dielectric_admittivity,
+ Zprimitive,
+ Pprimitive,
+ Zout,
+ Yout,
+ Zearth,
+ Pearth,
+ observations,
+ layer_coefficients,
+ coefficients,
+ tails
+ )
+ buffers = initialize_buffers(plan.reduction, Complex{T}, input, plan, buffers)
+ # The internal formula has an expression for each surface impedance that the geometry
+ # needs.
+ for kind in (any(>(0), cable.r_in) ? (:inner, :outer, :transfer) : (:outer,))
+ validate(Expression(formulation.methods.internal_impedance,
+ InternalImpedance.internal_impedance, Val(kind)))
+ end
+ # Concrete formulas provision numerical storage, never option-key inspection. A recipe
+ # formula provisions arrays only when a calculation uses it. The earth plan then
+ # provisions its calculations.
+ buffers = initialize_buffers(merge(formulation.methods, formulas), T, input, plan, buffers)
+ buffers = initialize_buffers(plan.earth, T, input, plan, buffers)
+ workspace = LineParametersWorkspace{
+ T,
+ typeof(input),
+ typeof(plan),
+ typeof(buffers),
+ typeof(trace)
+ }(input, plan, buffers, trace)
+ return workspace
+end
+
+function initialize_buffers(
+ selected::Union{EarthImpedanceFormulation, EarthAdmittanceFormulation},
+ ::Type{T}, input, plan, buffers) where {T}
+ return initialize_buffers(selected.equivalent_earth, T, input, plan, buffers)
+end
+
+function layer_index(problem::LineParametersProblem, horizontal, vertical)
+ layer_index(problem.earth_props, horizontal, vertical)
+end
+layer_index(pair::EarthPair) = pair.layers
+
+function layer_index(model::EarthModel, horizontal, vertical)
+ vertical > zero(vertical) && return 1
+ iszero(vertical) && throw(ArgumentError(
+ "a conductor on the air-earth interface has no physical layer"
+ ))
+ model.vertical_layers && length(model.layers) > 2 &&
+ throw(ArgumentError(
+ "physical source/target indexing for vertical earth interfaces is not implemented"))
+ depth = -vertical
+ boundary = zero(depth)
+ @inbounds for layer in 2:length(model.layers)
+ thickness = model.layers[layer].thickness
+ isinf(thickness) && return layer
+ boundary += thickness
+ depth <= boundary && return layer
+ end
+ throw(ArgumentError(
+ "conductor depth $depth m is outside the earth-layer model"
+ ))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct every ordered external interaction from resolved conductor geometry.
+Source columns and target rows retain physical earth-layer indices. Air is 1.
+
+`cables` contains representative conductor indices. `horizontal`, `vertical`,
+and `separation` are aligned coordinates and distances in meters. Diagonal
+separations supply the external self radius. Layer assignment uses `earth`'s
+physical interfaces and rejects conductors on the interface between air and earth.
+
+Return ordered geometry payloads for indexed formula dispatch. Formula
+selection and equivalent-earth reduction occur separately.
+"""
+function earth_pairs(
+ cables::AbstractVector{Int},
+ horizontal,
+ vertical,
+ separation,
+ earth::EarthModel
+)
+ T = eltype(vertical)
+ pairs = EarthPair{T}[]
+ sizehint!(pairs, length(cables)^2)
+ placed_layers = [layer_index(earth, horizontal[index], vertical[index])
+ for index in cables]
+ @inbounds for column in eachindex(cables), row in eachindex(cables)
+
+ source = cables[column]
+ target = cables[row]
+ layers = (placed_layers[column], placed_layers[row])
+ push!(pairs,
+ EarthPair(
+ row,
+ column,
+ (vertical[source], vertical[target]),
+ row == column ? zero(T) : separation[target, source],
+ layers; radius = row == column ? separation[target, source] : nothing
+ ))
+ end
+ return pairs
+end
+
+@inline function _outer_radii(cable_map, r_ext_values, r_ins_ext)
+ length(cable_map) == length(r_ext_values) == length(r_ins_ext) ||
+ throw(DimensionMismatch("cable maps and radius vectors must align"))
+ outer = fill(zero(eltype(r_ext_values)), maximum(cable_map))
+ @inbounds for index in eachindex(cable_map)
+ cable = cable_map[index]
+ outer[cable] = max(outer[cable], r_ext_values[index], r_ins_ext[index])
+ end
+ return outer
+end
+
+function horizontal_separation!(
+ destination,
+ horizontal,
+ r_ext_values,
+ r_ins_ext,
+ cable_map
+)
+ n = length(horizontal)
+ size(destination) == (n, n) || throw(DimensionMismatch(
+ "horizontal separation matrix must be $n×$n"
+ ))
+ outer = _outer_radii(cable_map, r_ext_values, r_ins_ext)
+ @inbounds for column in 1:n, row in 1:n
+
+ destination[row, column] = cable_map[row] == cable_map[column] ?
+ outer[cable_map[row]] :
+ abs(horizontal[row] - horizontal[column])
+ end
+ return destination
+end
diff --git a/src/engine/insulationadmittance/InsulationAdmittance.jl b/src/engine/insulationadmittance/InsulationAdmittance.jl
index f99ea7593..6a08a872d 100644
--- a/src/engine/insulationadmittance/InsulationAdmittance.jl
+++ b/src/engine/insulationadmittance/InsulationAdmittance.jl
@@ -1,27 +1,52 @@
"""
- LineCableModels.Engine.InsulationAdmittance
+ LineCableModels.Engine.InsulationAdmittance
+
+Define registered constitutive relations for cable-insulation admittance.
+`:lossy` retains material conduction and displacement current. `:lossless`
+explicitly selects the lossless approximation. `:default` routes to
+`:lossless`.
# Dependencies
$(IMPORTS)
-# Exports
-
-$(EXPORTS)
"""
module InsulationAdmittance
+import ...Commons: FormulationOptions, formulas
+using ...Commons: Functor
+import ...Commons: formulation_options
# Export public API
-export Lossless, ParallelRC
+export Formula, formula_id, formulas
# Module-specific dependencies
-using ...Commons
-import ...Commons: get_description
-using ...Utils: _to_σ
-import ..Engine: InsulationAdmittanceFormulation
-using Measurements
-
-include("lossless.jl")
-include("parallelrc.jl")
+#! explicit-imports: off
+# IMPORTS is expanded in this module docstring rather than called as Julia code.
+using DocStringExtensions: IMPORTS, TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+#! explicit-imports: on
+import ..Engine: InsulationAdmittanceFormulation, formula_id, validate
+import ...LineCableModels: FormulaDefinition, Expression
+using ...Materials: Material
+#! explicit-imports: off
+import ..Engine: description, conductivity
+using ...Commons: vacuum_permittivity
+#! explicit-imports: on
+
+include("interface.jl")
+
+public insulation_material
+
+#! explicit-imports: off
+const FORMULAS = (
+ include("formulas/default.jl"),
+ include("formulas/lossless.jl"),
+ include("formulas/lossy.jl"),
+)
+#! explicit-imports: on
+
+"""
+Return the built-in insulation-admittance formula identifiers.
+"""
+formulas(::Type{<:Formula}) = FORMULAS
end # module InsulationAdmittance
diff --git a/src/engine/insulationadmittance/formulas/default.jl b/src/engine/insulationadmittance/formulas/default.jl
new file mode 100644
index 000000000..c8a84fb5c
--- /dev/null
+++ b/src/engine/insulationadmittance/formulas/default.jl
@@ -0,0 +1,12 @@
+"""
+$(TYPEDSIGNATURES)
+
+Route the default selection to the explicit `:lossless` implementation.
+No numerical equation is owned by `:default`.
+"""
+description(::Type{<:Formula{:default}}; compact::Bool=false) =
+ compact ? "Default" : "Default routing to :lossless"
+
+Formula{:default}(; kwargs...) = Formula{:lossless}(; kwargs...)
+
+:default
diff --git a/src/engine/insulationadmittance/formulas/lossless.jl b/src/engine/insulationadmittance/formulas/lossless.jl
new file mode 100644
index 000000000..9800f0c69
--- /dev/null
+++ b/src/engine/insulationadmittance/formulas/lossless.jl
@@ -0,0 +1,52 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Lossless cable-insulation approximation retaining
+displacement current while suppressing conduction and polarization loss.
+
+**Expression.**
+
+```math
+\\kappa=j\\omega\\varepsilon_0\\varepsilon_r.
+```
+
+This is the lossless specialization of the standard frequency-domain
+constitutive relation.
+"""
+function description(::Type{<:Formula{:lossless}}; compact::Bool=false)
+ compact ? "Lossless" : "Lossless cable-insulation admittivity"
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate lossless cable-insulation admittivity:
+
+```math
+\\kappa=j\\omega\\varepsilon_0\\varepsilon_r.
+```
+
+# Arguments
+
+- `functor`: the Functor of the evaluation point. Its input holds:
+ - `material`: insulation material and relative permittivity.
+ - `frequency`: evaluation frequency \\[Hz\\].
+ - `temperature`: operating temperature \\[°C\\].
+ - `options`: the normalized formulation options of the formula.
+- `workspace`: optional computation workspace supplying reusable numerical buffers.
+
+# Returns
+
+- Complex lossless admittivity \\[S/m\\].
+"""
+@inline function insulation_material(::Formula{:lossless}, functor, workspace)
+ (; material, frequency) = functor.input
+ T = typeof(frequency)
+ ε₀ = vacuum_permittivity(T)
+ ω = 2 * (one(T) * π) * frequency
+ return complex(zero(T), ω) * ε₀ * material.eps_r
+end
+
+formulation_options(::Expression{<:Formula{:lossless}, typeof(insulation_material)}) = FormulationOptions()
+
+:lossless
diff --git a/src/engine/insulationadmittance/formulas/lossy.jl b/src/engine/insulationadmittance/formulas/lossy.jl
new file mode 100644
index 000000000..2e10438cd
--- /dev/null
+++ b/src/engine/insulationadmittance/formulas/lossy.jl
@@ -0,0 +1,73 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Generic lossy complex-admittivity representation of a
+homogeneous cable-insulation layer. Ohmic conduction, dielectric displacement,
+and an optional polarization-loss contribution are evaluated together.
+
+**Expression.** For resistivity ``\\rho``, real relative permittivity
+``\\varepsilon_r``, and polarization loss tangent ``\\tan\\delta_p``,
+
+```math
+\\kappa=\\frac{1}{\\rho}+\\omega\\varepsilon_0\\varepsilon_r\\tan\\delta_p+
+j\\omega\\varepsilon_0\\varepsilon_r.
+```
+
+The annular layer operator converts this material admittivity to
+``Y=2\\pi\\kappa/\\ln(b/a)``. This is the standard frequency-domain
+constitutive relation, not an author-specific empirical law. Ametani,
+Miyamoto, and Nagaoka (2004), Eqs. (14)-(15), remain a useful cable-layer
+application reference. The paper's main-insulation term is the lossless
+specialization.
+"""
+function description(::Type{<:Formula{:lossy}}; compact::Bool=false)
+ compact ? "Lossy" : "Lossy complex-admittivity cable-insulation model"
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate the standard lossy material admittivity for a cable-insulation layer:
+
+```math
+\\kappa=\\frac{1}{\\rho}+\\omega\\varepsilon_0\\varepsilon_r\\tan\\delta_p+
+j\\omega\\varepsilon_0\\varepsilon_r.
+```
+
+`material.tan_delta` represents polarization loss only. Conduction is supplied
+by `material.rho`. The common coaxial operator applies the annular geometry.
+
+# Arguments
+
+- `functor`: the Functor of the evaluation point. Its input holds:
+ - `material`: insulation material properties, including resistivity and
+ relative permittivity.
+ - `frequency`: evaluation frequency \\[Hz\\].
+ - `temperature`: operating temperature \\[°C\\].
+ - `options`: the normalized formulation options of the formula.
+- `workspace`: optional computation workspace supplying reusable numerical buffers.
+
+# Returns
+
+- Complex material admittivity \\[S/m\\].
+
+# Notes
+
+Ametani, Miyamoto, and Nagaoka (2004), DOI
+10.1109/TPWRD.2003.822502, is retained as a cable-layer application
+reference. The constitutive relation itself is standard frequency-domain
+electromagnetism.
+"""
+@inline function insulation_material(::Formula{:lossy}, functor, workspace)
+ (; material, frequency) = functor.input
+ T = typeof(frequency)
+ ε₀ = vacuum_permittivity(T)
+ ω = 2 * (one(T) * π) * frequency
+ displacement = complex(zero(T), ω) * ε₀ * material.eps_r
+ return conductivity(material.rho) + imag(displacement) * material.tan_delta +
+ displacement
+end
+
+formulation_options(::Expression{<:Formula{:lossy}, typeof(insulation_material)}) = FormulationOptions()
+
+:lossy
diff --git a/src/engine/insulationadmittance/interface.jl b/src/engine/insulationadmittance/interface.jl
new file mode 100644
index 000000000..658b51d89
--- /dev/null
+++ b/src/engine/insulationadmittance/interface.jl
@@ -0,0 +1,100 @@
+"""
+$(TYPEDEF)
+
+Select one insulation constitutive relation by its stable literature identifier.
+
+Each formula implements `insulation_material(selected, functor, workspace)` on its concrete
+selection type. The input of `functor` holds the material, the frequency, the temperature
+and the options. The method returns the material's frequency-evaluated admittivity [S/m]. Geometry and
+radial series aggregation remain common Engine operations.
+
+$(TYPEDFIELDS)
+"""
+struct Formula{ID, P <: NamedTuple, O <: FormulationOptions} <: InsulationAdmittanceFormulation
+ "Resolved physical model parameters."
+ parameters::P
+ "Normalized numerical sections for this equation."
+ options::O
+end
+
+"""
+Evaluate one formula-owned insulation-material constitutive relation.
+"""
+function insulation_material end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct a selected formulation with model parameters and numerical controls.
+Custom formulations extend `insulation_material` on their own concrete selection type.
+Unknown controls fail before numerical evaluation.
+"""
+function Formula{ID}(; parameters::NamedTuple=(;), options::Union{NamedTuple, FormulationOptions} = FormulationOptions()) where {ID}
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ isempty(parameters) || throw(ArgumentError("formula :$ID has no configurable model parameters"))
+ selected = Formula{ID, typeof(parameters), typeof(options)}(parameters, options)
+ expression = Expression(selected, insulation_material)
+ normalized = formulation_options(expression, options)
+ return Formula{ID, typeof(parameters), typeof(normalized)}(parameters, normalized)
+end
+
+@inline function (formula::InsulationAdmittanceFormulation)(
+ material::Material{T},
+ frequency::T,
+ temperature::T; workspace = nothing
+) where {T <: Real}
+ functor = Functor(formula,
+ (; material, frequency, temperature, options = formula.options); workspace)
+ value = Expression(formula, insulation_material)(functor, workspace)
+ return convert(Complex{T}, validate(value, formula, T))
+end
+
+function (formula::InsulationAdmittanceFormulation)(
+ material::Material{T},
+ frequency::Real,
+ temperature::Real; workspace = nothing
+) where {T <: Real}
+ U = promote_type(
+ T,
+ typeof(float(frequency)),
+ typeof(float(temperature))
+ )
+ return formula(
+ convert(Material{U}, material),
+ convert(U, float(frequency)),
+ convert(U, float(temperature)); workspace
+ )
+end
+
+Formula(identifier::Symbol; kwargs...) = Formula(Val(identifier); kwargs...)
+Formula(::Val{ID}; kwargs...) where {ID} = Formula{ID}(; kwargs...)
+Formula(selected::InsulationAdmittanceFormulation) = selected
+
+function Formula(selection::FormulaDefinition{ID, Order}) where {ID, Order}
+ Order === :default || throw(ArgumentError("order applies only to equivalent_earth"))
+ selection.equivalent_earth === nothing || throw(ArgumentError(
+ "equivalent_earth applies only to external earth formulas"))
+ return Formula{ID}(; parameters = selection.parameters,
+ options = selection.options)
+end
+
+"""
+Return the stable identifier of an insulation-admittance formula.
+"""
+formula_id(::Formula{ID}) where {ID} = ID
+formula_id(::Type{<:Formula{ID}}) where {ID} = ID
+# Identity-only dispatch also describes retained selections without constructors.
+description(value::Formula; compact::Bool = false) = description(typeof(value); compact)
+formulation_options(value::Formula) = value.options
+
+"""
+$(TYPEDSIGNATURES)
+
+Expose the selected identity, model parameters, and numerical options as a native record.
+"""
+function Base.NamedTuple(value::Formula)
+ return (identifier=formula_id(value), parameters=value.parameters, options=value.options.data)
+end
+
+"""Iterate the independently selectable child slots admitted by this formula family."""
+Base.pairs(::Type{<:Formula}; quantity=nothing) = pairs((;))
diff --git a/src/engine/insulationadmittance/lossless.jl b/src/engine/insulationadmittance/lossless.jl
deleted file mode 100644
index 79f3369af..000000000
--- a/src/engine/insulationadmittance/lossless.jl
+++ /dev/null
@@ -1,38 +0,0 @@
-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},
- loss_factor::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
-
-@inline function potential_coefficient(
- f::Lossless,
- ws,
- component_idx::Int,
- jω::Complex{T},
-) where {T <: REALSCALAR}
- return f(
- ws.r_ins_in[component_idx],
- ws.r_ins_ext[component_idx],
- ws.eps_ins[component_idx],
- jω,
- ws.tan_ins[component_idx],
- )
-end
diff --git a/src/engine/insulationadmittance/parallelrc.jl b/src/engine/insulationadmittance/parallelrc.jl
deleted file mode 100644
index f5f3ba244..000000000
--- a/src/engine/insulationadmittance/parallelrc.jl
+++ /dev/null
@@ -1,98 +0,0 @@
-"""
- ParallelRC
-
-Represent each concentric insulation layer as a frequency-independent shunt
-conductance in parallel with its capacitance.
-
-# Notes
-
-For a layer with inner radius ``r_i`` \\[m\\], outer radius ``r_o`` \\[m\\],
-resistivity ``\\rho`` \\[Ω·m\\], and relative permittivity
-``\\varepsilon_r`` \\[dimensionless\\], the per-unit-length layer admittance is
-
-```math
-y(s) = G + sC,
-\\qquad
-C = \\frac{2\\pi\\varepsilon_0\\varepsilon_r}{\\ln(r_o/r_i)},
-\\qquad
-G = \\frac{2\\pi}{\\rho\\ln(r_o/r_i)}.
-```
-
-The EMT solver assembles potential coefficients, so this formulation returns
-``p(s)=s/y(s)`` for every layer and adds series layer coefficients before matrix
-assembly. Material uncertainties therefore propagate through the complete
-admittance calculation.
-"""
-struct ParallelRC <: InsulationAdmittanceFormulation end
-
-get_description(::ParallelRC) =
- "Parallel-RC insulation (constant conductivity and permittivity)"
-
-"""
- (formulation::ParallelRC)(r_in, r_ex, rho, eps_r, s)
-
-Calculate the potential coefficient of one concentric lossy dielectric layer.
-
-# Arguments
-
-- `r_in`: Inner layer radius \\[m\\].
-- `r_ex`: Outer layer radius \\[m\\].
-- `rho`: Dielectric resistivity \\[Ω·m\\].
-- `eps_r`: Relative dielectric permittivity \\[dimensionless\\].
-- `s`: Complex angular frequency \\[rad/s\\].
-
-# Returns
-
-- Complex potential coefficient per unit length \\[m/F\\].
-
-# Notes
-
-This method implements
-
-```math
-p(s) = \\frac{s}{G+sC}
- = \\frac{\\ln(r_o/r_i)}{2\\pi}
- \\frac{s}{1/\\rho+s\\varepsilon_0\\varepsilon_r}.
-```
-
-At infinite resistivity, the result reduces exactly to the lossless potential
-coefficient ``1/C``.
-"""
-@inline function (f::ParallelRC)(
- r_in::T,
- r_ex::T,
- rho::T,
- eps_r::T,
- s::Complex{T},
-) where {T <: REALSCALAR}
- if isapprox(r_in, 0.0, atol = eps(T)) || isapprox(r_in, r_ex, atol = eps(T))
- # Keep bare-conductor handling consistent with Lossless.
- return zero(Complex{T})
- end
-
- log_ratio = log(r_ex / r_in)
- capacitance = T(2π * ε₀) * eps_r / log_ratio
- conductivity = _to_σ(rho)
- conductance = T(2π) * conductivity / log_ratio
-
- return s / (conductance + s * capacitance)
-end
-
-@inline function potential_coefficient(
- f::ParallelRC,
- ws,
- component_idx::Int,
- s::Complex{T},
-) where {T <: REALSCALAR}
- p = zero(Complex{T})
- @inbounds for layer_idx in ws.insulator_layer_ranges[component_idx]
- p += f(
- ws.r_ins_layer_in[layer_idx],
- ws.r_ins_layer_ext[layer_idx],
- ws.rho_ins_layer[layer_idx],
- ws.eps_ins_layer[layer_idx],
- s,
- )
- end
- return p
-end
diff --git a/src/engine/insulationimpedance/InsulationImpedance.jl b/src/engine/insulationimpedance/InsulationImpedance.jl
index 60854f40d..29e44e070 100644
--- a/src/engine/insulationimpedance/InsulationImpedance.jl
+++ b/src/engine/insulationimpedance/InsulationImpedance.jl
@@ -1,26 +1,47 @@
"""
- LineCableModels.Engine.InsulationImpedance
+ LineCableModels.Engine.InsulationImpedance
+
+Define registered series-impedance formulas for cable insulation.
# Dependencies
$(IMPORTS)
-# Exports
-
-$(EXPORTS)
"""
module InsulationImpedance
+import ...Commons: FormulationOptions, formulas
+using ...Commons: Functor
+import ...Commons: formulation_options
# Export public API
-export Lossless
-
+export Formula, formula_id, formulas
# Module-specific dependencies
-using ...Commons
-import ...Commons: get_description
-import ..Engine: InsulationImpedanceFormulation
-using Measurements
+#! explicit-imports: off
+# These abbreviations are expanded in this module docstring and included files.
+using DocStringExtensions: IMPORTS, TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+#! explicit-imports: on
+import ..Engine: InsulationImpedanceFormulation, formula_id
+import ...LineCableModels: FormulaDefinition, Expression
+#! explicit-imports: off
+import ..Engine: description
+using ...Commons: vacuum_permeability
+#! explicit-imports: on
+
+include("interface.jl")
+
+public insulation_impedance
+
+#! explicit-imports: off
+const FORMULAS = (
+ include("formulas/ametani1980.jl"),
+ include("formulas/default.jl"),
+)
+#! explicit-imports: on
-include("lossless.jl")
+"""
+Return the built-in insulation-impedance formula identifiers.
+"""
+formulas(::Type{<:Formula}) = FORMULAS
end # module InsulationImpedance
diff --git a/src/engine/insulationimpedance/formulas/ametani1980.jl b/src/engine/insulationimpedance/formulas/ametani1980.jl
new file mode 100644
index 000000000..16405bd00
--- /dev/null
+++ b/src/engine/insulationimpedance/formulas/ametani1980.jl
@@ -0,0 +1,69 @@
+
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Longitudinal magnetic impedance of one concentric
+insulation region in Ametani's single-core cable formulation.
+
+**Expression.**
+
+```math
+Z_{ins}=\\frac{j\\omega\\mu_0\\mu_r}{2\\pi}\\ln\\frac{b}{a}.
+```
+
+The term vanishes when the annular region has zero thickness. It is assembled
+with the conductor surface impedances to form the cable series-impedance
+matrix.
+
+**Reference.** A. Ametani, “A General Formulation of Impedance and Admittance
+of Cables,” *IEEE Transactions on Power Apparatus and Systems*, PAS-99(3),
+902–910, 1980. DOI: 10.1109/TPAS.1980.319718.
+
+"""
+function description(::Type{<:Formula{:ametani1980}}; compact::Bool=false)
+ compact ? "Ametani" : "Ametani coaxial-insulation magnetic impedance (1980)"
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate the longitudinal magnetic impedance of one concentric insulation
+region as used in Ametani's single-core cable assembly:
+
+```math
+z_{ab}=\\frac{s\\mu_0\\mu_i}{2\\pi}\\ln\\frac{b}{a}.
+```
+
+# Arguments
+
+- `functor`: the Functor of the evaluation point. Its input holds:
+ - `r_in`: inner insulation radius ``a`` \\[m\\].
+ - `r_ex`: outer insulation radius ``b`` \\[m\\].
+ - `mu_r`: relative insulation permeability ``\\mu_i`` \\[dimensionless\\].
+ - `jω`: complex angular frequency ``j\\omega`` \\[rad/s\\].
+ - `options`: the normalized formulation options of the formula.
+- `workspace`: optional computation workspace supplying reusable numerical buffers.
+
+# Returns
+
+- Longitudinal insulation impedance ``z_{ab}`` \\[Ω/m\\].
+
+# Notes
+
+Implements Ametani (1980) as reproduced in Ametani, Ohno, and Nagaoka
+(2015), Eqs. 2.6–2.13, and Ametani et al. (2021), Appendix A1.1.1.
+"""
+@inline function insulation_impedance(::Formula{:ametani1980}, functor, workspace)
+ (; r_in, r_ex, mu_r, jω) = functor.input
+ T = typeof(r_in)
+ if isapprox(r_in, zero(T); atol = eps(T)) ||
+ isapprox(r_in, r_ex; atol = eps(T))
+ return zero(Complex{T})
+ end
+ μ0 = vacuum_permeability(typeof(r_in))
+ return jω * μ0 * mu_r / (2 * (one(r_in) * π)) * log(r_ex / r_in)
+end
+
+formulation_options(::Expression{<:Formula{:ametani1980}, typeof(insulation_impedance)}) = FormulationOptions()
+
+:ametani1980
diff --git a/src/engine/insulationimpedance/formulas/default.jl b/src/engine/insulationimpedance/formulas/default.jl
new file mode 100644
index 000000000..921e5f26f
--- /dev/null
+++ b/src/engine/insulationimpedance/formulas/default.jl
@@ -0,0 +1,12 @@
+"""
+$(TYPEDSIGNATURES)
+
+Route the default selection to the explicit `:ametani1980` implementation.
+No numerical equation is owned by `:default`.
+"""
+description(::Type{<:Formula{:default}}; compact::Bool=false) =
+ compact ? "Default" : "Default routing to :ametani1980"
+
+Formula{:default}(; kwargs...) = Formula{:ametani1980}(; kwargs...)
+
+:default
diff --git a/src/engine/insulationimpedance/interface.jl b/src/engine/insulationimpedance/interface.jl
new file mode 100644
index 000000000..47f8c7600
--- /dev/null
+++ b/src/engine/insulationimpedance/interface.jl
@@ -0,0 +1,81 @@
+"""
+$(TYPEDEF)
+
+Select one insulation-impedance formula by its stable identifier.
+
+$(TYPEDFIELDS)
+"""
+struct Formula{ID, P <: NamedTuple, O <: FormulationOptions} <: InsulationImpedanceFormulation
+ "Resolved physical model parameters."
+ parameters::P
+ "Normalized numerical sections for this equation."
+ options::O
+end
+
+"""
+Evaluate one formula-owned insulation-impedance route.
+"""
+function insulation_impedance end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct a selected formulation with model parameters and numerical controls.
+Custom formulations extend `insulation_impedance` on their own concrete selection type.
+Unknown controls fail before numerical evaluation.
+"""
+function Formula{ID}(; parameters::NamedTuple=(;), options::Union{NamedTuple, FormulationOptions} = FormulationOptions()) where {ID}
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ isempty(parameters) || throw(ArgumentError("formula :$ID has no configurable model parameters"))
+ selected = Formula{ID, typeof(parameters), typeof(options)}(parameters, options)
+ expression = Expression(selected, insulation_impedance)
+ normalized = formulation_options(expression, options)
+ return Formula{ID, typeof(parameters), typeof(normalized)}(parameters, normalized)
+end
+
+@inline function (formula::InsulationImpedanceFormulation)(
+ r_in::T,
+ r_ex::T,
+ mu_r::T,
+ s::Complex{T}; workspace = nothing
+) where {T <: Real}
+ functor = Functor(formula, (; r_in, r_ex, mu_r, jω = s, options = formula.options);
+ workspace)
+ value = Expression(formula, insulation_impedance)(functor, workspace)
+ value isa Number && isfinite(value) || throw(DomainError(value,
+ "insulation_impedance must return a finite scalar"))
+ return value
+end
+
+Formula(identifier::Symbol; kwargs...) = Formula(Val(identifier); kwargs...)
+Formula(::Val{ID}; kwargs...) where {ID} = Formula{ID}(; kwargs...)
+Formula(selected::InsulationImpedanceFormulation) = selected
+
+function Formula(selection::FormulaDefinition{ID, Order}) where {ID, Order}
+ Order === :default || throw(ArgumentError("order applies only to equivalent_earth"))
+ selection.equivalent_earth === nothing || throw(ArgumentError(
+ "equivalent_earth applies only to external earth formulas"))
+ return Formula{ID}(; parameters = selection.parameters,
+ options = selection.options)
+end
+
+"""
+Return the stable identifier of an insulation-impedance formula.
+"""
+formula_id(::Formula{ID}) where {ID} = ID
+formula_id(::Type{<:Formula{ID}}) where {ID} = ID
+# Identity-only dispatch also describes retained selections without constructors.
+description(value::Formula; compact::Bool = false) = description(typeof(value); compact)
+formulation_options(value::Formula) = value.options
+
+"""
+$(TYPEDSIGNATURES)
+
+Expose the selected identity, model parameters, and numerical options as a native record.
+"""
+function Base.NamedTuple(value::Formula)
+ return (identifier=formula_id(value), parameters=value.parameters, options=value.options.data)
+end
+
+"""Iterate the independently selectable child slots admitted by this formula family."""
+Base.pairs(::Type{<:Formula}; quantity=nothing) = pairs((;))
diff --git a/src/engine/insulationimpedance/lossless.jl b/src/engine/insulationimpedance/lossless.jl
deleted file mode 100644
index b70e3a8ff..000000000
--- a/src/engine/insulationimpedance/lossless.jl
+++ /dev/null
@@ -1,22 +0,0 @@
-
-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/integration.jl b/src/engine/integration.jl
new file mode 100644
index 000000000..5c0acaf58
--- /dev/null
+++ b/src/engine/integration.jl
@@ -0,0 +1,166 @@
+"""
+$(TYPEDEF)
+
+Select a Sommerfeld-type integral over the spatial Fourier variable on `[0, Inf)`.
+The formula supplies the complete scalar integrand, including any weights and Jacobians.
+
+$(TYPEDFIELDS)
+"""
+struct SpectralIntegral{F} <: AbstractFormulation
+ "Complete integrand, evaluated at nonnegative real coordinates."
+ f::F
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Extend `buffers` with `quadrature`, the reusable QuadGK segments of the `:quad` solution.
+Coordinates have the real type of `T`, and integrand values have the type `Complex{T}`.
+`buffers` returns unchanged when it holds `quadrature` already. `integrate` reads the
+segments from this record.
+"""
+function initialize_buffers(::Type{SpectralIntegral}, ::Val{:quad}, ::Type{T}, input, plan,
+ buffers) where {T}
+ haskey(buffers, :quadrature) && return buffers
+ R = typeof(float(nominal(one(T))))
+ V = Complex{T}
+ size = 128
+ return merge(buffers, (quadrature = (
+ segments = alloc_segbuf(R, V, R; size),
+ seeds = alloc_segbuf(R, V, R; size),
+ seed = alloc_segbuf(R, V, R; size = 1),
+ prototype = zero(V),
+ points = sizehint!(R[], size),
+ mapped = sizehint!(R[], size),
+ warnings = nothing),))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate the complete callable over `[0, Inf)` with QuadGK. QuadGK takes its storage from
+`buffers.quadrature` when the caller passes `buffers`. Return
+`(value, estimated_error)`, where the error is the estimated absolute
+quadrature error in the same units as the integral.
+
+# Keywords
+
+- `points`: finite nonnegative subdivisions in the callable's coordinate.
+ The supplied collection is not mutated.
+- `coordinate_type`: real coordinate type when no buffers are supplied.
+ Defaults to `Float64`. Buffers supply their own coordinate type.
+- `context`: optional caller-provided description included in numerical warnings.
+- `observations`: optional trace vector receiving the native result and context.
+
+An unmet requested target produces a warning and returns the finite result.
+There is no outer retry or error-budget controller. Nonfinite integral values
+and actual quadrature failures remain errors.
+"""
+@inline function integrate(
+ integral::SpectralIntegral, ::Val{:quad}, controls, buffers = nothing;
+ points = (), coordinate_type::Type{C} = Float64, context = nothing,
+ observations = nothing) where {C <: AbstractFloat}
+ quadrature=buffers===nothing ? nothing : buffers.quadrature
+ R=quadrature===nothing ? coordinate_type : eltype(quadrature.points)
+ subdivisions=quadrature===nothing ? R[] : empty!(quadrature.points)
+ push!(subdivisions, zero(R))
+ for point in points
+ point isa Real && isfinite(point) && point>=0 ||
+ throw(DomainError(point, "integration points must be finite nonnegative real coordinates"))
+ push!(subdivisions, R(point))
+ end
+ all(isfinite, subdivisions) ||
+ throw(DomainError(subdivisions, "integration points must be finite in the coordinate type"))
+ unique!(sort!(subdivisions; alg = Base.Sort.QuickSort))
+ mapped=quadrature===nothing ? R[] : empty!(quadrature.mapped)
+ for point in subdivisions
+ push!(mapped, point/(one(R)+point))
+ end
+ push!(mapped, one(R))
+ unique!(sort!(mapped; alg = Base.Sort.QuickSort))
+ f=t->begin
+ den=inv(one(R)-t)
+ integral.f(t*den)*den^2
+ end
+ value,
+ error=if quadrature===nothing
+ quadgk(f, mapped; rtol = controls.rtol, atol = controls.atol,
+ maxevals = controls.maxevals, norm = numerical_magnitude)
+ else
+ # Public QuadGK evaluation creates reusable seeds in the same mapped
+ # coordinate as f. No dependency-private segment constructors are used.
+ seeds=empty!(quadrature.seeds)
+ sample=quadrature.prototype
+ for i in 1:(length(mapped) - 1)
+ quadgk(_->sample, mapped[i], mapped[i + 1];
+ segbuf = quadrature.seed, norm = numerical_magnitude)
+ append!(seeds, quadrature.seed)
+ end
+ quadgk(f, zero(R), one(R); rtol = controls.rtol, atol = controls.atol,
+ maxevals = controls.maxevals, segbuf = quadrature.segments,
+ eval_segbuf = seeds, norm = numerical_magnitude)
+ end
+ isfinite(value) || throw(DomainError(value, "QuadGK returned a nonfinite integral"))
+ record_integral!(observations, quadrature === nothing ? nothing : quadrature.warnings,
+ value, error, controls, context)
+ return value, error
+end
+
+# Actual and reused integrals report the same estimates and requested controls.
+function record_integral!(observations, warnings, value, error, controls, context)
+ observations === nothing ||
+ push!(observations, (; context, value, estimated_error = error))
+ target=max(controls.atol, controls.rtol*numerical_magnitude(value))
+ if !isfinite(error) || error>target
+ warnings === nothing ||
+ push!(warnings, (; context, value, estimated_error = error, controls))
+ @warn "QuadGK returned an estimated error above the requested target" value estimated_error=error target rtol=controls.rtol atol=controls.atol maxevals=controls.maxevals context
+ end
+ return nothing
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Normalize adaptive Gauss–Kronrod integration controls for `method=:quad`.
+Accepted controls are `rtol`, `atol`, and `maxevals`.
+"""
+function formulation_options(::Type{SpectralIntegral}, values::NamedTuple)
+ isempty(setdiff(keys(values), (:method, :options))) ||
+ throw(ArgumentError("integration accepts only method and options"))
+ method = get(values, :method, :quad)
+ method === :quad ||
+ throw(ArgumentError("integration method must be :quad"))
+ supplied = get(values, :options, (;))
+ supplied isa NamedTuple ||
+ throw(ArgumentError("integration options must be a named tuple"))
+ defaults = (rtol = 1e-8, atol = 0.0, maxevals = 10^7)
+ unknown_controls = setdiff(keys(supplied), keys(defaults))
+ isempty(unknown_controls) ||
+ throw(ArgumentError("unknown :$method integration controls: $(collect(unknown_controls))"))
+ controls = merge(defaults, supplied)
+ for key in (:rtol, :atol)
+ value = getproperty(controls, key)
+ value isa Real && isfinite(value) && value >= 0 ||
+ throw(ArgumentError("$key must be finite and nonnegative"))
+ end
+ controls.rtol > 0 || controls.atol > 0 ||
+ throw(ArgumentError("at least one integration tolerance must be positive"))
+ for key in setdiff(keys(controls), (:rtol, :atol))
+ value = getproperty(controls, key)
+ value isa Integer && !(value isa Bool) && value > 0 ||
+ throw(ArgumentError("$key must be a positive integer"))
+ end
+ return (method = Val(method), options = controls)
+end
+
+function formulation_options(::Expression, ::Val{:integration}, defaults::NamedTuple,
+ supplied::NamedTuple)
+ isempty(setdiff(keys(supplied), (:method, :options))) ||
+ throw(ArgumentError("integration accepts only method and options"))
+ get(supplied, :options, (;)) isa NamedTuple ||
+ throw(ArgumentError("integration options must be a NamedTuple"))
+ method = get(supplied, :method, defaults.method)
+ controls = merge(defaults.options, get(supplied, :options, (;)))
+ return formulation_options(SpectralIntegral, (; method, options = controls))
+end
diff --git a/src/engine/interfaces.jl b/src/engine/interfaces.jl
new file mode 100644
index 000000000..fa8a65390
--- /dev/null
+++ b/src/engine/interfaces.jl
@@ -0,0 +1,308 @@
+"""
+Return the series-impedance values of a line-parameter result.
+"""
+function Z end
+
+"""
+Return the shunt-admittance values of a line-parameter result.
+"""
+function Y end
+
+function X end
+function G end
+function B end
+function series_impedance end
+function shunt_admittance end
+function reactance end
+function conductance end
+function susceptance end
+function frequencies end
+function nconductors end
+function nfrequencies end
+"""
+Return absolute numerical errors from an owned comparison result.
+"""
+function absolute_error end
+"""
+Return reference-normalized numerical errors from an owned comparison result.
+"""
+function relative_error end
+
+"""
+Return electrical conductivity \\[S/m\\] from resistivity \\[Ω·m\\], including
+the open-circuit limit.
+"""
+@inline conductivity(rho) = isinf(rho) ? zero(rho) : inv(rho)
+
+"""
+Return a real scalar magnitude for numerical error estimates and physical-state
+comparisons. Deterministic values use absolute magnitude. Uncertainty extensions
+also account for uncertain contributions with zero nominal value. Evaluated
+physical quantities retain their correlations.
+"""
+@inline numerical_magnitude(z) = abs(complex(nominal(real(z)), nominal(imag(z))))
+
+# Equality of physical state includes correlation, not just nominal values.
+same_physical_state(a, b) = isequal(a, b)
+same_physical_state(a::BigFloat, b::BigFloat) = isequal(a, b)
+function same_physical_state(a::Number, b::Number)
+ a === b || (isequal(a, b) && iszero(numerical_magnitude(a-b)))
+end
+function same_physical_state(a::Tuple, b::Tuple)
+ # Tuple map preserves each field's type in heterogeneous physical records.
+ length(a) == length(b) && all(map(same_physical_state, a, b))
+end
+function same_physical_state(a::NamedTuple, b::NamedTuple)
+ keys(a) == keys(b) && same_physical_state(values(a), values(b))
+end
+function same_physical_state(a::AbstractArray, b::AbstractArray)
+ axes(a) == axes(b) && all(pair -> same_physical_state(pair...), zip(a, b))
+end
+
+"""
+Resolve a placed conductor's earth-layer index from the problem and coordinates
+[m], or read `(source, target)` indices from an initialized `EarthPair`. Air is
+layer 1. Subsequent indices retain the physical earth model's layer order.
+"""
+function layer_index end
+
+"""
+Resolve the real scalar representation needed by an active formulation before
+allocating numerical storage. Arguments of active formulations may widen `T`.
+Frequency-aligned arguments are
+validated by their scientific owner against `frequencies`.
+"""
+computation_type(::Type{T}, ::AbstractFormulation, frequencies) where {T <: Real} = T
+computation_type(::Type{T}, ::Nothing, frequencies) where {T <: Real} = T
+# Each selection of a record, and of a recipe within it, can widen `T`.
+function computation_type(::Type{T}, selections::NamedTuple, frequencies) where {T <: Real}
+ return foldl(values(selections); init = T) do scalar, selected
+ computation_type(scalar, selected, frequencies)
+ end
+end
+
+"""Identify the local formula selections that determine blueprint coefficients."""
+function blueprint_dependencies end
+
+"""Calculate the selected local shunt response while constructing a blueprint."""
+function internal_shunt_response end
+"""
+$(TYPEDSIGNATURES)
+
+Calculate the selected earth contributions at one frequency from completed material
+inputs: for every calculation of the earth plan, or for one calculation. Given a formula and
+the Functor of its calculation, return the physical matrices of the calculation after its
+parts, by quantity. A formula whose parts write the physical coefficients returns none. A
+coupled formula converts its parts' coefficients for both quantities in one calculation.
+Matrix rows are receivers and columns are sources. Impedance contributions are \\[Ω/m\\].
+Potential-coefficient contributions are \\[m/F\\].
+"""
+function earth! end
+function homogenize! end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate the material quantities required by a coaxial computation into its
+allocated buffers. Material-law methods receive the original material values.
+Field equations consume these completed quantities without reevaluating them.
+"""
+function materials! end
+
+"""
+Abstract tag for the physical domain represented by line-parameter matrices.
+"""
+abstract type LineParamsDomain end
+
+"""
+$(TYPEDEF)
+
+Store one earth-return matrix interaction and its resolved physical layers.
+
+$(TYPEDFIELDS)
+"""
+struct EarthPair{T <: Real}
+ "Destination row."
+ row::Int
+ "Destination column."
+ column::Int
+ "Source and target heights relative to the air-earth interface, in that order \\[m\\]."
+ heights::Tuple{T, T}
+ "Horizontal distance between conductor centers \\[m\\]."
+ separation::T
+ "Physical layer indices of the source and target conductors."
+ layers::Tuple{Int, Int}
+ "Conductor outer radius for a self interaction [m]. Nothing for a mutual. "
+ radius::Union{Nothing, T}
+end
+
+function EarthPair(row::Integer, column::Integer, heights::Tuple{T, T}, separation::T,
+ layers::Tuple{Int, Int}; radius = nothing) where {T <: Real}
+ return EarthPair{T}(row, column, heights, separation, layers, radius)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return `pair` on the earth that its calculation consumes. The layered earth model keeps the
+physical layers.
+"""
+EarthPair(pair::EarthPair, ::EarthModel) = pair
+
+"""
+$(TYPEDSIGNATURES)
+
+Return `pair` on an equivalent homogeneous earth: air stays layer 1, and every earth layer
+becomes layer 2.
+"""
+function EarthPair(pair::EarthPair, ::EquivalentHomogeneous.AbstractSequence)
+ return EarthPair(pair.row, pair.column, pair.heights, pair.separation,
+ map(layer -> layer == 1 ? 1 : 2, pair.layers); radius = pair.radius)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Check the resolved geometry of an earth-return interaction.
+
+# Arguments
+
+- `pair`: matrix indices and physical layer indices, with signed heights and
+ horizontal separation. An explicit self radius completes the geometry.
+ Lengths are in \\[m\\]. Layer 1 is air.
+
+# Returns
+
+- The same `pair`, without altering its geometry.
+
+# Errors
+
+- Throws `ArgumentError` for inconsistent indices or layer assignments.
+- Throws `DomainError` for nonfinite lengths, nonpositive self radius,
+ coincident distinct conductors, or conductors on the air-earth interface.
+"""
+function validate(pair::EarthPair)
+ pair.row > 0 && pair.column > 0 || throw(ArgumentError(
+ "earth-pair matrix indices must be positive"))
+ all(>(0), pair.layers) || throw(ArgumentError(
+ "earth-pair physical layer indices must be positive; layer 1 is air"))
+ all(isfinite, pair.heights) && isfinite(pair.separation) || throw(DomainError(
+ (pair.heights, pair.separation), "earth-pair lengths must be finite"))
+ pair.separation >= zero(pair.separation) || throw(DomainError(
+ pair.separation, "earth-pair horizontal separation must be nonnegative"))
+ for index in eachindex(pair.heights)
+ height = pair.heights[index]
+ iszero(height) && throw(DomainError(
+ height, "a conductor on the air-earth interface has no physical layer"))
+ (height > zero(height)) == (pair.layers[index] == 1) || throw(ArgumentError(
+ "earth-pair conductor $index height and physical layer disagree; layer 1 is air"))
+ end
+ if pair.row == pair.column
+ pair.heights[1] == pair.heights[2] && pair.layers[1] == pair.layers[2] ||
+ throw(ArgumentError("an earth self interaction must repeat the same conductor"))
+ iszero(pair.separation) ||
+ throw(ArgumentError("a self interaction has zero horizontal separation"))
+ pair.radius !== nothing && isfinite(pair.radius) && pair.radius > 0 ||
+ throw(DomainError(pair.radius, "a self interaction requires an explicit positive conductor radius"))
+ else
+ pair.radius === nothing ||
+ throw(ArgumentError("a mutual interaction has no self radius"))
+ iszero(pair.separation) && pair.heights[1] == pair.heights[2] &&
+ throw(DomainError((pair.heights, pair.separation),
+ "distinct earth-return conductors cannot have coincident centers"))
+ end
+ return pair
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Check conductor depths against their resolved horizontal earth layers.
+
+# Arguments
+
+- `pair`: resolved earth-return geometry.
+- `thickness`: layer thicknesses \\[m\\], including semi-infinite air first.
+
+# Returns
+
+- The same `pair`.
+
+# Errors
+
+- Throws `ArgumentError` when a layer index or conductor depth disagrees with
+ the supplied horizontal layer inventory.
+"""
+function validate(pair::EarthPair, thickness::Union{Tuple, AbstractVector})
+ validate(pair)
+ for position in eachindex(pair.layers)
+ layer = pair.layers[position]
+ layer <= length(thickness) || throw(ArgumentError(
+ "earth-pair conductor $position refers to absent physical layer $layer"))
+ layer == 1 && continue
+ depth = -pair.heights[position]
+ top = sum((thickness[index] for index in 2:(layer - 1)); init = zero(depth))
+ local_depth = depth - top
+ local_depth >= zero(depth) &&
+ (!isfinite(thickness[layer]) || local_depth <= thickness[layer]) ||
+ throw(ArgumentError(
+ "earth-pair conductor $position depth $depth m is outside its resolved earth layer $layer"))
+ end
+ return pair
+end
+
+"""
+Tag line parameters expressed in the physical phase domain.
+"""
+struct PhaseDomain <: LineParamsDomain end
+"""
+Supertype of the modal-to-phase voltage and current bases of a modal transformation.
+A concrete subtype implements `Base.size`, which returns the
+`(modes, modes, frequencies)` dimensions of its operator tensors.
+"""
+abstract type AbstractModalOperators end
+
+"""
+Store the coordinate system of a calculated modal transformation.
+
+The operator tensor type parameterizes the domain because inverse transforms
+consume it numerically. The defining transform module may use one formula-family
+parameter to record the selected formula, so different concrete formula identities can
+share one concrete element type for the result space.
+"""
+struct ModalDomain{O, G} <: LineParamsDomain
+ "Frequency-dependent modal-to-phase voltage and current bases."
+ operators::O
+ "Aligned propagation roots in the coefficient basis."
+ gamma::G
+
+ function ModalDomain(operators::O, gamma::G) where {O, G}
+ return validate(new{O, G}(operators, gamma))
+ end
+end
+
+function validate(domain::ModalDomain)
+ domain.gamma isa AbstractMatrix || throw(DimensionMismatch(
+ "modal roots must be a mode×frequency matrix"))
+ domain.operators isa AbstractModalOperators || throw(ArgumentError(
+ "modal domain requires a validated ModalOperators value"))
+ dimensions=size(domain.operators)
+ size(domain.gamma)==(dimensions[2], dimensions[3]) || throw(DimensionMismatch(
+ "modal roots must align with operator modes and frequency samples"))
+ return domain
+end
+
+"""
+Return a domain value restricted to selected frequency samples.
+"""
+selectdomain(domain::LineParamsDomain, _) = domain
+selectdetails(details, ::LineParamsDomain, _) = details
+
+@inline domain(::Type{PhaseDomain}) = PhaseDomain
+@inline domain(::Type{<:ModalDomain}) = ModalDomain
+
+"""
+Return the domain tag type of a value, or `nothing` when it has no domain.
+"""
+@inline domain(::Type) = nothing
+@inline domain(value) = domain(typeof(value))
diff --git a/src/engine/internalimpedance/InternalImpedance.jl b/src/engine/internalimpedance/InternalImpedance.jl
index 6d839dd86..4b49552e7 100644
--- a/src/engine/internalimpedance/InternalImpedance.jl
+++ b/src/engine/internalimpedance/InternalImpedance.jl
@@ -1,29 +1,46 @@
"""
- LineCableModels.Engine.InternalImpedance
+ LineCableModels.Engine.InternalImpedance
+
+Define conductor internal-impedance recipes and their electromagnetic
+interaction formulas.
# Dependencies
$(IMPORTS)
-# Exports
-
-$(EXPORTS)
"""
module InternalImpedance
+import ...Commons: FormulationOptions, Functor, formulas
# Export public API
-export ScaledBessel
+export Formula, formula_id, formulas, internal_impedance, surface_impedances
# Module-specific dependencies
-using ...Commons
-import ...Commons: get_description
-import ..Engine: InternalImpedanceFormulation
-using Measurements
-using LinearAlgebra
-using ...UncertainBessels: besselix, besselkx
-using ...Utils: _to_σ
+#! explicit-imports: off
+# These abbreviations are expanded in this module docstring and included files.
+using DocStringExtensions: IMPORTS, TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+#! explicit-imports: on
+import ..Engine: InternalImpedanceFormulation, formula_id, formulation_options
+#! explicit-imports: off
+import ...LineCableModels: FormulaDefinition, Expression
+import ..Engine: description, conductivity
+import ..Engine: special_besselix, special_besselkx
+using ...Commons: vacuum_permeability
+#! explicit-imports: on
+
+include("interface.jl")
+
+#! explicit-imports: off
+const FORMULAS = (
+ include("formulas/default.jl"),
+ include("formulas/schelkunoff1934.jl"),
+ include("formulas/wedepohl1973.jl"),
+)
+#! explicit-imports: on
-include("scaledbessel.jl")
+"""
+Return the built-in internal-impedance formula identifiers.
+"""
+formulas(::Type{<:Formula}) = FORMULAS
end # module InternalImpedance
-
diff --git a/src/engine/internalimpedance/formulas/default.jl b/src/engine/internalimpedance/formulas/default.jl
new file mode 100644
index 000000000..87ebd8229
--- /dev/null
+++ b/src/engine/internalimpedance/formulas/default.jl
@@ -0,0 +1,12 @@
+"""
+$(TYPEDSIGNATURES)
+
+Route the default selection to the explicit `:schelkunoff1934` implementation.
+No numerical equation is owned by `:default`.
+"""
+description(::Type{<:Formula{:default}}; compact::Bool=false) =
+ compact ? "Default" : "Default routing to :schelkunoff1934"
+
+Formula{:default}(; kwargs...) = Formula{:schelkunoff1934}(; kwargs...)
+
+:default
diff --git a/src/engine/internalimpedance/formulas/schelkunoff1934.jl b/src/engine/internalimpedance/formulas/schelkunoff1934.jl
new file mode 100644
index 000000000..48a04bcc0
--- /dev/null
+++ b/src/engine/internalimpedance/formulas/schelkunoff1934.jl
@@ -0,0 +1,187 @@
+
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Exact cylindrical surface impedances for a solid or
+hollow round conductor.
+
+**Expression.**
+
+```math
+\\begin{aligned}
+Z_{is}&=\\frac{\\rho m}{2\\pi aD}
+[I_0(ma)K_1(mb)+K_0(ma)I_1(mb)],\\\\
+Z_{os}&=\\frac{\\rho m}{2\\pi bD}
+[I_0(mb)K_1(ma)+K_0(mb)I_1(ma)],\\\\
+Z_{ms}&=\\frac{\\rho m}{2\\pi abD},\\\\
+D&=I_1(mb)K_1(ma)-K_1(mb)I_1(ma).
+\\end{aligned}
+```
+
+Here ``a`` and ``b`` are the inner and outer conductor radii in meters,
+``ρ`` is resistivity in Ω·m, and ``m=\\sqrt{jωμ/ρ}`` is in m⁻¹, with
+``μ=μ_0μ_r`` in H/m. ``I_ν`` and ``K_ν`` are modified Bessel functions.
+Each surface impedance is in Ω/m.
+
+For ``a=0``, ``Z_{int}=\\rho mI_0(mb)/(2\\pi bI_1(mb))``.
+
+Schelkunoff's surface terms were later recovered by Ametani to assemble the
+complete core-sheath-armor impedance matrix. The Engine applies that outward
+assembly recursively to any number of concentric conductive terminals.
+
+**Reference.** S. A. Schelkunoff, “The Electromagnetic Theory of Coaxial
+Transmission Lines and Cylindrical Shields,” *Bell System Technical Journal*,
+13, 532–579, 1934. A. Ametani, “A General Formulation of Impedance and
+Admittance of Cables,” *IEEE Transactions on Power Apparatus and Systems*,
+PAS-99(3), 902–910, 1980. DOI: 10.1109/TPAS.1980.319718.
+
+"""
+function description(::Type{<:Formula{:schelkunoff1934}}; compact::Bool=false)
+ compact ? "Schelkunoff" : "Schelkunoff exact round-conductor surface impedances (1934)"
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the Functor of one solid or hollow circular conductor at one frequency for the exact
+Schelkunoff surface impedances:
+
+```math
+Z_{is}=\\frac{\\rho m}{2\\pi aD}
+\\left[I_0(ma)K_1(mb)+K_0(ma)I_1(mb)\\right],
+\\qquad
+Z_{ms}=\\frac{\\rho m}{2\\pi abD},
+```
+
+```math
+Z_{os}=\\frac{\\rho m}{2\\pi bD}
+\\left[I_0(mb)K_1(ma)+K_0(mb)I_1(ma)\\right],
+```
+
+where
+
+```math
+D=I_1(mb)K_1(ma)-K_1(mb)I_1(ma),
+\\qquad
+m=\\sqrt{j\\omega\\mu/\\rho}.
+```
+
+Here ``μ=μ_0μ_r`` is absolute permeability in H/m, and ``I_ν`` and ``K_ν``
+are modified Bessel functions. For ``a=0``, the outer term is evaluated from the solid-cylinder limit
+``Z_{int}=\\rho m I_0(mb)/(2\\pi b I_1(mb))``.
+
+# Input
+
+- `r_in`: inner conductor radius ``a`` \\[m\\].
+- `r_ex`: outer conductor radius ``b`` \\[m\\].
+- `rho`: conductor resistivity ``\\rho`` \\[Ω·m\\].
+- `mu_r`: relative conductor permeability \\[dimensionless\\].
+- `jω`: complex angular frequency ``j\\omega`` \\[rad/s\\].
+
+# Returns
+
+- The Functor of the conductor at that frequency. Its state stores the scaled modified
+ Bessel functions ``I_0``, ``I_1``, ``K_0`` and ``K_1`` at ``ma`` and ``mb``, evaluated once
+ for the inner, outer and transfer surfaces. A solid conductor evaluates only ``I_0(mb)``
+ and ``I_1(mb)``.
+
+# Notes
+
+Implements Schelkunoff's cylindrical surface terms. Ametani (1980) recovered
+these terms for the complete core-sheath-armor impedance assembly performed
+recursively by the Engine.
+"""
+function Functor(formula::Formula{:schelkunoff1934}, input::NamedTuple; workspace = nothing)
+ (; r_in, r_ex, rho, mu_r, jω) = input
+ T = typeof(r_in)
+ isfinite(r_in) && isfinite(r_ex) && zero(T) <= r_in < r_ex ||
+ throw(DomainError((r_in, r_ex), "conductor radii must satisfy 0 ≤ r_in < r_ex [m]"))
+ isfinite(rho) && rho > zero(T) && isfinite(mu_r) && mu_r > zero(T) ||
+ throw(DomainError((rho, mu_r), "conductor resistivity and permeability must be positive and finite"))
+ isfinite(jω) && !iszero(jω) || throw(DomainError(jω, "jω must be finite and nonzero"))
+ mu_c = vacuum_permeability(T) * mu_r
+ sigma_c = conductivity(rho)
+ m = sqrt(jω * mu_c * sigma_c)
+ w_ex = m * r_ex
+ w_in = m * r_in
+ i0_ex = special_besselix(0, w_ex)
+ i1_ex = special_besselix(1, w_ex)
+ # A solid conductor has no inner surface, and K at zero would be infinite. Its values
+ # that the outer surface does not read are zero, so the state type does not depend on
+ # the geometry.
+ absent = zero(i0_ex)
+ sc_ex, sc, i0_in, i1_in, k0_in, k1_in, k0_ex, k1_ex =
+ if isapprox(r_in, zero(T); atol = eps(T))
+ ntuple(_ -> absent, 8)
+ else
+ sc_in = exp(abs(real(w_in)) - w_ex)
+ sc_out = exp(abs(real(w_ex)) - w_in)
+ (sc_out, sc_in / sc_out, special_besselix(0, w_in), special_besselix(1, w_in),
+ special_besselkx(0, w_in), special_besselkx(1, w_in),
+ special_besselkx(0, w_ex), special_besselkx(1, w_ex))
+ end
+ state = (; mu_c, sigma_c, m, w_in, w_ex, sc_ex, sc,
+ i0_in, i1_in, k0_in, k1_in, i0_ex, i1_ex, k0_ex, k1_ex)
+ return Functor(formula, input, state)
+end
+
+@inline function internal_impedance(
+ ::Formula{:schelkunoff1934},
+ ::Val{:inner},
+ functor, workspace
+)
+ input, state = functor.input, functor.state
+ T = typeof(input.r_in)
+ if isapprox(input.r_in, zero(T); atol = eps(T))
+ return zero(Complex{T})
+ end
+
+ numerator = state.k0_in * state.i1_ex + state.sc * state.i0_in * state.k1_ex
+ denominator = state.k1_in * state.i1_ex - state.sc * state.i1_in * state.k1_ex
+ return Complex{T}(
+ (input.jω * state.mu_c / 2π) * (1 / state.w_in) * (numerator / denominator)
+ )
+end
+
+@inline function internal_impedance(
+ ::Formula{:schelkunoff1934},
+ ::Val{:outer},
+ functor, workspace
+)
+ input, state = functor.input, functor.state
+ T = typeof(input.r_in)
+ if isapprox(input.r_in, zero(T); atol = eps(T))
+ numerator = state.i0_ex
+ denominator = state.i1_ex
+ else
+ numerator = state.i0_ex * state.k1_in + state.sc * state.k0_ex * state.i1_in
+ denominator = state.i1_ex * state.k1_in - state.sc * state.k1_ex * state.i1_in
+ end
+ return Complex{T}(
+ (input.jω * state.mu_c / 2π) * (1 / state.w_ex) *
+ (numerator / denominator)
+ )
+end
+
+@inline function internal_impedance(
+ ::Formula{:schelkunoff1934},
+ ::Val{:transfer},
+ functor, workspace
+)
+ input, state = functor.input, functor.state
+ T = typeof(input.r_in)
+ if isapprox(input.r_in, zero(T); atol = eps(T))
+ return zero(Complex{T})
+ end
+
+ numerator = one(state.sc_ex) / state.sc_ex
+ denominator = state.i1_ex * state.k1_in - state.sc * state.i1_in * state.k1_ex
+ return Complex{T}(
+ (1 / (2π * input.r_in * input.r_ex * state.sigma_c)) *
+ (numerator / denominator)
+ )
+end
+
+formulation_options(::Expression{<:Formula{:schelkunoff1934}, typeof(internal_impedance)}) = FormulationOptions()
+
+:schelkunoff1934
diff --git a/src/engine/internalimpedance/formulas/wedepohl1973.jl b/src/engine/internalimpedance/formulas/wedepohl1973.jl
new file mode 100644
index 000000000..0f6181771
--- /dev/null
+++ b/src/engine/internalimpedance/formulas/wedepohl1973.jl
@@ -0,0 +1,25 @@
+"""
+$(TYPEDSIGNATURES)
+
+Identify the Wedepohl-Wilcox analytical approximations for the inner, outer,
+and transfer surface impedances of a round conductor, in Ω/m. A solid cylinder
+requires only the outer coefficient. An annulus uses all three coefficients.
+
+This scientific identity is registered for backend dispatch. The owned coaxial backend has
+no expression for it, so selecting it there fails before evaluation.
+PSCAD's line-constants program uses these approximations for conductor surfaces.
+
+Reference: L. M. Wedepohl and D. J. Wilcox, “Transient Analysis of Underground
+Power-Transmission Systems: System-Model and Wave-Propagation Characteristics,”
+*Proceedings of the IEE*, 120, 253–260, 1973. DOI: 10.1049/piee.1973.0056.
+PSCAD 5.1 help reproduces the surface approximations in *Deriving System Y and Z
+Matrices*, Eqs. (8-8), (8-9), and (8-11)-(8-13).
+"""
+function description(::Type{<:Formula{:wedepohl1973}}; compact::Bool = false)
+ compact ? "Wedepohl" : "Wedepohl-Wilcox round-conductor surface impedances (1973)"
+end
+
+formulation_options(::Expression{<:Formula{:wedepohl1973}, typeof(internal_impedance)}) =
+ FormulationOptions()
+
+:wedepohl1973
diff --git a/src/engine/internalimpedance/interface.jl b/src/engine/internalimpedance/interface.jl
new file mode 100644
index 000000000..03590e9a1
--- /dev/null
+++ b/src/engine/internalimpedance/interface.jl
@@ -0,0 +1,117 @@
+"""
+$(TYPEDEF)
+
+Select cylindrical surface equations and their model and numerical controls.
+The actual conductor geometry determines which surfaces are required.
+
+$(TYPEDFIELDS)
+"""
+struct Formula{ID, P <: NamedTuple, O <: FormulationOptions} <: InternalImpedanceFormulation
+ "Resolved physical model parameters."
+ parameters::P
+ "Normalized numerical sections indexed by surface kind."
+ options::O
+end
+
+"""Evaluate a selected cylindrical surface coefficient in Ω/m."""
+function internal_impedance end
+
+"""Evaluate required cylindrical surface impedances in Ω/m."""
+function surface_impedances end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct an internal-impedance formulation. Numerical controls are projected
+onto its surface equations. Unknown numerical sections are rejected.
+A custom formulation subtypes `InternalImpedanceFormulation` and extends
+`internal_impedance` on its own type. Its `Functor` method builds the values shared by its
+surface impedances.
+"""
+function Formula{ID}(; parameters::NamedTuple=(;), options::Union{NamedTuple, FormulationOptions} = FormulationOptions()) where {ID}
+ ID in formulas(Formula) || throw(ArgumentError("unknown internal-impedance formula :$ID"))
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ isempty(parameters) || throw(ArgumentError("internal impedance :$ID has no model parameters"))
+ kinds = (:inner, :outer, :transfer)
+ selected = Formula{ID, typeof(parameters), typeof(options)}(parameters, options)
+ expressions = map(kind -> Expression(selected, internal_impedance, Val(kind)), kinds)
+ projected = formulation_options(selected, expressions)
+ normalized = FormulationOptions(NamedTuple{kinds}(map(expressions) do expression
+ projected.options[findfirst(==(expression), projected.expressions)].data
+ end))
+ return Formula{ID, typeof(parameters), typeof(normalized)}(parameters, normalized)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate cylindrical surface coefficients in Ω/m. The wall operator is
+`[inner transfer; transfer outer]` in the surface-current basis
+`(-enclosed axial current, total axial current including the wall)`.
+Assemblers own basis transformation and matrix placement.
+
+# Arguments
+
+- `formula`: the selected formulation.
+- `r_in`, `r_ex`: inner and outer conductor radii \\[m\\].
+- `rho`: conductor resistivity \\[Ω·m\\].
+- `mu_r`: relative permeability \\[dimensionless\\].
+- `jω`: imaginary angular frequency \\[1/s\\].
+- `workspace`: optional computation workspace passed to each selected equation.
+
+# Returns
+
+- NamedTuple of `outer` for a solid primitive, or `inner`, `outer`, and
+ `transfer` for a tubular primitive \\[Ω/m\\].
+"""
+function surface_impedances(formula::InternalImpedanceFormulation, r_in, r_ex, rho, mu_r, jω;
+ workspace=nothing)
+ return surface_impedances(formula,
+ r_in > 0 ? Val((:inner,:outer,:transfer)) : Val((:outer,)),
+ r_in, r_ex, rho, mu_r, jω; workspace)
+end
+
+"""
+Evaluate the surface impedances that `Kinds` names. The formula's `Functor` stores the values
+shared by its surface impedances, built once per conductor and frequency. Each surface
+impedance evaluates with the options of its own section.
+"""
+@inline function surface_impedances(formula::InternalImpedanceFormulation, ::Val{Kinds},
+ r_in, r_ex, rho, mu_r, jω; workspace=nothing) where {Kinds}
+ length(Kinds) in (1, 3) ||
+ throw(ArgumentError("internal surfaces require one or three kinds"))
+ functor = Functor(formula, (; r_in, r_ex, rho, mu_r, jω); workspace)
+ values = map(map(Val, Kinds)) do kind
+ options = formulation_options(formula, kind)
+ value = Expression(formula, internal_impedance, kind)(
+ Functor(functor, (; options)), workspace)
+ value isa Number && isfinite(value) || throw(DomainError(value,
+ "internal_impedance must return a finite surface coefficient [Ω/m]"))
+ value
+ end
+ return NamedTuple{Kinds}(values)
+end
+
+Formula(identifier::Symbol; kwargs...) = Formula(Val(identifier); kwargs...)
+Formula(::Val{ID}; kwargs...) where {ID} = Formula{ID}(; kwargs...)
+Formula(selected::InternalImpedanceFormulation) = selected
+Formula(::Nothing) = Formula(:default)
+
+function Formula(selection::FormulaDefinition{ID, Order}) where {ID, Order}
+ Order === :default || throw(ArgumentError("order applies only to equivalent_earth"))
+ selection.equivalent_earth === nothing || throw(ArgumentError(
+ "equivalent_earth applies only to external earth formulas"))
+ return Formula{ID}(; parameters=selection.parameters, options=selection.options)
+end
+
+"""Return the stable identifier of a selected internal-impedance formulation."""
+formula_id(::Formula{ID}) where {ID} = ID
+formula_id(::Type{<:Formula{ID}}) where {ID} = ID
+description(value::Formula; compact::Bool=false) = description(typeof(value); compact)
+formulation_options(value::Formula) = value.options
+
+"""Expose the selected identity, model parameters and numerical controls."""
+Base.NamedTuple(value::Formula) = (identifier=formula_id(value),
+ parameters=value.parameters, options=value.options.data)
+
+Base.pairs(::Type{<:Formula}; quantity=nothing) = pairs((;))
diff --git a/src/engine/internalimpedance/scaledbessel.jl b/src/engine/internalimpedance/scaledbessel.jl
deleted file mode 100644
index 8dafd7171..000000000
--- a/src/engine/internalimpedance/scaledbessel.jl
+++ /dev/null
@@ -1,157 +0,0 @@
-
-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/lineparameters.jl b/src/engine/lineparameters.jl
new file mode 100644
index 000000000..9884af0ce
--- /dev/null
+++ b/src/engine/lineparameters.jl
@@ -0,0 +1,524 @@
+# LineParameters computation remains independent from CableConstants.
+
+@inline _stash!(::Nothing, ::Symbol, ::Int, ::AbstractMatrix) = nothing
+
+@inline function _stash!(trace::NamedTuple, name::Symbol, frequency::Int, source::AbstractMatrix)
+ destination = getproperty(trace, name)
+ @views copyto!(destination[:, :, frequency], source)
+ return nothing
+end
+
+function _solve!(
+ workspace::LineParametersWorkspace{T},
+ formulation::LineParametersFormulation,
+ calculations::Tuple = workspace.plan.earth.calculations,
+ materials::Tuple = workspace.buffers.earth.calculations
+) where {T <: Real}
+ input = workspace.input
+ plan = workspace.plan
+ buffers = workspace.buffers
+ workspace.trace===nothing || empty!(workspace.trace.integrals)
+ Zprimitive = buffers.Zprimitive
+ Pprimitive = buffers.Pprimitive
+ Zout = buffers.Zout
+ Yout = buffers.Yout
+
+ materials!(workspace, formulation)
+ @debug "Starting line parameters computation"
+ for frequency in 1:input.n_frequencies
+ materials!(workspace, formulation, frequency, calculations, materials)
+ cable_impedance!(Zprimitive, input.cable, buffers.rho_cond,
+ formulation.methods, input.jω[frequency]; workspace)
+ cable_potential!(Pprimitive, input.cable, buffers.dielectric_admittivity,
+ input.jω[frequency], buffers.layer_coefficients, buffers.coefficients, buffers.tails)
+ _stash!(workspace.trace, :Zin, frequency, Zprimitive)
+ _stash!(workspace.trace, :Pin, frequency, Pprimitive)
+ earth!(workspace, frequency, calculations, materials)
+ _stash!(workspace.trace, :Zg, frequency, buffers.Zearth)
+ _stash!(workspace.trace, :Pg, frequency, buffers.Pearth)
+ impedance!(Zprimitive, workspace, frequency)
+ admittance!(Pprimitive, workspace, frequency)
+ reduce_line_matrices!(view(Zout, :, :, frequency), view(Yout, :, :, frequency),
+ Zprimitive, Pprimitive, input.jω[frequency], plan.reduction, buffers.reduction)
+ end
+
+ return workspace
+end
+
+function _retained_details(workspace::LineParametersWorkspace{
+ <:Real, <:NamedTuple, <:NamedTuple,
+ <:NamedTuple, Nothing})
+ # Local model diagnostics vary with geometry and selection, not the result type.
+ ComputationDetails(NamedTuple{(:shunt_model,), Tuple{NamedTuple}}((workspace.input.cable.shunt_details,)))
+end
+
+function _retained_details(workspace::LineParametersWorkspace)
+ trace = workspace.trace
+ shunt = NamedTuple{(:shunt_model,), Tuple{NamedTuple}}((workspace.input.cable.shunt_details,))
+ trace === nothing && return ComputationDetails(shunt)
+ input = workspace.input
+ return ComputationDetails(merge(shunt,
+ (
+ trace = (
+ phase_map = copy(input.phase_map),
+ cable_map = copy(input.cable_map),
+ Zin = copy(trace.Zin),
+ Pin = copy(trace.Pin),
+ Zg = copy(trace.Zg),
+ Pg = copy(trace.Pg),
+ Z = copy(trace.Z),
+ P = copy(trace.P),
+ integrals = copy(trace.integrals)
+ ),
+ )))
+end
+
+function _finish(
+ workspace::LineParametersWorkspace,
+ problem::LineParametersProblem,
+ formulation::LineParametersFormulation,
+ ::Val{Basis};
+ physical_inputs=completed_inputs(problem),
+ gridpoint=Commons.gridpoint_id()
+) where {Basis}
+ impedance = copy(workspace.buffers.Zout)
+ admittance = copy(workspace.buffers.Yout)
+ if Basis === :total
+ impedance .*= workspace.input.line_length
+ admittance .*= workspace.input.line_length
+ end
+ all(isfinite, impedance) || throw(DomainError(impedance,
+ "completed series impedance must contain only finite entries"))
+ all(isfinite, admittance) || throw(DomainError(admittance,
+ "completed shunt admittance must contain only finite entries"))
+ retained = _retained_details(workspace)
+ names=["cable:$(terminal.cable):$(terminal.terminal)"
+ for terminal in problem.system.terminal_order]
+ coordinates=map(workspace.plan.reduction.indices) do index
+ phase=problem.system.connection_order[index]
+ members=findall(==(phase), problem.system.connection_order)
+ formulation.options.data.reduce_bundle && phase > 0 && length(members) > 1 ?
+ "bundle:[" * join(names[members], ",") * "]" : names[index]
+ end
+ result = LineParameters(PhaseDomain,
+ SeriesImpedance{eltype(impedance), Basis}(impedance),
+ ShuntAdmittance{eltype(admittance), Basis}(admittance),
+ workspace.input.freq,
+ completion_details(merge(retained.data,
+ completed_formulation(formulation, workspace),
+ NamedTuple{(:inputs,:gridpoint,:coordinates),Tuple{NamedTuple,NamedTuple,Vector{String}}}(
+ (physical_inputs,gridpoint,coordinates)))))
+ return result
+end
+
+function _compute(
+ engine::LineCableModelsCoaxial,
+ problem::LineParametersProblem,
+ formulation::LineParametersFormulation,
+ execution::ComputationOptions,
+ input::NamedTuple,
+ physical_inputs::NamedTuple,
+ gridpoint::NamedTuple
+)
+ workspace = LineParametersWorkspace(problem, formulation, execution, input)
+ _solve!(workspace, formulation)
+ return _finish(workspace, problem, formulation, execution.data.output_basis;
+ physical_inputs, gridpoint)
+end
+
+function _compute(
+ engine::LineCableModelsCoaxial,
+ problem::LineParametersProblem,
+ formulation::LineParametersFormulation,
+ execution::ComputationOptions,
+ timing::Val
+)
+ values = _compute(
+ engine,
+ problem,
+ typeof(formulation)[formulation],
+ execution,
+ timing
+ )
+ return first(values)
+end
+
+function _compute(
+ engine::LineCableModelsCoaxial,
+ problem::LineParametersProblem,
+ formulations::AbstractVector{<:LineParametersFormulation},
+ execution::ComputationOptions,
+ timing::Val
+)
+ isempty(formulations) && throw(ArgumentError(
+ "line-parameter formulation collections cannot be empty",
+ ))
+ progress = verbosity(execution, :progress) > 0
+ started = progress ? time_ns() : UInt64(0)
+ last_log = started
+ previous_completion = started
+ average_seconds = 0.0
+ progress && @info "Line parameters computation started" _group=:progress total=length(formulations)
+ validate(problem)
+ for design in problem.system.designs, formulation in formulations
+
+ validate(design, formulation.methods.pipe_impedance, engine)
+ end
+ maximum(problem.frequencies) > oftype(first(problem.frequencies), 1e8) &&
+ @warn("Frequencies above 100 MHz exceed the quasi-TEM validity range.",
+ max_frequency=maximum(problem.frequencies),)
+ physical_inputs = completed_inputs(problem)
+ source_id = Commons.gridpoint_id().source_id
+ T = eltype(problem)
+ blueprints = flatten(engine, problem.system.designs, T, formulations)
+ inputs = [lineinput(problem, first(blueprints))]
+ for index in 2:length(blueprints)
+ previous = findfirst(other -> other === blueprints[index], blueprints)
+ push!(inputs, previous < index ? inputs[previous] :
+ lineinput(problem, blueprints[index]))
+ end
+ values = map(formulations, inputs, eachindex(formulations)) do formulation, input, index
+ gridpoint = Commons.gridpoint_id(; source_id, formulation_index=index)
+ # Measure a full scan from workspace creation through the solve and result validation.
+ # Shared input and workspace construction, attachment, callbacks and progress are excluded.
+ value = if timing isa Val{true}
+ measured = Base.@timed _compute(engine, problem, formulation, execution,
+ input, physical_inputs, gridpoint)
+ retain_gridpoint(measured.value, gridpoint; fields=(timing=(
+ wall_seconds=measured.time, bytes=measured.bytes,
+ gc_seconds=measured.gctime, compile_seconds=measured.compile_time,
+ recompile_seconds=measured.recompile_time),))
+ else
+ _compute(
+ engine,
+ problem,
+ formulation,
+ execution,
+ input,
+ physical_inputs,
+ gridpoint
+ )
+ end
+ execution.data.on_result === nothing ||
+ execution.data.on_result(problem, index, value)
+ if progress
+ now = time_ns()
+ interval = (now - previous_completion) * 1e-9
+ average_seconds = index == 1 ? interval : 0.2 * interval + 0.8 * average_seconds
+ previous_completion = now
+ if now - last_log >= 5_000_000_000
+ @info "Line parameters progress" _group=:progress completed=index total=length(formulations) elapsed_seconds=(now-started)*1e-9 eta_hours=(length(formulations)-index)*average_seconds/3600
+ last_log = now
+ end
+ end
+ value
+ end
+ progress && @info "Line parameters computation completed successfully" _group=:progress completed=length(values) total=length(formulations) elapsed_seconds=(time_ns()-started)*1e-9
+ return values
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compute line parameters with the coaxial backend and default formulation.
+
+# Arguments
+
+- `problem`: completed line-parameter problem.
+
+# Keywords
+
+- `options`: coaxial-backend computation options.
+
+# Returns
+
+- One [`LineParameters`](@ref) result.
+"""
+Base.@constprop :aggressive function compute(
+ problem::LineParametersProblem;
+ options::Union{NamedTuple, ComputationOptions} = ComputationOptions(),
+ modal=nothing, modal_options::Union{NamedTuple, ComputationOptions}=ComputationOptions()
+)
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ modal===nothing || return compute(problem,Formulation(),
+ ModalAnalysis.ModalAnalysisFormulation(modal);options,modal_options)
+ isempty(modal_options isa NamedTuple ? modal_options : modal_options.data) ||
+ throw(ArgumentError("modal_options require a modal formulation"))
+ return compute(LineCableModelsCoaxial(), problem, Formulation(); options)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compute frequency-dependent line parameters with the coaxial backend.
+
+The completed data model supplies the equivalent concentric representation
+used for series impedance and ordinary radial dielectric intervals. Eligible
+open wire and tape domains retain their physical geometry for the explicitly selected
+`shunt_model=:boundary` calculation. The default uses annular geometry. The
+physical system is normalized once into a backend-owned
+workspace, and all reusable numerical storage is allocated before the frequency
+loop. `trace=true` copies `Zin`, `Pin`, `Zg`, `Pg`, `Z`, `P`, `phase_map`,
+`cable_map` and the integration records into `details(result).data.trace`.
+These arrays remain available after the workspace is reused. The result type
+remains unchanged.
+
+# Arguments
+
+- `problem`: completed line-parameter problem.
+- `formulation`: selected line-parameter physical methods.
+
+# Keywords
+
+- `options`: named tuple containing `verbosity`, `output_basis`, `trace`, and
+ `on_result`. The optional callable `on_result(problem, index, result)` runs
+ synchronously after each completed formulation and
+ before the next computation. `index` is local to the formulation collection
+ (`1` for a scalar call). Its return value is ignored. Exceptions propagate.
+ The callback must not mutate the problem or result. The default is `nothing`.
+ The compute call constructs the selected local shunt coefficients in the
+ cable blueprints without an additional execution option.
+
+# Returns
+
+- One [`LineParameters`](@ref) result.
+"""
+Base.@constprop :aggressive function compute(
+ problem::LineParametersProblem,
+ formulation::LineParametersFormulation;
+ options::Union{NamedTuple, ComputationOptions} = ComputationOptions(),
+ modal=nothing, modal_options::Union{NamedTuple, ComputationOptions}=ComputationOptions()
+)
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ modal===nothing || return compute(problem,formulation,
+ ModalAnalysis.ModalAnalysisFormulation(modal);options,modal_options)
+ isempty(modal_options isa NamedTuple ? modal_options : modal_options.data) ||
+ throw(ArgumentError("modal_options require a modal formulation"))
+ return compute(LineCableModelsCoaxial(), problem, formulation; options)
+end
+
+function compute(
+ problem::LineParametersProblem,
+ formulations::AbstractVector{<:LineParametersFormulation};
+ options::Union{NamedTuple, ComputationOptions} = ComputationOptions(),
+ modal=nothing, modal_options::Union{NamedTuple, ComputationOptions}=ComputationOptions()
+)
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ modal===nothing || return compute(problem,formulations,
+ ModalAnalysis.ModalAnalysisFormulation(modal);options,modal_options)
+ isempty(modal_options isa NamedTuple ? modal_options : modal_options.data) ||
+ throw(ArgumentError("modal_options require a modal formulation"))
+ return compute(LineCableModelsCoaxial(), problem, formulations; options)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compute line parameters through an explicit coaxial backend tag.
+
+The tag owns execution dispatch while `formulation.methods` retains the
+selected physical recipes. Ordinary callers can omit the tag and use the
+two-argument `compute` method.
+"""
+Base.@constprop :aggressive function compute(
+ engine::LineCableModelsCoaxial,
+ problem::LineParametersProblem,
+ formulation::LineParametersFormulation = Formulation();
+ options::Union{NamedTuple, ComputationOptions} = ComputationOptions()
+)
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ execution = computation_options(LineCableModelsCoaxial, options)
+ # Timing changes the retained detail schema. Carry this finite choice through
+ # the caller's dynamically typed logger without widening the default result.
+ timing = execution.data.timing ? Val(true) : Val(false)
+ logger = VerbosityLogger(Logging.current_logger(), execution.data.verbosity)
+ return with_logger(logger) do
+ _compute(engine, problem, formulation, execution, timing)
+ end
+end
+
+Base.@constprop :aggressive function compute(
+ engine::LineCableModelsCoaxial,
+ problem::LineParametersProblem,
+ formulations::AbstractVector{<:LineParametersFormulation};
+ options::Union{NamedTuple, ComputationOptions} = ComputationOptions()
+)
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ execution = computation_options(LineCableModelsCoaxial, options)
+ timing = execution.data.timing ? Val(true) : Val(false)
+ logger = VerbosityLogger(Logging.current_logger(), execution.data.verbosity)
+ return with_logger(logger) do
+ _compute(engine, problem, formulations, execution, timing)
+ end
+end
+
+function computation_details(
+ ::Type{<:LineParametersFormulation},
+ result::LineParameters
+)::ComputationDetails
+ return details(result)
+end
+
+function computation_details(
+ ::Type{<:LineCableModelsFEM},
+ result::LineParameters
+)::ComputationDetails
+ return details(result)
+end
+
+function materials!(
+ destination::NamedTuple,
+ relation,
+ model::EarthModel{T},
+ frequencies::AbstractVector{T}; workspace = nothing
+) where {T <: Real}
+ rho, eps_r, mu_r = destination.rho, destination.eps_r, destination.mu_r
+ @inbounds for row in eachindex(model.layers)
+ static = EarthMaterial(model.layers[row])
+ for column in eachindex(frequencies)
+ material = row == firstindex(model.layers) ?
+ static :
+ constitutive(relation, static, frequencies[column]; workspace)
+ rho[row, column] = material.rho
+ eps_r[row, column] = material.eps_r
+ mu_r[row, column] = material.mu_r
+ end
+ end
+ return destination
+end
+
+function materials!(workspace::LineParametersWorkspace, formulation::LineParametersFormulation)
+ input, buffers = workspace.input, workspace.buffers
+ for (index, material) in pairs(input.cable.conductor_materials)
+ buffers.rho_cond[index] = constitutive(formulation.methods.temperature_dependence,
+ material, input.temperature; workspace)
+ end
+ buffers.earth.evaluated === nothing || materials!(buffers.earth.evaluated,
+ formulation.methods.earth_properties, input.earth, input.freq; workspace)
+ return workspace
+end
+
+function materials!(
+ workspace::LineParametersWorkspace, formulation::LineParametersFormulation,
+ frequency::Int,
+ calculations::Tuple = workspace.plan.earth.calculations,
+ materials::Tuple = workspace.buffers.earth.calculations)
+ homogenize!(workspace, frequency, formulation, calculations, materials)
+ dielectric!(workspace.buffers.dielectric_admittivity, workspace.input.cable,
+ formulation.methods, workspace.input.freq[frequency], workspace.input.temperature; workspace)
+ return workspace
+end
+
+@inline function _media!(destination, column::Int, air, earth)
+ epsilon0 = vacuum_permittivity(typeof(earth.rho))
+ mu0 = vacuum_permeability(typeof(earth.rho))
+ destination.rho[1, column] = air.rho
+ destination.rho[2, column] = earth.rho
+ destination.epsilon[1, column] = epsilon0 * air.eps_r
+ destination.epsilon[2, column] = epsilon0 * earth.eps_r
+ destination.mu[1, column] = mu0 * air.mu_r
+ destination.mu[2, column] = mu0 * earth.mu_r
+ return nothing
+end
+
+function homogenize!(
+ workspace::LineParametersWorkspace,
+ frequency::Int,
+ formulation::LineParametersFormulation,
+ calculations::Tuple = workspace.plan.earth.calculations,
+ materials::Tuple = workspace.buffers.earth.calculations
+)
+ foreach(calculations, materials) do calculation, destination
+ homogenize!(destination, calculation, workspace, frequency,
+ formulation.methods.earth_properties)
+ end
+ return materials
+end
+
+# The plan decided once which earth the formula's expressions see: the layered earth or
+# a reduction. The loop dispatches on that decision.
+function homogenize!(destination,
+ calculation::NamedTuple,
+ workspace::LineParametersWorkspace, frequency_index::Int, relation)
+ earth = workspace.buffers.earth
+ model = workspace.input.earth
+ frequency = workspace.input.freq[frequency_index]
+ data = calculation.earth isa EquivalentHomogeneous.BeforeFD ? earth.static : earth.evaluated
+ homogenize!(destination, calculation.earth, relation, data, model,
+ calculation, frequency, frequency_index; workspace)
+ return destination
+end
+
+# The layered earth reuses the layerwise FrequencyDependent values already evaluated
+# during input construction. Its thicknesses were set when the buffers were allocated.
+function homogenize!(
+ destination,
+ ::EarthModel,
+ relation,
+ evaluated,
+ model::EarthModel,
+ calculation,
+ frequency,
+ frequency_index::Int; workspace = nothing
+)
+ epsilon0=vacuum_permittivity(eltype(destination.rho))
+ mu0=vacuum_permeability(eltype(destination.rho))
+ for row in axes(destination.rho, 1)
+ for column in eachindex(calculation.pairs)
+ destination.rho[row, column]=evaluated.rho[row, frequency_index]
+ destination.epsilon[row, column]=epsilon0*evaluated.eps_r[row, frequency_index]
+ destination.mu[row, column]=mu0*evaluated.mu_r[row, frequency_index]
+ end
+ end
+ return destination
+end
+
+function homogenize!(
+ destination,
+ sequence::EquivalentHomogeneous.AfterFD,
+ relation,
+ evaluated,
+ model::EarthModel,
+ calculation,
+ frequency,
+ frequency_index::Int; workspace = nothing
+)
+ rho = @view evaluated.rho[:, frequency_index]
+ eps_r = @view evaluated.eps_r[:, frequency_index]
+ mu_r = @view evaluated.mu_r[:, frequency_index]
+ air = EarthMaterial(rho[1], eps_r[1], mu_r[1])
+ foreach(calculation.reductions) do reduction
+ @inbounds for column in reduction.pairs
+ functor = Functor(sequence.rule, (; rho, eps_r, mu_r, model,
+ pair = calculation.pairs[column].physical, frequency, reduction.options);
+ workspace)
+ earth = validate(reduction.expression(functor, workspace), sequence.rule)
+ _media!(destination, column, air, earth)
+ end
+ end
+ return destination
+end
+
+function homogenize!(
+ destination,
+ sequence::EquivalentHomogeneous.BeforeFD,
+ relation,
+ static,
+ model::EarthModel,
+ calculation,
+ frequency,
+ frequency_index::Int; workspace = nothing
+)
+ air = EarthMaterial(static.rho[1], static.eps_r[1], static.mu_r[1])
+ foreach(calculation.reductions) do reduction
+ @inbounds for column in reduction.pairs
+ functor = Functor(sequence.rule, (; static.rho, static.eps_r, static.mu_r, model,
+ pair = calculation.pairs[column].physical, frequency, reduction.options);
+ workspace)
+ reconstructed = validate(reduction.expression(functor, workspace), sequence.rule)
+ earth = constitutive(relation, reconstructed, frequency; workspace)
+ _media!(destination, column, air, earth)
+ end
+ end
+ return destination
+end
diff --git a/src/engine/lineparameters/base.jl b/src/engine/lineparameters/base.jl
new file mode 100644
index 000000000..ffa5e6823
--- /dev/null
+++ b/src/engine/lineparameters/base.jl
@@ -0,0 +1,45 @@
+Base.eltype(::LineParameters{T}) where {T} = T
+Base.eltype(::Type{LineParameters{T}}) where {T} = T
+
+Base.size(value::SeriesImpedance) = size(value.values)
+Base.size(value::SeriesImpedance, dimension::Int) = size(value.values, dimension)
+Base.axes(value::SeriesImpedance) = axes(value.values)
+Base.ndims(::Type{<:SeriesImpedance}) = 3
+Base.eltype(::Type{SeriesImpedance{T, Basis}}) where {T, Basis} = T
+Base.getindex(value::SeriesImpedance, indices...) = getindex(value.values, indices...)
+Base.IndexStyle(::Type{<:SeriesImpedance}) = IndexCartesian()
+
+Base.size(value::ShuntAdmittance) = size(value.values)
+Base.size(value::ShuntAdmittance, dimension::Int) = size(value.values, dimension)
+Base.axes(value::ShuntAdmittance) = axes(value.values)
+Base.ndims(::Type{<:ShuntAdmittance}) = 3
+Base.eltype(::Type{ShuntAdmittance{T, Basis}}) where {T, Basis} = T
+Base.getindex(value::ShuntAdmittance, indices...) = getindex(value.values, indices...)
+Base.IndexStyle(::Type{<:ShuntAdmittance}) = IndexCartesian()
+
+function Base.getindex(
+ lp::LineParameters{T, U, D, Basis},
+ selector::Union{
+ Integer, AbstractRange{<:Integer}, AbstractVector{<:Integer}, Colon}
+) where {T, U, D, Basis}
+ selected = selector isa Integer ? (selector:selector) : selector
+ checkbounds(lp.f, selected)
+ selected_frequencies = selector isa Integer ? lp.f[selector:selector] : lp.f[selected]
+ return LineParameters(
+ selectdomain(lp.domain, selected),
+ SeriesImpedance{T, Basis}(Array(view(lp.Z.values,:,:,selected))),
+ ShuntAdmittance{T, Basis}(Array(view(lp.Y.values,:,:,selected))),
+ selected_frequencies,
+ selectdetails(lp.details,lp.domain,selected)
+ )
+end
+
+"""
+Return whether a scalar type encodes explicit numerical uncertainty.
+"""
+has_uncertainty_type(::Type) = false
+
+function _result_unit(value, selector)
+ quantity = Units.quantity(selector)
+ return Units.native_unit(quantity, basis(value))
+end
diff --git a/src/engine/lineparameters/benchmark.jl b/src/engine/lineparameters/benchmark.jl
new file mode 100644
index 000000000..6cdb9a33a
--- /dev/null
+++ b/src/engine/lineparameters/benchmark.jl
@@ -0,0 +1,537 @@
+"""
+$(TYPEDEF)
+
+Store element-wise absolute and reference-normalized root-mean-square benchmark errors.
+
+Each matrix entry contains the error for the corresponding line-parameter
+term over the selected frequency samples. Missing values represent explicit
+non-applicability, an empty band, or ineligible operands, with the explanation
+retained in `details`. An ineligible sample makes both metrics missing for that
+term and band. Eligible small and zero errors remain unchanged.
+
+$(TYPEDFIELDS)
+"""
+struct RMSError{T <: Real, D <: ComputationDetails}
+ "Absolute error in the units of the compared quantity."
+ absolute::Matrix{Union{Missing, T}}
+ "Reference-normalized error [dimensionless]."
+ relative::Matrix{Union{Missing, T}}
+ "Requested and selected frequency band, tolerance, applicability, and per-term classification."
+ details::D
+end
+
+function RMSError{T}(absolute::AbstractMatrix, relative::AbstractMatrix;
+ details::ComputationDetails = ComputationDetails()) where {T <: Real}
+ size(absolute) == size(relative) ||
+ throw(DimensionMismatch("RMS error matrices must match"))
+ return RMSError{T, typeof(details)}(absolute, relative, details)
+end
+
+function RMSError(absolute::AbstractMatrix{T}, relative::AbstractMatrix{S};
+ details::ComputationDetails = ComputationDetails()) where {T <: Real, S <: Real}
+ return RMSError{promote_type(T, S)}(absolute, relative; details)
+end
+
+"""Return RMS comparison metadata without exposing result storage to consumers."""
+details(error::RMSError) = error.details
+observe(error::RMSError, ::typeof(absolute_error)) = error.absolute
+observe(error::RMSError, ::typeof(relative_error)) = error.relative
+observe(error::RMSError, ::typeof(absolute_error), indices...) = getindex(error.absolute, indices...)
+observe(error::RMSError, ::typeof(relative_error), indices...) = getindex(error.relative, indices...)
+
+"""
+$(TYPEDEF)
+
+Store per-term frequency-domain errors for the series impedance and shunt
+admittance of two [`LineParameters`](@ref) objects.
+
+$(TYPEDFIELDS)
+"""
+struct LineParametersBenchmark{T <: Real, Basis, ZE <: RMSError{T}, YE <: RMSError{T}} <:
+ AbstractProblemResult
+ "Series-impedance error."
+ Z::ZE
+ "Shunt-admittance error."
+ Y::YE
+end
+
+function LineParametersBenchmark(
+ impedance::RMSError{T},
+ admittance::RMSError{T};
+ basis::Symbol = :pul
+) where {T <: Real}
+ validate(basis, LineParameters)
+ return LineParametersBenchmark{T, basis, typeof(impedance), typeof(admittance)}(impedance, admittance)
+end
+
+basis(::LineParametersBenchmark{T, Basis}) where {T, Basis} = Basis
+
+function observe(benchmark::LineParametersBenchmark, ::typeof(Z), ::typeof(absolute_error), indices...)
+ getindex(benchmark.Z.absolute, indices...)
+end
+function observe(benchmark::LineParametersBenchmark, ::typeof(Z), ::typeof(relative_error), indices...)
+ getindex(benchmark.Z.relative, indices...)
+end
+function observe(benchmark::LineParametersBenchmark, ::typeof(Y), ::typeof(absolute_error), indices...)
+ getindex(benchmark.Y.absolute, indices...)
+end
+function observe(benchmark::LineParametersBenchmark, ::typeof(Y), ::typeof(relative_error), indices...)
+ getindex(benchmark.Y.relative, indices...)
+end
+
+function observe(benchmark::LineParametersBenchmark, ::typeof(Z), ::typeof(absolute_error))
+ benchmark.Z.absolute
+end
+function observe(benchmark::LineParametersBenchmark, ::typeof(Z), ::typeof(relative_error))
+ benchmark.Z.relative
+end
+function observe(benchmark::LineParametersBenchmark, ::typeof(Y), ::typeof(absolute_error))
+ benchmark.Y.absolute
+end
+function observe(benchmark::LineParametersBenchmark, ::typeof(Y), ::typeof(relative_error))
+ benchmark.Y.relative
+end
+
+function observables(::Type{<:LineParametersBenchmark})
+ (
+ (Z, absolute_error),
+ (Z, relative_error),
+ (Y, absolute_error),
+ (Y, relative_error)
+ )
+end
+
+function _rms_series(reference::AbstractVector, result::AbstractVector,
+ normalization::Symbol, tolerance, result_tolerance;
+ reference_unresolved=nothing, result_unresolved=nothing)
+ unresolved_reference = reference_unresolved===nothing ? count(_resolution_unresolved.(reference,tolerance)) : count(reference_unresolved)
+ unresolved_result = result_unresolved===nothing ? count(_resolution_unresolved.(result,result_tolerance)) : count(result_unresolved)
+ counts = (; reference=unresolved_reference, result=unresolved_result)
+ for (operand,values) in ((:reference,reference),(:result,result))
+ if any(value -> !resolution_available(value),values)
+ return (absolute=missing,relative=missing,status=Symbol(operand,:_unavailable),
+ reason="$(operand) contains unavailable or nonfinite samples; no samples were omitted",counts)
+ end
+ end
+ # Eligibility is two-sided and independent of normalization. Never reduce a
+ # band's sample population to hide an undefined pairwise relative comparison.
+ for (operand, count) in pairs(counts)
+ if count > 0
+ entire_trace = count == length(reference)
+ status = if operand === :reference
+ entire_trace ? :reference_below_tolerance : :reference_sample_below_tolerance
+ else
+ entire_trace ? :result_below_tolerance : :result_sample_below_tolerance
+ end
+ reason = "Samples at or below declared resolution: reference " *
+ "$(counts.reference)/$(length(reference)), result $(counts.result)/$(length(reference)); " *
+ "both RMS metrics require nominal operands above tolerance at every selected sample; no samples were omitted"
+ return (; absolute=missing, relative = missing, status, reason, counts)
+ end
+ end
+ difference_norm = norm(reference .- result)
+ sample_normalizer = sqrt(oftype(difference_norm, length(reference)))
+ absolute = difference_norm / sample_normalizer
+ relative = normalization === :pointwise ?
+ norm((result .- reference) ./ reference) / sample_normalizer :
+ difference_norm / norm(reference)
+ return (; absolute, relative, status = :compared, reason = nothing, counts)
+end
+
+"""
+ compare(reference::AbstractArray{<:Number,3}, result; normalization=:reference_rms, atol=0)
+
+Measure per-entry absolute and relative RMS differences across the third axis.
+The caller must establish equal physical coordinates, units and terminal order.
+`atol` is a nonnegative scalar or one tolerance per sample, in the input units.
+Both RMS metrics are `missing` if either operand's nominal magnitude is at or
+below `atol` at any selected sample, for either normalization. `details` explains
+unavailable comparisons. Eligible small and zero errors are retained unchanged.
+"""
+function compare(
+ reference::AbstractArray{<:Number, 3}, result::AbstractArray{<:Number, 3};
+ normalization::Symbol = :reference_rms, atol = 0)
+ axes(reference) == axes(result) ||
+ throw(DimensionMismatch("RMS tensor axes must match"))
+ isempty(reference) && throw(ArgumentError("RMS tensors cannot be empty"))
+ normalization in (:reference_rms, :pointwise) ||
+ throw(ArgumentError("unknown RMS normalization"))
+ tolerance=atol isa Real ? fill(atol, size(reference, 3)) : collect(atol)
+ length(tolerance) == size(reference, 3) ||
+ throw(DimensionMismatch("one tolerance per sample is required"))
+ all(value -> value isa Real && isfinite(value) && value >= 0, tolerance) ||
+ throw(ArgumentError("RMS tolerances must be finite and nonnegative"))
+ return _rms_arrays(reference, result, normalization, tolerance, tolerance)
+end
+
+function _rms_arrays(reference, result, normalization, tolerance, result_tolerance;
+ reference_unresolved=nothing,result_unresolved=nothing)
+ T=promote_type(typeof(float(real(zero(Base.nonmissingtype(eltype(reference)))))),
+ typeof(float(real(zero(Base.nonmissingtype(eltype(result)))))))
+ errors=[_rms_series(view(reference, row, column, :),
+ view(result, row, column, :), normalization, tolerance, result_tolerance;
+ reference_unresolved=reference_unresolved===nothing ? nothing : view(reference_unresolved,row,column,:),
+ result_unresolved=result_unresolved===nothing ? nothing : view(result_unresolved,row,column,:))
+ for row in axes(reference, 1), column in axes(reference, 2)]
+ return RMSError{T}(getproperty.(errors, :absolute), getproperty.(errors, :relative);
+ details = ComputationDetails(; normalization, atol = tolerance, sample_count = size(reference, 3),
+ status = getproperty.(errors, :status), normalization_reason = getproperty.(errors, :reason),
+ unresolved_samples = getproperty.(errors, :counts),
+ resolution=(kind=:explicit_floor, unit=nothing)))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compare two line-parameter results using absolute and reference-normalized
+root-mean-square errors for each Z and Y matrix term across frequency.
+
+For the reference series ``A_{ij}`` and result series ``B_{ij}`` at one
+matrix term, the default `normalization=:reference_rms` uses:
+
+```math
+\\mathrm{RMS}_{\\mathrm{abs},ij} =
+\\sqrt{\\frac{1}{N_f}\\sum_{k=1}^{N_f}\\left|A_{ij,k}-B_{ij,k}\\right|^2}
+```
+
+```math
+\\mathrm{RMS}_{\\mathrm{rel},ij} =
+\\sqrt{\\frac{\\sum_{k=1}^{N_f}\\left|A_{ij,k}-B_{ij,k}\\right|^2}
+{\\sum_{k=1}^{N_f}\\left|A_{ij,k}\\right|^2}}
+```
+
+The operands must have identical frequency samples, tensor dimensions, basis,
+and domain. Comparison does not reorder conductors, interpolate frequency
+samples, convert basis, or apply a reduction.
+
+Every original operand sample must pass the shared engineering-zero classifier.
+Complex zero requires both Cartesian components to satisfy their own cutoffs.
+Unavailable samples are separately ineligible. Otherwise both error metrics are
+`missing`, including for identical ineligible traces. Eligible identical traces
+retain errors equal to zero.
+
+Keyword arguments are shared with the single-observable `compare` method:
+`normalization`, `band`, `fundamental`, `harmonics`, `atol`, and `unsupported`. Full-band error
+is the default. Optional sub-bands only slice the stored results. Each Z/Y
+error retains its selected samples and numerical-zero classification.
+
+# Arguments
+
+- `reference`: reference line parameters.
+- `result`: result line parameters on the same frequency samples.
+
+# Returns
+
+- A [`LineParametersBenchmark`](@ref). Absolute Z errors use the impedance
+ units selected by the result basis, and absolute Y errors use the corresponding
+ admittance units. Relative errors are dimensionless.
+
+# Errors
+
+- `DimensionMismatch` when Z/Y tensor dimensions differ.
+- `ArgumentError` when frequencies, basis, or domain differ.
+"""
+function compare(reference::LineParameters, result::LineParameters; kwargs...)
+ return LineParametersBenchmark(compare(reference, result, Z; kwargs...),
+ compare(reference, result, Y; kwargs...); basis = basis(reference))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compare a selected line-parameter observable over stored frequency samples.
+The first operand sets the relative-error normalization, not scientific truth.
+
+# Arguments
+
+- `reference`, `result`: results with identical frequency coordinates, basis,
+ domain, and matrix dimensions.
+- `quantity`: `Z`, `Y`, `R`, `X`, `L`, `G`, `B`, or `C`. Comparing `G`
+ separately prevents displacement current from hiding dielectric-loss differences.
+
+# Keywords
+
+- `normalization`: `:reference_rms` (default) divides the absolute RMS error
+ by the reference RMS. `:pointwise` instead computes the RMS of the
+ sample-wise relative errors. Both return dimensionless fractions, not percentages.
+- `band`: `:all` (default), explicit closed `(lower, upper)` Hz bounds, or
+ `:dc` (0.1–100 Hz), `:harmonic` (`fundamental` to `harmonics*fundamental`),
+ `:narrow` (1000–1000000 Hz), or `:wide` (strictly above 1000000 Hz).
+- `fundamental`: fundamental frequency in Hz. Default 50.
+- `harmonics`: upper harmonic order. Default 50. Every stored sample in the
+ harmonic band is used, not only samples at integer harmonics.
+- `atol`: declared absolute reporting resolution in the observable's native basis
+ units, not a certified floating-point error bound.
+ Use a scalar for the requested quantity or a NamedTuple to select tolerances
+ by component symbol. Defaults per meter are 1e-10 Ω/m for R, 1e-12 S/m for G,
+ 1e-15 H/m for L, and 1e-16 F/m for C. X and B thresholds are linked through
+ 2πf. Total-basis defaults scale by retained physical length. Otherwise explicit
+ total-unit cutoffs are required. Complex requests require component-keyed cutoffs.
+ Unless overridden directly, X/B use `2πf*atol_L`/`2πf*atol_C`,
+ Z uses `atol_R + 2πf*atol_L` and Y uses
+ `atol_G + 2πf*atol_C` at each sample. A fixed admittance threshold would
+ otherwise treat the same small capacitance differently across the spectrum.
+- `unsupported`: NamedTuple of quantity symbols and explanatory strings for
+ explicitly unavailable comparisons. Backend declarations may also supply
+ this map as `details.comparison_unsupported` in either result.
+
+Closed endpoints snap to the nearest stored frequency by absolute Hz distance.
+Ties select the lower frequency. Partial overlap uses the available portion.
+Disjoint bands and an empty `:wide` band return `missing` errors with
+`:no_samples`. No interpolation, extrapolation, weighting, or computation runs
+are introduced. A one-sample band is valid.
+
+Both RMS metrics require every original operand sample to pass
+`observation_resolution`, for either normalization. Real quantities use absolute
+nominal magnitude. Complex zero requires both components to satisfy their own
+cutoffs. An entire trace within tolerance gives `missing` for both metrics with status
+`:reference_below_tolerance` or `:result_below_tolerance`. A partially
+negligible trace gives `:reference_sample_below_tolerance` or
+`:result_sample_below_tolerance`. The check is local to the selected band.
+The denominator is used without a floor. Source arrays are never modified.
+
+For `normalization=:pointwise`, the relative error is
+
+```math
+\\sqrt{\\frac{1}{N_f}\\sum_{k=1}^{N_f}
+\\left|\\frac{B_{ij,k}-A_{ij,k}}{A_{ij,k}}\\right|^2}.
+```
+
+Samples are never omitted to obtain an eligible subset. Eligibility is independent
+of normalization. Eligible errors are never clipped. Each cell retains its explanation
+in `details.normalization_reason`.
+
+# Returns
+
+- [`RMSError`](@ref), including actual bounds, sample indices and count, tolerance,
+ reason, and a per-term status matrix in `details`.
+"""
+function compare(reference::AbstractCoreResult, result::AbstractCoreResult,
+ quantity::Union{typeof(Z), typeof(Y), typeof(R), typeof(X), typeof(L), typeof(G), typeof(B), typeof(C)};
+ normalization::Symbol = :reference_rms,
+ band = :all, fundamental::Real = 50.0, harmonics::Integer = 50,
+ atol = nothing, unsupported::NamedTuple = (;))
+ validate((; normalization, band, fundamental, harmonics, atol, unsupported), compare)
+ left_coordinates=get(details(reference).data, :coordinates, nothing)
+ right_coordinates=get(details(result).data, :coordinates, nothing)
+ if left_coordinates !== nothing && right_coordinates !== nothing
+ left_coordinates == right_coordinates || throw(ArgumentError("reference and result output terminal identities differ"))
+ end
+ f = frequencies(reference)
+ isempty(f) && throw(ArgumentError("reference frequencies cannot be empty"))
+ f == frequencies(result) || throw(ArgumentError(
+ "reference and result frequencies must match exactly and in order"))
+ basis(reference) === basis(result) || throw(ArgumentError("reference and result basis must match"))
+ domain(reference) === domain(result) || throw(ArgumentError("reference and result domains must match"))
+ issorted(f) || throw(ArgumentError("frequency-band comparison requires ascending stored frequencies"))
+ left, right = _line_observation_values(reference,quantity), _line_observation_values(result,quantity)
+ declared = merge(get(details(result).data, :comparison_unsupported, (;)),
+ get(details(reference).data, :comparison_unsupported, (;)), unsupported)
+ return compare(left, right, quantity; frequencies=f, result_basis=basis(reference),
+ reference_resolution=observation_resolution(reference, quantity; atol, frequencies=f),
+ result_resolution=observation_resolution(result, quantity; atol, frequencies=f),
+ normalization, band, fundamental, harmonics, atol, unsupported=declared)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compare physical tensors on an explicit frequency axis using the same band and
+two-sided resolution rules as core results. Arrays use native units in
+`result_basis` (`:pul` or `:total`). Frequencies use Hz. Owner-supplied resolution
+records preserve operand-specific precision. The operation preserves each sample without a physical calculation.
+"""
+function compare(left::AbstractArray{<:Union{Missing,Number},3}, right::AbstractArray{<:Union{Missing,Number},3},
+ quantity::Function; frequencies::AbstractVector, result_basis::Symbol,
+ reference_resolution=nothing, result_resolution=nothing,
+ normalization::Symbol=:reference_rms, band=:all, fundamental::Real=50.0,
+ harmonics::Integer=50, atol=nothing, unsupported::NamedTuple=(;))
+ validate((; normalization, band, fundamental, harmonics, atol, unsupported), compare)
+ validate(result_basis, LineParameters)
+ quantity in _LINE_RESOLUTION_QUANTITIES || throw(ArgumentError("unsupported physical RMS quantity"))
+ f = frequencies
+ !isempty(f) && issorted(f) && all(value -> isfinite(value) && value >= 0, f) ||
+ throw(ArgumentError("frequency-band comparison requires finite ascending nonnegative frequencies"))
+ size(left) == size(right) || throw(DimensionMismatch("reference and result quantity dimensions must match"))
+ !isempty(left) && size(left, 3) == length(f) ||
+ throw(DimensionMismatch("quantity dimensions must match the stored frequencies"))
+ requested = if band === :all
+ (first(f), last(f))
+ elseif band === :dc
+ (0.1, 100.0)
+ elseif band === :harmonic
+ (fundamental, fundamental * harmonics)
+ elseif band === :narrow
+ (1e3, 1e6)
+ elseif band === :wide
+ (1e6, Inf)
+ elseif band isa Tuple{Real, Real}
+ band
+ else
+ throw(ArgumentError("band must be :all, :dc, :harmonic, :narrow, :wide, or (lower, upper) in Hz"))
+ end
+ lower, upper = requested
+ isfinite(lower) && lower >= 0 && !isnan(upper) && upper >= lower ||
+ throw(ArgumentError("frequency bounds must satisfy 0 ≤ lower ≤ upper with finite lower"))
+ indices = if band === :wide
+ (searchsortedlast(f, 1e6) + 1):length(f)
+ elseif upper < first(f) || lower > last(f)
+ 1:0
+ elseif band === :all
+ 1:length(f)
+ else
+ first_index = argmin(abs.(f .- lower))
+ last_index = isinf(upper) ? length(f) : argmin(abs.(f .- upper))
+ first_index:last_index
+ end
+ T = promote_type(typeof(float(real(zero(Base.nonmissingtype(eltype(left)))))),
+ typeof(float(real(zero(Base.nonmissingtype(eltype(right)))))))
+ name = Symbol(nameof(quantity))
+ resolution = reference_resolution === nothing ?
+ observation_resolution(left, quantity; atol, frequencies=f, result_basis) : reference_resolution
+ result_resolution = result_resolution === nothing ?
+ observation_resolution(right, quantity; atol, frequencies=f, result_basis) : result_resolution
+ tolerance = _comparison_cutoffs(resolution.atol,indices)
+ result_tolerance = _comparison_cutoffs(result_resolution.atol,indices)
+ reason = get(unsupported, name, nothing)
+ reason === nothing || reason isa AbstractString && !isempty(reason) ||
+ throw(ArgumentError("unsupported comparisons require a nonempty explanatory string for $name"))
+ status = reason !== nothing ? :unsupported : isempty(indices) ? :no_samples : :compared
+ reason === nothing && isempty(indices) &&
+ (reason = "No stored samples in the requested frequency band")
+ absolute = Matrix{Union{Missing, T}}(missing, size(left, 1), size(left, 2))
+ relative = similar(absolute)
+ fill!(relative, missing)
+ classifications = fill(status, size(absolute))
+ normalization_reasons = Matrix{Union{Nothing, String}}(nothing, size(absolute))
+ unresolved_samples = fill((reference=0, result=0), size(absolute))
+ if status === :compared
+ error=_rms_arrays(left[:, :, indices], right[:, :, indices], normalization,
+ tolerance, result_tolerance;
+ reference_unresolved=resolution.unresolved[:,:,indices],
+ result_unresolved=result_resolution.unresolved[:,:,indices])
+ absolute .= error.absolute
+ relative .= error.relative
+ classifications .= error.details.data.status
+ normalization_reasons .= error.details.data.normalization_reason
+ unresolved_samples .= error.details.data.unresolved_samples
+ end
+ bounds = isempty(indices) ? (missing, missing) :
+ (f[first(indices)], f[last(indices)])
+ comparison_details = (; quantity = name, normalization, band,
+ requested_bounds = requested, actual_bounds = bounds,
+ indices, sample_count = length(indices), fundamental, harmonics, atol = tolerance,
+ result_atol = result_tolerance,
+ status = classifications, reason, normalization_reason = normalization_reasons,
+ unresolved_samples,
+ resolution=(; resolution.kind, resolution.unit))
+ # Empty bands and supported bands have the same result type on a Gridspace.
+ # Preserve the frequency scalar type while admitting an absent bound or reason.
+ detail_types=map(keys(comparison_details)) do key
+ key === :actual_bounds ? NTuple{2,Union{Missing,eltype(f)}} :
+ key === :reason ? Union{Nothing,String} : typeof(getproperty(comparison_details,key))
+ end
+ stable_details=NamedTuple{keys(comparison_details),Tuple{detail_types...}}(values(comparison_details))
+ return RMSError{T}(absolute, relative; details=ComputationDetails(stable_details))
+end
+
+_comparison_cutoffs(value::Real,indices) = fill(value,length(indices))
+_comparison_cutoffs(value::AbstractVector,indices) = value[indices]
+_comparison_cutoffs(value::NamedTuple,indices) = map(item -> _comparison_cutoffs(item,indices),value)
+_comparison_cutoffs(::Nothing,indices) = throw(ArgumentError("comparison requires explicit applicable operand cutoffs"))
+
+_comparison_primary(source::Commons.AbstractResultSpace,index) = source[index]
+_comparison_primary(source::AbstractVector,index) = source[index]
+_comparison_primary(source,index) = index==1 ? source : throw(BoundsError(source,index))
+
+function compare(reference::AbstractCoreResult,results::AbstractVector,request::Union{Function,Tuple};kwargs...)
+ return [compare(reference,result,request;kwargs...) for result in results]
+end
+
+function _comparison_maximum(values)
+ eligible=findall(!ismissing,values)
+ isempty(eligible) && return (value=missing,index=missing)
+ selected=eligible[argmax(values[eligible])]
+ return (value=values[selected],index=Tuple(selected))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Complete explicitly requested comparisons through the existing scalar or UQ
+comparison methods, then associate each product with its original result and
+reference identities. The request vector distinguishes this operation from one
+statistical request tuple. No observation constructor performs this operation.
+"""
+function compare(reference,result,requests::AbstractVector;
+ bands=(:all,),normalizations=(:reference_rms,),pairing=nothing,kwargs...)
+ isempty(requests) && throw(ArgumentError("comparison requests must be nonempty"))
+ allunique(requests) && allunique(bands) && allunique(normalizations) ||
+ throw(ArgumentError("comparison selections must be distinct"))
+ if reference isa AbstractCoreResult && pairing!==nothing
+ count=result isa Union{AbstractVector,Commons.AbstractResultSpace} ? length(result) : 1
+ all(pair -> first(pair)==1,pairing) && sort(last.(collect(pairing)))==collect(1:count) ||
+ throw(ArgumentError("a scalar reference requires reference index 1 for every result"))
+ pairing=nothing
+ end
+ completed=NamedTuple[]
+ for request in requests,band in bands,normalization in normalizations
+ errors=pairing===nothing ? compare(reference,result,request;band,normalization,kwargs...) :
+ compare(reference,result,request;band,normalization,pairing,kwargs...)
+ for (result_index,error) in enumerate(errors isa RMSError ? (errors,) : errors)
+ reference_index=pairing===nothing ? 1 : first(only(filter(pair -> last(pair)==result_index,pairing)))
+ left=_comparison_primary(reference,reference_index)
+ right=_comparison_primary(result,result_index)
+ reference_id=Commons.observation_gridpoint(left).id
+ result_id=Commons.observation_gridpoint(right).id
+ reference_id===nothing && throw(ArgumentError("the reference needs an explicit retained gridpoint identity"))
+ result_id===nothing && throw(ArgumentError("the result needs an explicit retained gridpoint identity"))
+ absolute=observe(error,absolute_error)
+ relative=observe(error,relative_error)
+ information=merge(details(error).data,(basis=basis(right),requested_atol=get(kwargs,:atol,nothing),unsupported=get(kwargs,:unsupported,(;))))
+ identity=request_identity(request)
+ statistic=identity isa Tuple && first(identity)!==Z && first(identity)!==Y ?
+ (last(identity) isa Base.Fix2 ? Symbol("quantile_",last(identity).x) : nameof(last(identity))) : :value
+ unit=Units.native_unit(Commons.request_quantity(request),basis(right))
+ push!(completed,Commons.detach((result_id,reference_id,request,
+ quantity=Commons.request_quantity(request),statistic,band,normalization,
+ absolute,relative,absolute_unit=unit,relative_unit=Units.units(:base,:dimensionless),
+ coordinates=get(details(right).data,:coordinates,string.(1:size(absolute,1))),
+ assumptions=observation_assumptions(right,identity isa Function ? identity : identity[2]),
+ settings=information,maxima=(absolute=_comparison_maximum(absolute),relative=_comparison_maximum(relative)))))
+ end
+ end
+ return completed
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Validate the RMS comparison settings of `compare` before accessing results or
+starting a computation. `settings` holds `normalization`, `band`, `fundamental`,
+`harmonics`, `atol` and `unsupported`. Frequency bounds and `fundamental` use Hz. Absolute
+tolerances use the units of the selected quantities. Return `settings`.
+"""
+function validate(settings::NamedTuple, ::typeof(compare))
+ (; normalization, band, fundamental, harmonics, atol, unsupported) = settings
+ normalization in (:reference_rms, :pointwise) || throw(ArgumentError(
+ "normalization must be :reference_rms or :pointwise"))
+ fundamental isa Real && isfinite(fundamental) && fundamental > 0 ||
+ throw(ArgumentError("fundamental must be finite and positive Hz"))
+ harmonics isa Integer && !(harmonics isa Bool) && harmonics > 0 ||
+ throw(ArgumentError("harmonics must be a positive integer"))
+ if band isa Tuple{Real, Real}
+ lower, upper = band
+ isfinite(lower) && lower >= 0 && !isnan(upper) && upper >= lower ||
+ throw(ArgumentError("frequency bounds must satisfy 0 ≤ lower ≤ upper with finite lower"))
+ else
+ band in (:all, :dc, :harmonic, :narrow, :wide) || throw(ArgumentError(
+ "band must be :all, :dc, :harmonic, :narrow, :wide, or (lower, upper) in Hz"))
+ end
+ _validate_resolution_atol(atol)
+ unsupported isa NamedTuple && isempty(setdiff(keys(unsupported), (:Z, :Y, :R, :X, :L, :G, :B, :C))) ||
+ throw(ArgumentError("unsupported must name Z, Y, R, X, L, G, B, or C"))
+ all(reason -> reason isa AbstractString && !isempty(reason), unsupported) ||
+ throw(ArgumentError("unsupported comparisons require nonempty explanatory strings"))
+ return settings
+end
diff --git a/src/engine/lineparameters/lineparameters.jl b/src/engine/lineparameters/lineparameters.jl
new file mode 100644
index 000000000..e83d6c33e
--- /dev/null
+++ b/src/engine/lineparameters/lineparameters.jl
@@ -0,0 +1,556 @@
+const LINE_PARAMETER_BASES = (:pul, :total)
+
+"""
+ SeriesImpedance{T, Basis}
+
+Store a square series-impedance matrix over frequency. Values use \\[Ω/m\\]
+when `Basis` is `:pul` and \\[Ω\\] when it is `:total`.
+"""
+struct SeriesImpedance{T, Basis} <: AbstractArray{T, 3}
+ "Complex series-impedance tensor with dimensions conductor × conductor × frequency."
+ values::Array{T, 3}
+
+ function SeriesImpedance{T, Basis}(values::Array{T, 3}) where {T, Basis}
+ Basis isa Symbol || throw(
+ ArgumentError("basis must be :pul or :total; got $(repr(Basis))"),
+ )
+ validate(Basis, LineParameters)
+ return new{T, Basis}(values)
+ end
+end
+
+"""
+ ShuntAdmittance{T, Basis}
+
+Store a square shunt-admittance matrix over frequency. Values use \\[S/m\\]
+when `Basis` is `:pul` and \\[S\\] when it is `:total`.
+"""
+struct ShuntAdmittance{T, Basis} <: AbstractArray{T, 3}
+ "Complex shunt-admittance tensor with dimensions conductor × conductor × frequency."
+ values::Array{T, 3}
+
+ function ShuntAdmittance{T, Basis}(values::Array{T, 3}) where {T, Basis}
+ Basis isa Symbol || throw(
+ ArgumentError("basis must be :pul or :total; got $(repr(Basis))"),
+ )
+ validate(Basis, LineParameters)
+ return new{T, Basis}(values)
+ end
+end
+
+function SeriesImpedance(A::AbstractArray{T, 3}; basis::Symbol = :pul) where {T}
+ validate(basis, LineParameters)
+ return SeriesImpedance{T, basis}(Array(A))
+end
+
+function ShuntAdmittance(A::AbstractArray{T, 3}; basis::Symbol = :pul) where {T}
+ validate(basis, LineParameters)
+ return ShuntAdmittance{T, basis}(Array(A))
+end
+
+@inline basis(::Type{<:SeriesImpedance{T, Basis}}) where {T, Basis} = Basis
+@inline basis(::SeriesImpedance{T, Basis}) where {T, Basis} = Basis
+@inline basis(::Type{<:ShuntAdmittance{T, Basis}}) where {T, Basis} = Basis
+@inline basis(::ShuntAdmittance{T, Basis}) where {T, Basis} = Basis
+
+"""
+ LineParameters{T, U, D, Basis, Q}
+
+Frequency-dependent series-impedance and shunt-admittance matrices.
+
+ `Basis` is either `:pul` or `:total`. Per-length values are stored in
+Ω/m and S/m. Total values are stored in Ω and S. Frequencies are stored in
+Hz. `D` is the concrete physical coordinate-domain value. `Q` is the concrete
+named-tuple type of optional computation output.
+"""
+struct LineParameters{
+ T <: Complex,
+ U <: Real,
+ D <: LineParamsDomain,
+ Basis,
+ Q <: ComputationDetails
+} <: AbstractCoreResult
+ "Frequency-dependent series impedance \\[Ω/m\\] or \\[Ω\\]."
+ Z::SeriesImpedance{T, Basis}
+ "Frequency-dependent shunt admittance \\[S/m\\] or \\[S\\]."
+ Y::ShuntAdmittance{T, Basis}
+ "Frequency samples \\[Hz\\]."
+ f::Vector{U}
+ "Physical coordinate domain, including any required transformation state."
+ domain::D
+ "Typed supplemental output retained by the computation."
+ details::Q
+
+ function LineParameters(
+ domain::D,
+ Z::SeriesImpedance{T, Basis},
+ Y::ShuntAdmittance{T, Basis},
+ f::AbstractVector{U},
+ details::Q = ComputationDetails()
+ ) where {
+ D <: LineParamsDomain,
+ T <: Complex,
+ U <: Real,
+ Basis,
+ Q <: ComputationDetails
+ }
+ retained=haskey(details.data,:gridpoint) ? details :
+ completion_details(merge(details.data,(gridpoint=Commons.gridpoint_id(),)))
+ return validate(new{T, U, D, Basis, typeof(retained)}(
+ Z,
+ Y,
+ Vector{U}(f),
+ domain,
+ retained
+ ))
+ end
+end
+
+# Line-parameter quantities are per unit length (`:pul`) or for the total line length.
+@inline function validate(basis::Symbol, ::Type{LineParameters})
+ basis in LINE_PARAMETER_BASES || throw(
+ ArgumentError("basis must be :pul or :total; got :$basis"),
+ )
+ return basis
+end
+
+# The phase domain does not restrict the coefficients.
+validate(domain::LineParamsDomain, ::LineParameters) = domain
+
+function validate(domain::ModalDomain, parameters::LineParameters)
+ size(domain.operators)==size(parameters.Z.values) || throw(DimensionMismatch(
+ "modal operators must align with line-parameter coefficients"))
+ return domain
+end
+
+function validate(parameters::LineParameters{T, U, D, Basis}) where {T, U, D, Basis}
+ Basis in LINE_PARAMETER_BASES || throw(ArgumentError(
+ "LineParameters basis must be :pul or :total; received $(repr(Basis))"
+ ))
+ size(parameters.Z, 1) == size(parameters.Z, 2) || throw(DimensionMismatch(
+ "LineParameters.Z must be square; received size $(size(parameters.Z))"
+ ))
+ size(parameters.Y, 1) == size(parameters.Y, 2) || throw(DimensionMismatch(
+ "LineParameters.Y must be square; received size $(size(parameters.Y))"
+ ))
+ size(parameters.Z) == size(parameters.Y) || throw(DimensionMismatch(
+ "LineParameters.Z and LineParameters.Y must have equal n×n×nfreq " *
+ "dimensions; received $(size(parameters.Z)) and $(size(parameters.Y))"
+ ))
+ size(parameters.Z, 3) == length(parameters.f) || throw(DimensionMismatch(
+ "LineParameters.f must contain one value per matrix frequency plane; " *
+ "received $(length(parameters.f)) values for $(size(parameters.Z, 3)) planes"
+ ))
+ validate(parameters.domain, parameters)
+ all(isfinite, parameters.f) || throw(ArgumentError(
+ "LineParameters.f must contain only finite frequencies; received " *
+ repr(parameters.f)
+ ))
+ return parameters
+end
+
+function LineParameters(
+ ::Type{PhaseDomain},
+ Z::SeriesImpedance{T, Basis},
+ Y::ShuntAdmittance{T, Basis},
+ f::AbstractVector{U},
+ details::Q = ComputationDetails()
+) where {T <: Complex, U <: Real, Basis, Q <: ComputationDetails}
+ return LineParameters(PhaseDomain(), Z, Y, f, details)
+end
+
+function LineParameters(
+ Z::SeriesImpedance{T, Basis},
+ Y::ShuntAdmittance{T, Basis},
+ f::AbstractVector{U}
+) where {T <: Complex, U <: Real, Basis}
+ return LineParameters(PhaseDomain, Z, Y, f)
+end
+
+function LineParameters(
+ Z::SeriesImpedance{TZ, ZBasis},
+ Y::ShuntAdmittance{TY, YBasis},
+ f::AbstractVector{U}
+) where {
+ TZ <: Complex,
+ TY <: Complex,
+ U <: Real,
+ ZBasis,
+ YBasis
+}
+ ZBasis === YBasis || throw(
+ ArgumentError("Z and Y must have the same basis; got :$ZBasis and :$YBasis"),
+ )
+ element_type = promote_type(TZ, TY)
+ return LineParameters(
+ PhaseDomain,
+ SeriesImpedance(convert(Array{element_type, 3}, Z.values); basis = ZBasis),
+ ShuntAdmittance(convert(Array{element_type, 3}, Y.values); basis = YBasis),
+ f
+ )
+end
+
+function LineParameters(
+ domain::D,
+ Z::AbstractArray{TZ, 3},
+ Y::AbstractArray{TY, 3},
+ f::AbstractVector{U};
+ basis::Symbol = :pul,
+ details::ComputationDetails = ComputationDetails()
+) where {
+ D <: LineParamsDomain,
+ TZ <: Complex,
+ TY <: Complex,
+ U <: Real
+}
+ validate(basis, LineParameters)
+ element_type = promote_type(TZ, TY)
+ return LineParameters(
+ domain,
+ SeriesImpedance(convert(Array{element_type, 3}, Z); basis),
+ ShuntAdmittance(convert(Array{element_type, 3}, Y); basis),
+ f,
+ details
+ )
+end
+
+function LineParameters(
+ ::Type{PhaseDomain},
+ Z::AbstractArray{TZ, 3},
+ Y::AbstractArray{TY, 3},
+ f::AbstractVector{U};
+ basis::Symbol = :pul,
+ details::ComputationDetails = ComputationDetails()
+) where {TZ <: Complex, TY <: Complex, U <: Real}
+ return LineParameters(PhaseDomain(), Z, Y, f; basis, details)
+end
+
+function LineParameters(
+ Z::AbstractArray{TZ, 3},
+ Y::AbstractArray{TY, 3},
+ f::AbstractVector{U};
+ basis::Symbol = :pul,
+ details::ComputationDetails = ComputationDetails()
+) where {
+ TZ <: Complex,
+ TY <: Complex,
+ U <: Real
+}
+ return LineParameters(PhaseDomain, Z, Y, f; basis, details)
+end
+
+@inline domain(::Type{<:LineParameters{
+ T, U, D}}) where {T, U, D <: LineParamsDomain} = domain(D)
+@inline domain(lp::LineParameters) = domain(typeof(lp))
+@inline basis(::Type{<:LineParameters{T, U, D, Basis}}) where {T, U, D, Basis} = Basis
+@inline basis(::LineParameters{T, U, D, Basis}) where {T, U, D, Basis} = Basis
+details(parameters::LineParameters) = parameters.details
+
+observe(lp::LineParameters, ::typeof(frequencies)) = lp.f
+observe(lp::LineParameters, ::typeof(frequencies), indices...) = getindex(lp.f, indices...)
+frequencies(lp::LineParameters, indices...) = observe(lp, frequencies, indices...)
+nconductors(lp::LineParameters) = size(lp.Z, 1)
+nfrequencies(lp::LineParameters) = length(lp.f)
+
+function observables(::Type{<:LineParameters})
+ (
+ frequencies,
+ Z,
+ Y,
+ R,
+ X,
+ L,
+ G,
+ B,
+ C,
+ (Z, abs),
+ (Z, angle),
+ (Y, abs),
+ (Y, angle),
+ (Z, diag),
+ (Y, diag),
+ (R, diag),
+ (X, diag),
+ (L, diag),
+ (G, diag),
+ (B, diag),
+ (C, diag)
+ )
+end
+
+series_impedance(value::Union{LineParameters, SeriesImpedance}) = observe(value, Z)
+shunt_admittance(value::Union{LineParameters, ShuntAdmittance}) = observe(value, Y)
+"""
+ Z(parameters[, i, j[, k]])
+ Y(parameters[, i, j[, k]])
+
+Return series impedance or shunt admittance. With `(i, j)`, return the complete
+frequency response at that matrix position. Use one index, a range or `:` for `k`. Stored units follow [`basis`](@ref): \\[Ω/m\\] and \\[S/m\\] for
+`:pul` or \\[Ω\\] and \\[S\\] for `:total`.
+"""
+@inline _observe_array(values::AbstractArray) = values
+@inline _observe_array(values::AbstractArray, i, j) = view(values, i, j, :)
+@inline _observe_array(values::AbstractArray, indices...) = getindex(values, indices...)
+
+function observe(impedance::SeriesImpedance, ::typeof(Z), indices...)
+ _observe_array(impedance.values, indices...)
+end
+function observe(admittance::ShuntAdmittance, ::typeof(Y), indices...)
+ _observe_array(admittance.values, indices...)
+end
+function observe(lp::LineParameters, ::typeof(Z), indices...)
+ _observe_array(lp.Z.values, indices...)
+end
+function observe(lp::LineParameters, ::typeof(Y), indices...)
+ _observe_array(lp.Y.values, indices...)
+end
+
+function observe(value::Union{LineParameters, SeriesImpedance}, ::typeof(R), indices...)
+ real.(observe(value, Z, indices...))
+end
+function observe(value::Union{LineParameters, SeriesImpedance}, ::typeof(X), indices...)
+ imag.(observe(value, Z, indices...))
+end
+function observe(value::Union{LineParameters, ShuntAdmittance}, ::typeof(G), indices...)
+ real.(observe(value, Y, indices...))
+end
+function observe(value::Union{LineParameters, ShuntAdmittance}, ::typeof(B), indices...)
+ imag.(observe(value, Y, indices...))
+end
+
+function observe(value::LineParameters, ::typeof(Z), ::typeof(abs), indices...)
+ abs.(observe(value, Z, indices...))
+end
+function observe(value::SeriesImpedance, ::typeof(Z), ::typeof(abs), indices...)
+ abs.(observe(value, Z, indices...))
+end
+function observe(value::LineParameters, ::typeof(Z), ::typeof(angle), indices...)
+ angle.(observe(value, Z, indices...))
+end
+function observe(value::SeriesImpedance, ::typeof(Z), ::typeof(angle), indices...)
+ angle.(observe(value, Z, indices...))
+end
+function observe(value::LineParameters, ::typeof(Y), ::typeof(abs), indices...)
+ abs.(observe(value, Y, indices...))
+end
+function observe(value::ShuntAdmittance, ::typeof(Y), ::typeof(abs), indices...)
+ abs.(observe(value, Y, indices...))
+end
+function observe(value::LineParameters, ::typeof(Y), ::typeof(angle), indices...)
+ angle.(observe(value, Y, indices...))
+end
+function observe(value::ShuntAdmittance, ::typeof(Y), ::typeof(angle), indices...)
+ angle.(observe(value, Y, indices...))
+end
+
+function _observe_diagonal(values::AbstractArray{T, 3}, indices...) where {T}
+ size(values, 1) == size(values, 2) || throw(DimensionMismatch(
+ "diagonal observation requires square matrix slices"
+ ))
+ diagonal = Matrix{T}(undef, size(values, 1), size(values, 3))
+ for sample in axes(values, 3), mode in axes(values, 1)
+
+ diagonal[mode, sample] = values[mode, mode, sample]
+ end
+ return isempty(indices) ? diagonal : getindex(diagonal, indices...)
+end
+
+const _SeriesResult = Union{LineParameters, SeriesImpedance}
+const _ShuntResult = Union{LineParameters, ShuntAdmittance}
+
+function observe(value::LineParameters, ::typeof(Z), ::typeof(diag), indices...)
+ _observe_diagonal(observe(value, Z), indices...)
+end
+function observe(value::SeriesImpedance, ::typeof(Z), ::typeof(diag), indices...)
+ _observe_diagonal(observe(value, Z), indices...)
+end
+function observe(value::_SeriesResult, ::typeof(R), ::typeof(diag), indices...)
+ _observe_diagonal(observe(value, R), indices...)
+end
+function observe(value::_SeriesResult, ::typeof(X), ::typeof(diag), indices...)
+ _observe_diagonal(observe(value, X), indices...)
+end
+function observe(value::LineParameters, ::typeof(L), ::typeof(diag), indices...)
+ _observe_diagonal(observe(value, L), indices...)
+end
+"""
+$(TYPEDSIGNATURES)
+
+Return diagonal inductances from a standalone impedance tensor and its explicit
+frequency vector \\[Hz\\]. The output axes are conductor × frequency. Optional
+indices select those axes. Values use \\[H/m\\] for `:pul` and \\[H\\] for `:total`.
+"""
+function observe(value::SeriesImpedance, ::typeof(L), ::typeof(diag),
+ frequencies::AbstractVector, indices...)
+ _observe_diagonal(observe(value, L, frequencies), indices...)
+end
+
+function observe(value::LineParameters, ::typeof(Y), ::typeof(diag), indices...)
+ _observe_diagonal(observe(value, Y), indices...)
+end
+function observe(value::ShuntAdmittance, ::typeof(Y), ::typeof(diag), indices...)
+ _observe_diagonal(observe(value, Y), indices...)
+end
+function observe(value::_ShuntResult, ::typeof(G), ::typeof(diag), indices...)
+ _observe_diagonal(observe(value, G), indices...)
+end
+function observe(value::_ShuntResult, ::typeof(B), ::typeof(diag), indices...)
+ _observe_diagonal(observe(value, B), indices...)
+end
+function observe(value::LineParameters, ::typeof(C), ::typeof(diag), indices...)
+ _observe_diagonal(observe(value, C), indices...)
+end
+"""
+$(TYPEDSIGNATURES)
+
+Return diagonal capacitances from a standalone admittance tensor and its explicit
+frequency vector \\[Hz\\]. The output axes are conductor × frequency. Optional
+indices select those axes. Values use \\[F/m\\] for `:pul` and \\[F\\] for `:total`.
+"""
+function observe(value::ShuntAdmittance, ::typeof(C), ::typeof(diag),
+ frequencies::AbstractVector, indices...)
+ _observe_diagonal(observe(value, C, frequencies), indices...)
+end
+
+function observables(::Type{<:SeriesImpedance})
+ (Z, R, X, L, (Z, abs), (Z, angle), (Z, diag), (R, diag), (X, diag), (L, diag))
+end
+function observables(::Type{<:ShuntAdmittance})
+ (Y, G, B, C, (Y, abs), (Y, angle), (Y, diag), (G, diag), (B, diag), (C, diag))
+end
+
+Z(value::Union{LineParameters, SeriesImpedance}, indices...) = observe(value, Z, indices...)
+Y(value::Union{LineParameters, ShuntAdmittance}, indices...) = observe(value, Y, indices...)
+R(value::Union{LineParameters, SeriesImpedance}, indices...) = observe(value, R, indices...)
+X(value::Union{LineParameters, SeriesImpedance}, indices...) = observe(value, X, indices...)
+G(value::Union{LineParameters, ShuntAdmittance}, indices...) = observe(value, G, indices...)
+B(value::Union{LineParameters, ShuntAdmittance}, indices...) = observe(value, B, indices...)
+
+resistance(value::Union{LineParameters, SeriesImpedance}, args...) = observe(value, R, args...)
+reactance(value::Union{LineParameters, SeriesImpedance}, args...) = observe(value, X, args...)
+conductance(value::Union{LineParameters, ShuntAdmittance}, args...) = observe(value, G, args...)
+susceptance(value::Union{LineParameters, ShuntAdmittance}, args...) = observe(value, B, args...)
+
+@inline function _angular_frequencies(lp::LineParameters, k)
+ selected = lp.f[k]
+ any(iszero, selected isa Number ? (selected,) : selected) && throw(
+ DomainError(selected, "L and C are undefined at zero frequency"),
+ )
+ return 2π .* selected
+end
+
+function _angular_frequencies(frequencies::AbstractVector)
+ any(iszero, frequencies) && throw(
+ DomainError(frequencies, "L and C are undefined at zero frequency"),
+ )
+ return reshape(2π .* frequencies, 1, 1, :)
+end
+
+function observe(
+ impedance::SeriesImpedance,
+ ::typeof(L),
+ frequencies::AbstractVector
+)
+ size(impedance, 3) == length(frequencies) || throw(
+ DimensionMismatch("frequency count must match the impedance third dimension"),
+ )
+ return imag.(impedance.values) ./ _angular_frequencies(frequencies)
+end
+
+function observe(
+ impedance::SeriesImpedance,
+ ::typeof(L),
+ frequencies::AbstractVector,
+ indices...
+)
+ return getindex(observe(impedance, L, frequencies), indices...)
+end
+
+function observe(
+ admittance::ShuntAdmittance,
+ ::typeof(C),
+ frequencies::AbstractVector
+)
+ size(admittance, 3) == length(frequencies) || throw(
+ DimensionMismatch("frequency count must match the admittance third dimension"),
+ )
+ return imag.(admittance.values) ./ _angular_frequencies(frequencies)
+end
+
+function observe(
+ admittance::ShuntAdmittance,
+ ::typeof(C),
+ frequencies::AbstractVector,
+ indices...
+)
+ return getindex(observe(admittance, C, frequencies), indices...)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return series inductance using the same frequency-selection grammar as
+[`Z`](@ref), evaluated as:
+
+```math
+L(f) = \\frac{\\operatorname{Im} Z(f)}{2\\pi f}.
+```
+
+Units are \\[H/m\\] for `:pul` and \\[H\\] for `:total`.
+
+# Errors
+
+Throws `DomainError` when a selected frequency is zero.
+"""
+function observe(lp::LineParameters, ::typeof(L))
+ any(iszero, lp.f) && throw(DomainError(lp.f, "L is undefined at zero frequency"))
+ return imag.(lp.Z.values) ./ reshape(2π .* lp.f, 1, 1, :)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return shunt capacitance using the same frequency-selection grammar as
+[`Y`](@ref), evaluated as:
+
+```math
+C(f) = \\frac{\\operatorname{Im} Y(f)}{2\\pi f}.
+```
+
+Units are \\[F/m\\] for `:pul` and \\[F\\] for `:total`.
+
+# Errors
+
+Throws `DomainError` when a selected frequency is zero.
+"""
+function observe(lp::LineParameters, ::typeof(C))
+ any(iszero, lp.f) && throw(DomainError(lp.f, "C is undefined at zero frequency"))
+ return imag.(lp.Y.values) ./ reshape(2π .* lp.f, 1, 1, :)
+end
+
+observe(lp::LineParameters, ::typeof(L), i, j) = observe(lp, L, i, j, :)
+observe(lp::LineParameters, ::typeof(C), i, j) = observe(lp, C, i, j, :)
+function _divide_by_angular_frequency(component, angular_frequency)
+ angular_frequency isa Number && return component ./ angular_frequency
+ ndims(component) == 1 && return component ./ angular_frequency
+ dimensions = (ntuple(_ -> 1, ndims(component) - 1)..., length(angular_frequency))
+ return component ./ reshape(angular_frequency, dimensions)
+end
+
+function observe(lp::LineParameters, ::typeof(L), i, j, k)
+ return _divide_by_angular_frequency(
+ observe(lp, X, i, j, k),
+ _angular_frequencies(lp, k)
+ )
+end
+function observe(lp::LineParameters, ::typeof(C), i, j, k)
+ return _divide_by_angular_frequency(
+ observe(lp, B, i, j, k),
+ _angular_frequencies(lp, k)
+ )
+end
+
+L(lp::LineParameters, args...) = observe(lp, L, args...)
+C(lp::LineParameters, args...) = observe(lp, C, args...)
+inductance(lp::LineParameters, args...) = observe(lp, L, args...)
+capacitance(lp::LineParameters, args...) = observe(lp, C, args...)
diff --git a/src/engine/lineparameters/observations.jl b/src/engine/lineparameters/observations.jl
new file mode 100644
index 000000000..27c5c4ffb
--- /dev/null
+++ b/src/engine/lineparameters/observations.jl
@@ -0,0 +1,234 @@
+const _ObservedLineSource = Union{LineParameters,SeriesImpedance,ShuntAdmittance}
+_line_families(::LineParameters) = (Z,Y)
+_line_families(::SeriesImpedance) = (Z,)
+_line_families(::ShuntAdmittance) = (Y,)
+_primary_family(selector) = selector in (Z,R,X,L) ? Z : selector in (Y,G,B,C) ? Y : nothing
+observation_assumptions(::Union{SeriesImpedance,ShuntAdmittance},selector) = nothing
+function observation_assumptions(source::AbstractCoreResult,selector)
+ family=_primary_family(selector)
+ family===nothing && return nothing
+ return get(get(details(source).data,:selections,(;)),Symbol(nameof(family)),nothing)
+end
+_primary_key(identity) = identity isa Function ? nameof(identity) :
+ first(identity) in (Z,Y) && any(in((abs,angle)),Base.tail(identity)) ?
+ Symbol(nameof(first(identity)),last(filter(in((abs,angle)),Base.tail(identity)))===abs ? :_abs : :_angle) : nameof(first(identity))
+_pair_keys(::typeof(Z)) = ((:R,:X),(:Z_abs,:Z_angle),(:R,:L))
+_pair_keys(::typeof(Y)) = ((:G,:B),(:Y_abs,:Y_angle),(:G,:C))
+_pair_selector(key::Symbol) = key===:Z_abs ? (Z,abs) : key===:Z_angle ? (Z,angle) :
+ key===:Y_abs ? (Y,abs) : key===:Y_angle ? (Y,angle) : (getfield(@__MODULE__,key),)
+
+function _line_request(source,request)
+ identity=request_identity(request)
+ prefix=identity isa Tuple ? identity : (identity,)
+ indices=request_indices(request)
+ first(prefix) in (real,imag,abs,angle) && throw(ArgumentError("use a physical quantity or a family-qualified transform"))
+ if length(prefix)>=2 && prefix[2] in (real,imag)
+ component=first(prefix)===Z ? (prefix[2]===real ? R : X) :
+ first(prefix)===Y ? (prefix[2]===real ? G : B) :
+ throw(ArgumentError("real/imag transforms require Z or Y"))
+ prefix=(component,prefix[3:end]...)
+ end
+ family=_primary_family(first(prefix))
+ family in _line_families(source) || throw(ArgumentError("quantity does not belong to an available primary family"))
+ diagonal=diag in prefix
+ rank=diagonal ? 2 : 3
+ isempty(indices) && (indices=ntuple(_ -> Colon(),rank))
+ !diagonal && length(indices)==2 && (indices=(indices...,Colon()))
+ length(indices)==rank || throw(DimensionMismatch("a primary request requires $rank coordinate selectors"))
+ all(x -> x isa Colon || x isa Integer && !(x isa Bool) || x isa AbstractVector{<:Integer},indices) ||
+ throw(ArgumentError("coordinates must be integer indices, ranges, vectors, or :"))
+ # Polar diagonal access is obtained from the original complex diagonal.
+ supported=Tuple(filter(!=(diag),prefix))
+ identity_checked=length(supported)==1 ? only(supported) : supported
+ identity_checked in observables(typeof(source)) || throw(ArgumentError("unsupported line representation"))
+ return (prefix...,indices...)
+end
+
+function _line_observation_requests(source::_ObservedLineSource,requests::Tuple;complete_pairs::Bool=false)
+ selected=isempty(requests) ? Tuple(_line_families(source)) : requests
+ expanded=Tuple[]
+ for item in selected
+ if item in (real,imag,abs,angle)
+ append!(expanded,[_line_request(source,(family,item)) for family in _line_families(source)])
+ continue
+ end
+ normalized=_line_request(source,item)
+ identity=request_identity(normalized)
+ prefix=identity isa Tuple ? identity : (identity,)
+ if first(prefix) in (Z,Y) && all(==(diag),Base.tail(prefix))
+ for key in first(_pair_keys(first(prefix)))
+ push!(expanded,(_pair_selector(key)...,Base.tail(prefix)...,request_indices(normalized)...))
+ end
+ else
+ push!(expanded,normalized)
+ end
+ end
+ allunique(expanded) || throw(ArgumentError("observation requests must be distinct"))
+ retained=Tuple[]
+ for family in _line_families(source)
+ entries=filter(request -> _primary_family(first(request))===family,expanded)
+ if isempty(entries)
+ template=first(expanded)
+ diagonal=diag in (request_identity(template) isa Tuple ? request_identity(template) : ())
+ entries=[(_pair_selector(key)...,(diagonal ? (diag,) : ())...,request_indices(template)...)
+ for key in first(_pair_keys(family))]
+ end
+ keys=Tuple(_primary_key(request_identity(request)) for request in entries)
+ choices=filter(pair -> all(in(pair),keys),_pair_keys(family))
+ if length(entries)==1 && complete_pairs
+ chosen=first(choices)
+ diagonal=diag in (request_identity(first(entries)) isa Tuple ? request_identity(first(entries)) : ())
+ entries=[(_pair_selector(key)...,(diagonal ? (diag,) : ())...,request_indices(first(entries))...)
+ for key in chosen]
+ elseif length(entries)!=2 || length(unique(keys))!=2 || length(choices)!=1
+ throw(ArgumentError("$(nameof(family)) requires one complete pair: $(_pair_keys(family)); received $keys"))
+ end
+ dimensions=size(source isa LineParameters ? source.Z : source)
+ coordinate(request) = begin
+ identity=request_identity(request)
+ diagonal=identity isa Tuple && diag in identity
+ dims=diagonal ? (dimensions[1],dimensions[3]) : dimensions
+ map(observation_indices,request_indices(request),dims)
+ end
+ coordinate(first(entries))==coordinate(last(entries)) ||
+ throw(DimensionMismatch("both quantities of a primary pair must select identical coordinates and rank"))
+ chosen=only(filter(pair -> Set(pair)==Set(_primary_key(request_identity(q)) for q in entries),_pair_keys(family)))
+ append!(retained,[only(filter(q -> _primary_key(request_identity(q))===key,entries)) for key in chosen])
+ end
+ return (retained=Tuple(retained),displayed=Tuple(expanded))
+end
+Commons.observation_requests(source::LineParameters,requests::Tuple;complete_pairs::Bool=false) =
+ _line_observation_requests(source,requests;complete_pairs)
+Commons.observation_requests(source::Union{SeriesImpedance,ShuntAdmittance},requests::Tuple;complete_pairs::Bool=false) =
+ _line_observation_requests(source,requests;complete_pairs)
+
+"""
+$(TYPEDSIGNATURES)
+
+Select physical matrix coordinates for an indexed line-parameter observation.
+
+# Arguments
+
+- `source`: line parameters, series impedance or shunt admittance tensor.
+- `request`: normalized observable request with row and column and sample indices, or
+ diagonal and sample indices for a diagonal request.
+- `frequencies`: supplied frequency samples \\[Hz\\] or `nothing`. Line parameters
+ use their stored frequencies and reject a differing supplied vector.
+
+# Returns
+
+- A named tuple retaining the original indices, selected row and column and sample
+ positions, frequencies \\[Hz\\], coordinate labels, full tensor extent, domain,
+ and `:matrix` or `:diagonal` representation. Selection order is preserved.
+"""
+function line_coordinates(source,request,frequencies)
+ identity=request_identity(request)
+ diagonal=identity isa Tuple && diag in identity
+ dimensions=size(source isa LineParameters ? source.Z : source)
+ indices=request_indices(request)
+ dims=diagonal ? (dimensions[1],dimensions[3]) : dimensions
+ selected=map(observation_indices,indices,dims)
+ rows=selected[1]
+ columns=diagonal ? copy(rows) : selected[2]
+ samples=last(selected)
+ f=_resolution_frequencies(source,frequencies)
+ f===nothing || length(f)==dimensions[3] || throw(DimensionMismatch("frequency count differs from tensor depth"))
+ labels=source isa LineParameters ? get(details(source).data,:coordinates,nothing) : nothing
+ labels=labels===nothing ? string.(1:dimensions[1]) : copy(labels)
+ return (kind=diagonal ? :diagonal : :matrix,indices,rows,columns,samples,
+ frequencies=f===nothing ? nothing : copy(f[samples]),frequency_unit=Units.units(:base,:hertz),
+ extent=dimensions,labels,domain=source isa LineParameters ? nameof(domain(source)) : :unspecified)
+end
+
+function _line_observation_quantity(source::_ObservedLineSource,request;
+ unit=nothing,clip=true,atol=nothing,frequencies=nothing)
+ identity=request_identity(request)
+ prefix=identity isa Tuple ? identity : (identity,)
+ selector=first(prefix)
+ indices=request_indices(request)
+ coordinates=line_coordinates(source,request,frequencies)
+ f=_resolution_frequencies(source,frequencies)
+ sampled_f=f===nothing ? nothing : f[last(indices)]
+ polar=any(in((abs,angle)),prefix)
+ phase=angle in prefix
+ diagonal=diag in prefix
+ original=polar ? (diagonal ? observe(source,selector,diag,indices...) : observe(source,selector,indices...)) :
+ _line_observation_values(source,request;frequencies=f)
+ resolution=observation_resolution(original,selector;atol,frequencies=sampled_f,
+ result_basis=basis(source),line_length=line_length(source))
+ values=polar ? (phase ? angle.(original) : abs.(original)) : original
+ available=resolution.available .& resolution_available.(values)
+ exact_origin=polar ? iszero.(nominal.(original)) : false
+ phase && (available=available .& .!exact_origin)
+ mask=resolution.unresolved===nothing ? false : resolution.unresolved
+ resolved=broadcast(values,mask,available) do value,unresolved,valid
+ !valid && return missing
+ clip && phase && unresolved && return missing
+ clip && unresolved ? zero(value) : value
+ end
+ reasons=broadcast(available,mask,exact_origin) do valid,unresolved,origin
+ !valid ? (polar && !phase && origin ? :undefined_first_order_magnitude : phase && origin ? :undefined_phase :
+ selector in (L,C) ? :unavailable_proxy : :nonfinite_value) :
+ clip && phase && unresolved ? :engineering_zero_phase : nothing
+ end
+ q=Commons.request_quantity(request)
+ native=Units.native_unit(q,basis(source))
+ target=unit===nothing ? Units.display_unit(q,basis(source)) : unit
+ T=typeof(float(real(nominal(zero(Base.nonmissingtype(eltype(original)))))))
+ factor=Units.scale_factor(native,target,T)
+ threshold_unit=resolution.unit
+ threshold_factor=phase ? one(T) : factor
+ undefined=polar && !phase && any(x -> x===:undefined_first_order_magnitude,
+ reasons isa AbstractArray ? reasons : (reasons,))
+ # Components are retained only for the undefined first-order polar value.
+ # They are scientific values, never a reference back to the source tensor.
+ components=undefined ? (nominal_magnitude=Commons.detach(abs.(nominal.(original))),real=Commons.detach(real.(original)),imaginary=Commons.detach(imag.(original)),
+ unit=Units.native_unit(selector,basis(source))) : nothing
+ return (request,quantity=q,family=Symbol(nameof(_primary_family(selector))),statistic=:value,
+ values=Commons.detach(resolved isa AbstractArray && ndims(resolved)==0 ? only(resolved) : resolved,factor),unit=target,basis=basis(source),coordinates,
+ assumptions=observation_assumptions(source,selector),
+ thresholds=(kind=resolution.kind,values=Commons.detach(resolution.atol,threshold_factor),
+ unit=phase ? threshold_unit : target),available,engineering_zero=mask,clipped=clip,
+ missing_reason=reasons,unavailable_components=components)
+end
+Commons.observation_quantity(source::LineParameters,request;kwargs...) =
+ _line_observation_quantity(source,request;kwargs...)
+Commons.observation_quantity(source::Union{SeriesImpedance,ShuntAdmittance},request;kwargs...) =
+ _line_observation_quantity(source,request;kwargs...)
+
+function Commons.observation_requests(source::CableConstants,requests::Tuple;complete_pairs::Bool=false)
+ selected=isempty(requests) ? (R,L,G,C) : requests
+ Commons.validate_observables(source,selected,())
+ return (retained=selected,displayed=selected)
+end
+
+function Commons.observation_quantity(source::CableConstants,request;
+ unit=nothing,clip=true,atol=nothing,frequencies=nothing)
+ frequencies===nothing || frequencies==[source.frequency] || throw(ArgumentError("frequency differs from cable constants"))
+ indices=request_indices(request)
+ length(indices)<=1 || throw(ArgumentError("cable constants select assembly indices"))
+ index=isempty(indices) ? Colon() : only(indices)
+ selected=observation_indices(index,length(source))
+ selector=request_identity(request)
+ values=getindex(observe(source,selector),index)
+ resolution=observation_resolution(values,selector;atol,result_basis=:pul)
+ resolved = if !clip
+ values
+ elseif resolution.unresolved === nothing
+ resolution.available === false ? missing : values
+ else
+ broadcast(values, resolution.unresolved, resolution.available) do value, unresolved, available
+ available || return missing
+ return unresolved ? zero(value) : value
+ end
+ end
+ q=Units.quantity(selector)
+ target=unit===nothing ? Units.display_unit(q,:pul) : unit
+ T=typeof(float(nominal(zero(eltype(values)))))
+ factor=Units.scale_factor(Units.native_unit(q,:pul),target,T)
+ return (request,quantity=q,family=:constants,statistic=:value,values=Commons.detach(resolved,factor),
+ unit=target,basis=:pul,coordinates=(kind=:assemblies,indices=(index,),assemblies=selected,
+ labels=copy(source.cores),frequencies=[source.frequency],frequency_unit=Units.units(:base,:hertz),extent=(length(source),1)),
+ thresholds=(kind=resolution.kind,values=Commons.detach(resolution.atol,factor),unit=target),
+ available=resolution.available,engineering_zero=resolution.unresolved,clipped=clip,missing_reason=nothing)
+end
diff --git a/src/engine/lineparameters/quantities.jl b/src/engine/lineparameters/quantities.jl
new file mode 100644
index 000000000..f913ffaa3
--- /dev/null
+++ b/src/engine/lineparameters/quantities.jl
@@ -0,0 +1,34 @@
+# Engine-owned line-parameter quantity and unit accessors.
+Units.quantity(::typeof(frequencies)) = Units.Quantity{:frequency}()
+Units.quantity(::typeof(Z)) = Units.Quantity{:series_impedance}()
+Units.quantity(::typeof(Y)) = Units.Quantity{:shunt_admittance}()
+Units.quantity(::typeof(X)) = Units.Quantity{:series_reactance}()
+Units.quantity(::typeof(G)) = Units.Quantity{:shunt_conductance}()
+Units.quantity(::typeof(B)) = Units.Quantity{:shunt_susceptance}()
+
+Units.quantity(::typeof(Z), ::typeof(abs)) =
+ Units.Quantity{(:series_impedance, :magnitude)}()
+Units.quantity(::typeof(Z), ::typeof(angle)) =
+ Units.Quantity{(:series_impedance, :phase_angle)}()
+Units.quantity(::typeof(Y), ::typeof(abs)) =
+ Units.Quantity{(:shunt_admittance, :magnitude)}()
+Units.quantity(::typeof(Y), ::typeof(angle)) =
+ Units.Quantity{(:shunt_admittance, :phase_angle)}()
+
+Units.quantity(::typeof(Z), ::typeof(diag)) = Units.quantity(Z)
+Units.quantity(::typeof(Y), ::typeof(diag)) = Units.quantity(Y)
+Units.quantity(::typeof(R), ::typeof(diag)) = Units.quantity(R)
+Units.quantity(::typeof(X), ::typeof(diag)) = Units.quantity(X)
+Units.quantity(::typeof(L), ::typeof(diag)) = Units.quantity(L)
+Units.quantity(::typeof(G), ::typeof(diag)) = Units.quantity(G)
+Units.quantity(::typeof(B), ::typeof(diag)) = Units.quantity(B)
+Units.quantity(::typeof(C), ::typeof(diag)) = Units.quantity(C)
+
+Units.quantity(::typeof(Z), ::typeof(absolute_error)) =
+ Units.Quantity{:series_impedance_absolute_error}()
+Units.quantity(::typeof(Z), ::typeof(relative_error)) =
+ Units.Quantity{:series_impedance_relative_error}()
+Units.quantity(::typeof(Y), ::typeof(absolute_error)) =
+ Units.Quantity{:shunt_admittance_absolute_error}()
+Units.quantity(::typeof(Y), ::typeof(relative_error)) =
+ Units.Quantity{:shunt_admittance_relative_error}()
diff --git a/src/engine/lineparameters/resolution.jl b/src/engine/lineparameters/resolution.jl
new file mode 100644
index 000000000..056499e62
--- /dev/null
+++ b/src/engine/lineparameters/resolution.jl
@@ -0,0 +1,209 @@
+# Exact decimal engineering floors in native per-metre units. Convert before
+# frequency or length scaling. These are not solver-error estimates.
+const _LINE_RESOLUTION_DEFAULTS = (R=1//10^10, L=1//10^15, G=1//10^12, C=1//10^16)
+const _LINE_RESOLUTION_QUANTITIES = (Z, Y, R, X, L, G, B, C)
+
+function _validate_resolution_atol(atol)
+ atol === nothing && return nothing
+ limits = atol isa NamedTuple ? values(atol) : (atol,)
+ if atol isa NamedTuple
+ all(name -> name in (:R,:X,:L,:G,:B,:C), keys(atol)) ||
+ throw(ArgumentError("atol keys must be R, X, L, G, B, or C; complex zero uses component cutoffs"))
+ for pair in ((:X,:L),(:B,:C))
+ all(key -> haskey(atol,key),pair) && throw(ArgumentError(
+ "supply only one cutoff for $(join(pair, '/')); their thresholds are linked by 2πf"))
+ end
+ end
+ all(value -> value isa Real && !(value isa Bool) && isfinite(value) && value >= 0, limits) ||
+ throw(ArgumentError("atol must be finite and nonnegative"))
+ return nothing
+end
+
+_resolution_frequencies(source, supplied) = supplied
+function _resolution_frequencies(source::LineParameters, supplied)
+ supplied === nothing || supplied == source.f || throw(ArgumentError(
+ "supplied frequencies must match the stored LineParameters frequency axis"))
+ return source.f
+end
+
+_cutoff_number(::Type{T}, value::Rational) where {T} = T(value)
+_cutoff_number(::Type{T}, value::Real) where {T} = convert(promote_type(T,typeof(float(value))),value)
+
+function _line_resolution_tolerance(quantity, ::Type{T}, f, atol;
+ result_basis=:pul, line_length=nothing) where {T}
+ name = nameof(quantity)
+ atol isa Real && name in (:Z,:Y) && throw(ArgumentError(
+ "complex zero requires component cutoffs, for example atol=(R=0, X=0)"))
+ overrides = atol === nothing ? (;) : atol isa NamedTuple ? atol : NamedTuple{(name,)}((atol,))
+ component = function (selector)
+ key=nameof(selector)
+ haskey(overrides,key) && return _cutoff_number(T,overrides[key])
+ partner = key === :X ? :L : key === :L ? :X : key === :B ? :C : key === :C ? :B : nothing
+ linked = key in (:X,:B) || (partner !== nothing && haskey(overrides,partner))
+ if linked
+ f === nothing && return nothing
+ angular = T(2)*T(π) .* T.(nominal.(f))
+ other = _line_resolution_tolerance(getfield(@__MODULE__,partner),T,f,overrides;
+ result_basis,line_length)
+ other === nothing && return nothing
+ # L/C at zero frequency are unavailable. Never divide by zero.
+ return key in (:X,:B) ? angular .* other :
+ broadcast((cutoff,w) -> iszero(w) ? zero(cutoff) : cutoff/w,other,angular)
+ end
+ cutoff=T(getproperty(_LINE_RESOLUTION_DEFAULTS,key))
+ result_basis === :pul && return cutoff
+ line_length === nothing && throw(ArgumentError(
+ "total $key reporting requires retained line length or an explicit total-unit cutoff"))
+ length_value=nominal(line_length)
+ isfinite(length_value) && length_value>0 || throw(ArgumentError("line length must be finite and positive"))
+ return cutoff * length_value
+ end
+ name === :Z && return (R=component(R),X=component(X))
+ name === :Y && return (G=component(G),B=component(B))
+ return component(quantity)
+end
+
+"""Return whether a scalar has finite nominal and first-order uncertainty values."""
+resolution_available(value) = false
+resolution_available(value::Real) = isfinite(nominal(value)) && isfinite(uncertainty(value))
+resolution_available(value::Complex) = resolution_available(real(value)) && resolution_available(imag(value))
+_resolution_unresolved(value::Number, tolerance::Real) =
+ resolution_available(value) && abs(nominal(value)) <= tolerance
+_resolution_unresolved(value, tolerance) = false
+
+_aligned_cutoff(tolerance::Real, values) = tolerance
+_aligned_cutoff(tolerance::AbstractArray, values::Number) = only(tolerance)
+_aligned_cutoff(tolerance::AbstractArray, values::AbstractArray) =
+ reshape(tolerance, (ntuple(_ -> 1,ndims(values)-1)...,length(tolerance)))
+_cutoff_available(value::Real) = isfinite(value) && value>=0
+_cutoff_available(value::AbstractArray) = all(_cutoff_available,value)
+_cutoff_available(value::NamedTuple) = all(_cutoff_available,values(value))
+_cutoff_available(::Nothing) = false
+
+"""
+$(TYPEDSIGNATURES)
+
+Classify original quantities using `abs(nominal(value)) ≤ cutoff`. Complex zero
+requires both Cartesian components to satisfy their cutoffs. Availability is
+separate. Physical standard uncertainty never contributes to a reporting floor.
+
+# Keywords
+
+- `atol`: component cutoffs in native basis units.
+- `frequencies`: aligned frequencies \\[Hz\\] for linked X/L and B/C cutoffs.
+- `result_basis=:pul`: per-metre or `:total` quantities.
+- `line_length`: physical length \\[m\\] for scaling default total-unit floors.
+
+# Returns
+
+- Applied thresholds, native units, and aligned availability and nominal-zero
+ masks. An unknown frequency-dependent threshold is explicitly unassessed.
+"""
+function observation_resolution(values::Union{Number,AbstractArray}, selector::Function;
+ atol=nothing, frequencies=nothing, result_basis::Symbol=:pul, line_length=nothing)
+ _validate_resolution_atol(atol)
+ validate(result_basis, LineParameters)
+ selector in _LINE_RESOLUTION_QUANTITIES ||
+ return observation_resolution(nothing,selector;atol,frequencies)
+ if frequencies isa Real
+ isfinite(frequencies) && frequencies>=0 || throw(ArgumentError("frequency must be finite and nonnegative in Hz"))
+ elseif frequencies !== nothing
+ frequencies isa AbstractVector && all(f -> f isa Real && isfinite(f) && f>=0,frequencies) ||
+ throw(ArgumentError("frequencies must be finite and nonnegative in Hz"))
+ (values isa Number ? 1 : size(values,ndims(values))) == length(frequencies) ||
+ throw(DimensionMismatch("frequencies must match the quantity depth"))
+ end
+ T=typeof(float(real(nominal(zero(Base.nonmissingtype(eltype(values)))))))
+ tolerance=_line_resolution_tolerance(selector,T,frequencies,atol;result_basis,line_length)
+ assessed = tolerance !== nothing && (!(tolerance isa NamedTuple) || all(!isnothing,Base.values(tolerance)))
+ available=resolution_available.(values)
+ if selector in (L,C) && frequencies!==nothing
+ available = available .& (.!iszero.(_aligned_cutoff(frequencies,values)))
+ end
+ assessed || return (kind=:unassessed,atol=nothing,unit=Units.native_unit(selector,result_basis),
+ unresolved=nothing,available)
+ _cutoff_available(tolerance) || throw(ArgumentError("cutoffs must remain finite and nonnegative"))
+ unresolved = if selector in (Z,Y)
+ first_limit,last_limit=Base.values(tolerance)
+ _resolution_unresolved.(real.(values),_aligned_cutoff(first_limit,values)) .&
+ _resolution_unresolved.(imag.(values),_aligned_cutoff(last_limit,values))
+ else
+ _resolution_unresolved.(values,_aligned_cutoff(tolerance,values))
+ end
+ return (kind=:declared_floor,atol=tolerance,unit=Units.native_unit(selector,result_basis),
+ unresolved=unresolved .& available,available)
+end
+
+observation_resolution(source::Union{SeriesImpedance,ShuntAdmittance},request::Function;
+ atol=nothing,frequencies=nothing) = observation_resolution(source,(request,);atol,frequencies)
+
+# Observation and comparison admit unavailable DC proxies without changing the
+# strict raw L/C accessors used by numerical algorithms.
+function _line_observation_values(source, request; frequencies=nothing)
+ identity=request_identity(request)
+ selector=identity isa Tuple ? first(identity) : identity
+ indices=request_indices(request)
+ if selector in (L,C) && source isa Union{LineParameters,SeriesImpedance,ShuntAdmittance}
+ f=_resolution_frequencies(source,frequencies)
+ if source isa Union{SeriesImpedance,ShuntAdmittance} && !isempty(indices) && length(indices)>3 && first(indices) isa AbstractVector{<:Real}
+ f=first(indices)
+ indices=Base.tail(indices)
+ end
+ f===nothing && throw(ArgumentError("L/C observations require frequencies in Hz"))
+ transforms=identity isa Tuple ? Base.tail(identity) : ()
+ diagonal=diag in transforms
+ proxy=selector===L ? X : B
+ values=diagonal ? observe(source,proxy,diag,indices...) : observe(source,proxy,indices...)
+ sample=length(indices)==(diagonal ? 2 : 3) ? last(indices) : Colon()
+ selected=f[sample]
+ aligned=_aligned_cutoff(selected,values)
+ T=typeof(float(real(nominal(zero(eltype(values))))))
+ V=typeof(zero(eltype(values))/(T(2)*T(π)*one(eltype(f))))
+ output=Array{Union{Missing,V}}(undef,values isa Number ? () : size(values))
+ broadcast!(output,values,aligned) do value,frequency
+ iszero(frequency) && return missing
+ value/(T(2)*T(π)*frequency)
+ end
+ return output
+ end
+ return request isa Function ? observe(source,request) : observe(source,request...)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Classify an owned line observation in native units, using original complex
+components for polar requests.
+"""
+function observation_resolution(source::Union{AbstractCoreResult,SeriesImpedance,ShuntAdmittance},
+ request;atol=nothing,frequencies=nothing)
+ _validate_resolution_atol(atol)
+ identity=request_identity(request)
+ selector=identity isa Tuple ? first(identity) : identity
+ selector in _LINE_RESOLUTION_QUANTITIES || return observation_resolution(nothing,request;atol,frequencies)
+ f=_resolution_frequencies(source,frequencies)
+ indices=request_indices(request)
+ if source isa Union{SeriesImpedance,ShuntAdmittance} && selector in (L,C) && !isempty(indices)
+ embedded=first(indices)
+ f===nothing || f==embedded || throw(ArgumentError("request and supplied frequencies differ"))
+ f=embedded
+ indices=Base.tail(indices)
+ end
+ if f!==nothing
+ f isa AbstractVector && all(x -> x isa Real && isfinite(x) && x>=0,f) ||
+ throw(ArgumentError("frequencies must be finite and nonnegative in Hz"))
+ parent=selector in (Z,R,X,L) ? Z : Y
+ size(observe(source,parent),3)==length(f) || throw(DimensionMismatch("frequencies must match tensor depth"))
+ end
+ transforms=identity isa Tuple ? Base.tail(identity) : ()
+ polar=any(transform -> transform in (abs,angle),transforms)
+ diagonal=diag in transforms
+ values = if polar
+ diagonal ? observe(source,selector,diag,indices...) : observe(source,selector,indices...)
+ else
+ _line_observation_values(source,request;frequencies=f)
+ end
+ sample=length(indices)==(diagonal ? 2 : 3) ? last(indices) : Colon()
+ return observation_resolution(values,selector;atol,frequencies=f===nothing ? nothing : f[sample],
+ result_basis=basis(source),line_length=line_length(source))
+end
diff --git a/src/engine/lineparamopts.jl b/src/engine/lineparamopts.jl
deleted file mode 100644
index d3e03c044..000000000
--- a/src/engine/lineparamopts.jl
+++ /dev/null
@@ -1,88 +0,0 @@
-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
deleted file mode 100644
index 5c09a92e0..000000000
--- a/src/engine/lineparams.jl
+++ /dev/null
@@ -1,118 +0,0 @@
-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, D <: LineParamsDomain}
- "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(
- ::Type{D},
- Z::SeriesImpedance{T},
- Y::ShuntAdmittance{T},
- f::AbstractVector{U},
- ) where {D <: LineParamsDomain, 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, D}(Z, Y, Vector{U}(f))
- end
-
- # Backward-compatible constructor: defaults to PhaseDomain
- LineParameters(
- Z::SeriesImpedance{T},
- Y::ShuntAdmittance{T},
- f::AbstractVector{U},
- ) where {T <: COMPLEXSCALAR, U <: REALSCALAR} =
- LineParameters(PhaseDomain, Z, Y, f)
-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.
-"""
-LineParameters(
- ::Type{D},
- Z::AbstractArray{Tc, 3},
- Y::AbstractArray{Tc, 3},
- f::AbstractVector{U},
-) where {D <: LineParamsDomain, Tc <: COMPLEXSCALAR, U <: REALSCALAR} =
- LineParameters(D, SeriesImpedance(Z), ShuntAdmittance(Y), f)
-
-
-# Backward-compatible constructor: defaults to PhaseDomain
-LineParameters(
- Z::AbstractArray{Tc, 3},
- Y::AbstractArray{Tc, 3},
- f::AbstractVector{U},
-) where {Tc <: COMPLEXSCALAR, U <: REALSCALAR} =
- LineParameters(PhaseDomain, Z, Y, f)
-
-
-# """
-# $(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
-
-# --- Tiny domain extractors ---------------------------------------------------
-"""
-Return the domain tag type for `LineParameters` objects.
-"""
-@inline domain(::Type{<:LineParameters{T, U, D}}) where {T, U, D <: LineParamsDomain} = D
-@inline domain(lp::LineParameters) = domain(typeof(lp))
diff --git a/src/engine/modalanalysis/ModalAnalysis.jl b/src/engine/modalanalysis/ModalAnalysis.jl
new file mode 100644
index 000000000..0550f5091
--- /dev/null
+++ b/src/engine/modalanalysis/ModalAnalysis.jl
@@ -0,0 +1,71 @@
+"""
+ LineCableModels.Engine.ModalAnalysis
+
+Transform fully coupled line-parameter matrices between phase and modal
+coordinate domains independently of the backend that calculated them.
+
+# Dependencies
+
+$(IMPORTS)
+"""
+module ModalAnalysis
+
+export ModalAnalysisProblem, ModalAnalysisFormulation
+export LineCableModelsModal, ModalOperators, Formula
+export operators, Tv, Ti, gamma, alpha, beta, velocity, Zc, Yc, H, PropagationParameters, transform
+export formula_id, formulas
+
+#! explicit-imports: off
+using DocStringExtensions: IMPORTS, TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+import ...LineCableModels: Expression, nominal, FormulaDefinition, formula, parameterize, validate
+import ...LineCableModels: line_length
+import ...LineCableModels: Grid, Gridpoint, Gridspace
+import ...LineCableModels
+import ...Commons: AbstractProblemDefinition, AbstractFormulation,
+ FormulationOptions, ComputationOptions, ComputationDetails,
+ compute, computation_options, computation_details, formulation_options, details,
+ initialize_buffers, formulas
+import ...Commons: AbstractResultSpace
+import ...Commons: observe, observables, request_identity, request_indices, observation_indices
+import ..Engine: LineParameters, LineParametersFormulation, LineParametersProblem,
+ PhaseDomain, ModalDomain, SeriesImpedance, ShuntAdmittance, basis, frequencies,
+ description, formula_id, selectdomain, selectdetails
+using LinearAlgebra: Diagonal, I, checksquare, cond, diag, dot, eigen,
+ eigen!, issuccess, ldiv!, lu!, mul!, norm, rdiv!, svd!, svdvals!
+import ...Commons: AbstractCoreResult
+import ..Engine
+import ...Commons
+using ...Commons: vacuum_permittivity, vacuum_permeability
+import ...Units
+import ...TextDisplay
+#! explicit-imports: on
+
+include("interfaces.jl")
+include("problems.jl")
+
+include("formulations.jl")
+public decompose!
+include("eigensystems.jl")
+include("compute.jl")
+include("composition.jl")
+
+#! explicit-imports: off
+const FORMULAS = (
+ include("formulas/chrysochos2014.jl"),
+ include("formulas/vieira2026.jl"),
+ include("formulas/wedepohl1996.jl"),
+ include("formulas/default.jl"),
+)
+#! explicit-imports: on
+
+"""
+Return the built-in modal-transformation formula identifiers.
+"""
+formulas(::Type{<:Formula}) = FORMULAS
+
+include("quantities.jl")
+include("propagation.jl")
+include("observations.jl")
+include("textdisplay.jl")
+
+end # module ModalAnalysis
diff --git a/src/engine/modalanalysis/composition.jl b/src/engine/modalanalysis/composition.jl
new file mode 100644
index 000000000..60b3978b9
--- /dev/null
+++ b/src/engine/modalanalysis/composition.jl
@@ -0,0 +1,25 @@
+# A third positional argument composes a line-parameter computation with modal
+# analysis of its result, whatever formulation computed it. Each upstream wrapper
+# keeps its original no-modal execution path.
+const _ModalSelection=Union{ModalAnalysisFormulation,
+ Gridspace{<:ModalAnalysisFormulation}}
+
+function compute(problem::Union{LineParametersProblem,
+ Gridspace{<:LineParametersProblem},Gridpoint{<:LineParametersProblem}},
+ formulation,modal::_ModalSelection;
+ options::Union{NamedTuple,ComputationOptions}=ComputationOptions(),
+ modal_options::Union{NamedTuple,ComputationOptions}=ComputationOptions())
+ phase=compute(problem,formulation;options)
+ return _modal_results(phase,modal,modal_options)
+end
+
+function _modal_results(phase::LineParameters,modal,options)
+ return compute(ModalAnalysisProblem(phase),modal;options)
+end
+function _modal_results(phase::AbstractResultSpace,modal,options)
+ return compute(Gridspace{ModalAnalysisProblem}(phase),modal;options)
+end
+function _modal_results(phase::AbstractVector{<:LineParameters},modal,options)
+ problems=Gridspace{ModalAnalysisProblem}(ModalAnalysisProblem,(Grid(phase),))
+ return compute(problems,modal;options).values
+end
diff --git a/src/engine/modalanalysis/compute.jl b/src/engine/modalanalysis/compute.jl
new file mode 100644
index 000000000..8d843040c
--- /dev/null
+++ b/src/engine/modalanalysis/compute.jl
@@ -0,0 +1,258 @@
+function offdiagonal_ratio(matrix::AbstractMatrix)
+ n = checksquare(matrix)
+ n > 0 || throw(ArgumentError("modal matrix must be nonempty"))
+ first_value = nominal(matrix[1, 1])
+ matrix_norm_squared = zero(real(abs2(first_value)))
+ offdiagonal_norm_squared = zero(matrix_norm_squared)
+ @inbounds for column in 1:n, row in 1:n
+ squared = abs2(nominal(matrix[row, column]))
+ matrix_norm_squared += squared
+ row == column || (offdiagonal_norm_squared += squared)
+ end
+ matrix_norm = sqrt(matrix_norm_squared)
+ return sqrt(offdiagonal_norm_squared) /
+ max(matrix_norm, eps(float(real(matrix_norm))))
+end
+
+# The operators transform the coefficients of `parameters` between modal and phase domains.
+function validate(maps::ModalOperators, parameters::LineParameters)
+ expected = size(parameters.Z.values)
+ size(maps.Tv) == expected || throw(DimensionMismatch("Tv must have size $expected"))
+ size(maps.Ti) == expected || throw(DimensionMismatch("Ti must have size $expected"))
+ all(isfinite, maps.Tv) || throw(DomainError(maps.Tv, "Tv must be finite"))
+ all(isfinite, maps.Ti) || throw(DomainError(maps.Ti, "Ti must be finite"))
+ return maps
+end
+
+function computation_options(::Type{LineCableModelsModal}, record::ComputationOptions)::ComputationOptions
+ options = record.data
+ isempty(setdiff(keys(options), (:rotate, :offdiagonal_tolerance, :on_result, :timing, :verbosity))) ||
+ throw(ArgumentError("unknown modal action options: $(Tuple(keys(options)))"))
+ tolerance = get(options, :offdiagonal_tolerance, 1e-6)
+ tolerance isa Real && isfinite(tolerance) && tolerance >= 0 ||
+ throw(ArgumentError("offdiagonal_tolerance must be finite and nonnegative"))
+ callback = get(options, :on_result, nothing)
+ (callback === nothing || callback isa Function) ||
+ throw(ArgumentError("on_result must be a callable function or nothing"))
+ timing = get(options, :timing, false)
+ timing isa Bool || throw(ArgumentError("timing must be Bool"))
+ rotate = get(options, :rotate, true)
+ rotate isa Bool || throw(ArgumentError("rotate must be Bool"))
+ return ComputationOptions(; offdiagonal_tolerance=tolerance, on_result=callback,
+ timing, rotate, verbosity=get(options, :verbosity, 0))
+end
+
+"""Store one scalar modal computation's results, common arrays, selected work, and diagnostics."""
+struct ModalAnalysisWorkspace{P,I,V,R,Z,Y,B,D}
+ source::P
+ input::I
+ Tv::V
+ Ti::V
+ roots::R
+ Zm::Z
+ Ym::Y
+ buffers::B
+ diagnostics::D
+end
+
+formula_parameters(selected::Formula) = selected.parameters
+formula_parameters(::AbstractFormulation) = (;)
+
+function ModalAnalysisWorkspace(source::LineParameters, selected::AbstractFormulation)
+ Zp = nominal(source.Z.values)
+ Yp = nominal(source.Y.values)
+ n, _, nf = size(Zp)
+ ell0 = line_length(source)
+ factor = one(real(zero(eltype(Zp))))
+ if basis(source) === :total && ell0 !== nothing
+ ell0 isa Real && !(ell0 isa Bool) && isfinite(nominal(ell0)) && nominal(ell0) > 0 ||
+ throw(ArgumentError("total coefficients require a positive source normalization length"))
+ factor = nominal(ell0)
+ end
+ input = (Z=Zp, Y=Yp, f=nominal(source.f),
+ root_scale=factor, source_basis=basis(source))
+ T = float(promote_type(eltype(input.Z), eltype(input.Y)))
+ Tv = Array{T,3}(undef, n, n, nf)
+ Ti = similar(Tv)
+ roots = Array{T,2}(undef, n, nf)
+ S = promote_type(eltype(source.Z.values), eltype(Tv))
+ Zm = Array{S,3}(undef, n, n, nf)
+ Ym = similar(Zm)
+ plan = (; n, nf)
+ common = (Zslice=Matrix{T}(undef,n,n),Yslice=Matrix{T}(undef,n,n),
+ coordinate_product=Matrix{S}(undef,n,n),
+ coordinate_factor=Matrix{T}(undef,n,n),
+ admittance_impedance_product=Matrix{T}(undef,n,n),
+ previous_eigenvalues=Vector{T}(undef,n),eigenvalues=Vector{T}(undef,n),
+ previous_eigenvectors=Matrix{T}(undef,n,n),eigenvectors=Matrix{T}(undef,n,n),
+ propagation_eigenvalues=Vector{T}(undef,n),voltage_vector=Vector{T}(undef,n))
+ buffers = initialize_buffers(selected, T, input, plan, common)
+ R=typeof(real(zero(T)))
+ residual=Matrix{Union{Nothing,R}}(undef,n,nf)
+ iteration_counts=Matrix{Union{Nothing,Int}}(undef,n,nf)
+ convergence=Matrix{Union{Nothing,Bool}}(undef,n,nf)
+ fill!(residual,nothing)
+ fill!(iteration_counts,nothing)
+ fill!(convergence,nothing)
+ diagnostics = (fallback_frequencies=Int[], missed_frequencies=Int[],
+ z_coupling=Vector{typeof(real(zero(T)))}(undef, nf),
+ y_coupling=Vector{typeof(real(zero(T)))}(undef, nf),
+ eigen_residual=residual,iterations=iteration_counts,converged=convergence)
+ return ModalAnalysisWorkspace(source, input, Tv, Ti, roots, Zm, Ym, buffers, diagnostics)
+end
+
+function decompose! end
+
+function _coordinate_algebra!(workspace::ModalAnalysisWorkspace, execution)
+ source = workspace.source
+ nfreq = size(workspace.Zm, 3)
+ tolerance = execution.data.offdiagonal_tolerance
+ product=workspace.buffers.coordinate_product
+ factor=workspace.buffers.coordinate_factor
+ for frequency in 1:nfreq
+ Tvf = @view workspace.Tv[:, :, frequency]
+ Tif = @view workspace.Ti[:, :, frequency]
+ Zp = @view source.Z.values[:, :, frequency]
+ Yp = @view source.Y.values[:, :, frequency]
+ mul!(product,Zp,Tif)
+ copyto!(factor,Tvf)
+ ldiv!(lu!(factor),product)
+ copyto!(@view(workspace.Zm[:,:,frequency]),product)
+ mul!(product,Yp,Tvf)
+ copyto!(factor,Tif)
+ ldiv!(lu!(factor),product)
+ copyto!(@view(workspace.Ym[:,:,frequency]),product)
+ zr = offdiagonal_ratio(@view(workspace.Zm[:, :, frequency]))
+ yr = offdiagonal_ratio(@view(workspace.Ym[:, :, frequency]))
+ workspace.diagnostics.z_coupling[frequency] = zr
+ workspace.diagnostics.y_coupling[frequency] = yr
+ if zr > tolerance || yr > tolerance
+ @warn "modal coefficients exceed the requested off-diagonal tolerance" frequency zr yr tolerance
+ end
+ end
+ return workspace
+end
+
+# Differentiate the diagonal product at the retained nominal branch. The maps
+# stay nominal. Uncertainty in the transformed coefficients remains connected
+# to its original scalar sources.
+function _dependent_roots(workspace::ModalAnalysisWorkspace)
+ nominal_roots=workspace.roots
+ eltype(workspace.Zm)===eltype(nominal_roots) && return copy(nominal_roots)
+ S=promote_type(eltype(workspace.Zm),eltype(nominal_roots))
+ roots=Array{S}(undef,size(nominal_roots))
+ for frequency in axes(roots,2), mode in axes(roots,1)
+ root=nominal_roots[mode,frequency]
+ iszero(root) && throw(DomainError(root,
+ "first-order modal root is undefined at a zero nominal root"))
+ product=workspace.Zm[mode,mode,frequency]*workspace.Ym[mode,mode,frequency]
+ roots[mode,frequency]=root+(product-nominal(product))/(2root)
+ end
+ return roots
+end
+
+# Keep the completed record's outer key and type layout inferable even when an
+# upstream gridpoint or formula description is intentionally type-erased.
+@generated function _modal_detail_merge(record::NamedTuple{Names,Types},
+ extra::NamedTuple{ExtraNames,ExtraTypes}) where {Names,Types,ExtraNames,ExtraTypes}
+ retained=filter(name -> name ∉ ExtraNames &&
+ name ∉ (:timing,:comparison_unsupported),Names)
+ names=(retained...,ExtraNames...)
+ types=(map(name -> fieldtype(Types,findfirst(==(name),Names)),retained)...,
+ fieldtypes(ExtraTypes)...)
+ compact=map(type -> type<:NamedTuple ? NamedTuple : type,types)
+ output=NamedTuple{names,Tuple{compact...}}
+ entries=[:(getproperty(record,$(QuoteNode(name)))) for name in retained]
+ append!(entries,[:(getproperty(extra,$(QuoteNode(name)))) for name in ExtraNames])
+ return :(ComputationDetails($output(($(entries...),))))
+end
+
+Base.@constprop :aggressive function _modal_completion(parameters, formulation, selected, diagnostics, execution)
+ source_record=parameters.details.data
+ source_fields=get(source_record,:formulation_fields,(;))
+ modal_descriptions=Engine.completed_formulation(formulation).formulation_fields.all
+ modal_descriptions=filter(field -> !isempty(field.meaning),modal_descriptions)
+ descriptions=vcat(get(source_fields,:all,NamedTuple[]),modal_descriptions)
+ fields=merge(source_fields,
+ (all=descriptions,Z=copy(descriptions),Y=copy(descriptions)))
+ added=NamedTuple{(:source_gridpoint,:phase_coordinates,:coordinates,:modal,:formulation_fields),
+ Tuple{Any,Any,Vector{String},NamedTuple,NamedTuple}}((
+ get(parameters.details.data, :gridpoint, nothing),
+ get(parameters.details.data, :coordinates, nothing),
+ string.(1:size(parameters.Z,1)),
+ (identifier=formula_id(selected), requested=NamedTuple(formulation.definition),
+ effective=NamedTuple(selected), rotate=execution.data.rotate, diagnostics=diagnostics),
+ fields))
+ return _modal_detail_merge(source_record,added)
+end
+
+function compute(::LineCableModelsModal,
+ problem::ModalAnalysisProblem{<:LineParameters{T,U,PhaseDomain,Basis}},
+ formulation::ModalAnalysisFormulation, execution::ComputationOptions) where {T,U,Basis}
+ parameters=problem.parameters
+ selected = formulation.formula
+ workspace = ModalAnalysisWorkspace(parameters, selected)
+ decompose!(selected, workspace, formula_parameters(selected),
+ formulation_options(selected))
+ validate(ModalOperators(workspace.Tv, workspace.Ti), parameters)
+ orient_modes!(workspace.Tv, workspace.Ti, execution.data.rotate)
+ maps = ModalOperators(copy(workspace.Tv), copy(workspace.Ti))
+ _coordinate_algebra!(workspace, execution)
+ roots = _dependent_roots(workspace)
+ all(isfinite, roots) || throw(DomainError(roots, "modal roots must be finite"))
+ modal = ModalDomain(maps, roots)
+ diagnostics = map(copy,workspace.diagnostics)
+ retained = _modal_completion(parameters, formulation, selected, diagnostics, execution)
+ return LineParameters(modal,
+ SeriesImpedance{eltype(workspace.Zm),Basis}(copy(workspace.Zm)),
+ ShuntAdmittance{eltype(workspace.Ym),Basis}(copy(workspace.Ym)),
+ parameters.f, retained)
+end
+
+"""Restore phase coefficients using the retained modal-to-phase bases."""
+function transform(::Type{PhaseDomain}, parameters::LineParameters{T,U,D,Basis}) where {T,U,D<:ModalDomain,Basis}
+ maps = validate(parameters.domain.operators, parameters)
+ dimensions = size(parameters.Z.values)
+ S = promote_type(T, eltype(maps.Tv), eltype(maps.Ti))
+ impedance = Array{S,3}(undef, dimensions)
+ admittance = similar(impedance)
+ for frequency in axes(impedance, 3)
+ Tvf = @view maps.Tv[:, :, frequency]
+ Tif = @view maps.Ti[:, :, frequency]
+ @views impedance[:, :, frequency] .= (Tvf * parameters.Z.values[:, :, frequency]) / Tif
+ @views admittance[:, :, frequency] .= (Tif * parameters.Y.values[:, :, frequency]) / Tvf
+ end
+ retained = ComputationDetails(merge(parameters.details.data,
+ (coordinates=get(parameters.details.data,:phase_coordinates,nothing),)))
+ return LineParameters(PhaseDomain,
+ SeriesImpedance{S,Basis}(impedance),
+ ShuntAdmittance{S,Basis}(admittance), parameters.f,
+ retained)
+end
+
+function compute(problem::ModalAnalysisProblem{P}, formulation::ModalAnalysisFormulation;
+ options::Union{NamedTuple,ComputationOptions}=ComputationOptions()) where {P}
+ return compute(LineCableModelsModal(), problem, formulation; options)
+end
+
+function compute(::LineCableModelsModal, problem::ModalAnalysisProblem{P},
+ formulation::ModalAnalysisFormulation;
+ options::Union{NamedTuple,ComputationOptions}=ComputationOptions()) where {P}
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ execution = computation_options(LineCableModelsModal, options)
+ result = if execution.data.timing
+ measured = Base.@timed compute(LineCableModelsModal(),problem,formulation,execution)
+ Engine.retain_gridpoint(measured.value, details(measured.value).data.gridpoint;
+ fields=(timing=(wall_seconds=measured.time, bytes=measured.bytes,
+ gc_seconds=measured.gctime, compile_seconds=measured.compile_time,
+ recompile_seconds=measured.recompile_time),))
+ else
+ compute(LineCableModelsModal(),problem,formulation,execution)
+ end
+ execution.data.on_result === nothing || execution.data.on_result(problem, 1, result)
+ return result
+end
+
+function computation_details(::Type{<:ModalAnalysisFormulation}, result::LineParameters)::ComputationDetails
+ return details(result)
+end
diff --git a/src/engine/modalanalysis/eigensystems.jl b/src/engine/modalanalysis/eigensystems.jl
new file mode 100644
index 000000000..e8a0f9091
--- /dev/null
+++ b/src/engine/modalanalysis/eigensystems.jl
@@ -0,0 +1,281 @@
+# Shared numerical operations for frequency-tracked eigensystems.
+
+# Minimize the current column's imaginary norm, preserve the voltage and current
+# pairing, then resolve the remaining sign against the previous frequency.
+function orient_modes!(voltage::AbstractArray{T, 3}, current::AbstractArray{T, 3},
+ rotate::Bool) where {T <: Complex}
+ R=typeof(real(zero(T)))
+ for frequency in axes(current, 3), mode in axes(current, 2)
+
+ vector=@view current[:, mode, frequency]
+ phase=one(T)
+ scale=maximum(value -> max(abs(real(value)), abs(imag(value))), vector)
+ if rotate && !iszero(scale)
+ real_sum=zero(R)
+ imaginary_sum=zero(R)
+ for value in vector
+ scaled=value/scale
+ real_sum += real(scaled)^2-imag(scaled)^2
+ imaginary_sum += 2real(scaled)*imag(scaled)
+ end
+ phase=cis(-atan(imaginary_sum, real_sum)/2)
+ end
+ if frequency>firstindex(current, 3) && !iszero(scale)
+ previous=@view current[:, mode, frequency - 1]
+ previous_scale=maximum(value -> max(abs(real(value)), abs(imag(value))), previous)
+ if !iszero(previous_scale)
+ overlap=zero(T)
+ for row in eachindex(vector)
+ overlap += conj(previous[row]/previous_scale)*(vector[row]/scale)
+ end
+ real(overlap*phase) magnitude
+ pivot = index
+ magnitude = component_magnitude
+ end
+ end
+ iszero(magnitude) && return false
+ vector .*= conj(vector[pivot]) / magnitude
+ return true
+end
+
+function _align!(vector::AbstractVector, reference::AbstractVector)
+ _unit!(vector) || return false
+ overlap = dot(reference, vector)
+ scale = abs(overlap)
+ if scale > sqrt(eps(typeof(real(scale))))
+ vector .*= conj(overlap) / scale
+ return true
+ end
+ return _orient!(vector)
+end
+
+function normalize_bilinear!(vector::AbstractVector{T}) where {T <: Complex}
+ squared = zero(T)
+ magnitude = zero(typeof(real(zero(T))))
+ @inbounds for value in vector
+ squared += value * value
+ magnitude += abs2(value)
+ end
+ abs(squared) > sqrt(eps(typeof(magnitude))) * max(magnitude, one(magnitude)) ||
+ return false
+ vector ./= sqrt(squared)
+ return true
+end
+
+function _seed(matrix::AbstractMatrix{T}) where {T <: Complex}
+ decomposition = eigen(matrix)
+ values = Vector{T}(decomposition.values)
+ vectors = Matrix{T}(decomposition.vectors)
+ @inbounds for mode in axes(vectors, 2)
+ _orient!(@view(vectors[:, mode])) || throw(ArgumentError(
+ "eigendecomposition produced a zero eigenvector"
+ ))
+ end
+ return values, vectors
+end
+
+# Minimum-cost square assignment by the O(n³) Hungarian algorithm.
+function hungarian_assignment!(cost::AbstractMatrix{R},buffers) where {R <: Real}
+ n = checksquare(cost)
+ n == 0 && return Int[]
+ u,v,matching,way,minimums,used=buffers.u,buffers.v,buffers.matching,
+ buffers.way,buffers.minimums,buffers.used
+ fill!(u,zero(R));fill!(v,zero(R))
+ fill!(matching,0);fill!(way,0)
+
+ @inbounds for row in 1:n
+ matching[1] = row
+ fill!(minimums, R(Inf))
+ fill!(used, false)
+ column = 1
+ while true
+ used[column] = true
+ matched_row = matching[column]
+ delta = R(Inf)
+ next_column = 0
+ for column_slot in 2:(n + 1)
+ used[column_slot] && continue
+ reduced = cost[matched_row, column_slot - 1] -
+ u[matched_row + 1] - v[column_slot]
+ if reduced < minimums[column_slot]
+ minimums[column_slot] = reduced
+ way[column_slot] = column
+ end
+ if minimums[column_slot] < delta
+ delta = minimums[column_slot]
+ next_column = column_slot
+ end
+ end
+ isfinite(delta) || throw(ArgumentError(
+ "modal assignment cost must be finite"
+ ))
+ for column_slot in 1:(n + 1)
+ if used[column_slot]
+ u[matching[column_slot] + 1] += delta
+ v[column_slot] -= delta
+ elseif column_slot > 1
+ minimums[column_slot] -= delta
+ end
+ end
+ column = next_column
+ iszero(matching[column]) && break
+ end
+ while true
+ previous = way[column]
+ matching[column] = matching[previous]
+ column = previous
+ column == 1 && break
+ end
+ end
+
+ assignment = buffers.assignment
+ @inbounds for column in 1:n
+ assignment[matching[column + 1]] = column
+ end
+ return assignment
+end
+function _match!(
+ values::AbstractVector{T},
+ vectors::AbstractMatrix{T},
+ previous_values::AbstractVector{T},
+ previous_vectors::AbstractMatrix{T},buffers
+) where {T <: Complex}
+ n = length(values)
+ length(previous_values) == n || throw(DimensionMismatch(
+ "eigenvalue sequences must have equal length"
+ ))
+ size(vectors) == size(previous_vectors) == (n, n) || throw(
+ DimensionMismatch("eigenvector matrices must be n×n")
+ )
+ R = typeof(real(zero(T)))
+ cost = buffers.cost
+ @inbounds for previous in 1:n, current in 1:n
+ denominator = norm(@view(previous_vectors[:, previous])) *
+ norm(@view(vectors[:, current]))
+ overlap = iszero(denominator) ? zero(R) :
+ abs(dot(
+ @view(previous_vectors[:, previous]),
+ @view(vectors[:, current])
+ )) / denominator
+ cost[previous, current] = one(R) - overlap
+ end
+ assignment = hungarian_assignment!(cost,buffers)
+ ordered_values = buffers.ordered_values
+ ordered_vectors = buffers.ordered_vectors
+ copyto!(ordered_values,values)
+ copyto!(ordered_vectors,vectors)
+ @inbounds for mode in 1:n
+ source = assignment[mode]
+ values[mode] = ordered_values[source]
+ copyto!(@view(vectors[:, mode]), @view(ordered_vectors[:, source]))
+ end
+ return assignment
+end
+"""
+$(TYPEDSIGNATURES)
+
+Accept the tracked modes of one frequency: return whether the columns `t` of `Ti` and
+the values `γ²` diagonalize `YZ`. The test returns `true` when:
+
+- every value and every entry of `Ti` is finite,
+- `Ti` is invertible within √eps, with a condition number of at most `1/√eps`,
+- each pair satisfies `YZ·t = γ²·t` within `tolerance`, relative to `‖YZ‖∞` and never
+ tighter than √eps.
+
+`residual` is the work vector of the last condition. When the test fails, the caller
+records the frequency as a missed numerical target. With `fallback = :matched` it then
+replaces the tracked modes by directly decomposed eigenpairs matched to the previous
+frequency.
+"""
+function diagonalizes!(
+ Ti::AbstractMatrix{T},
+ γ²::AbstractVector{T},
+ YZ::AbstractMatrix{T},
+ tolerance::Real,residual::AbstractVector{T}
+) where {T <: Complex}
+ all(isfinite, γ²) && all(isfinite, Ti) || return false
+ R = typeof(real(zero(T)))
+ condition = cond(Ti)
+ isfinite(condition) && condition <= inv(sqrt(eps(R))) || return false
+ scale = max(norm(YZ, Inf), eps(R))
+ limit = max(convert(R, tolerance), sqrt(eps(R))) * scale
+ @inbounds for mode in eachindex(γ²)
+ mul!(residual, YZ, @view(Ti[:, mode]))
+ residual .-= γ²[mode] .* @view(Ti[:, mode])
+ norm(residual, Inf) <= limit || return false
+ end
+ return true
+end
+function recompute_matched_eigenpairs!(
+ matrix::AbstractMatrix{T},
+ previous_values::AbstractVector{T},
+ previous_vectors::AbstractMatrix{T},buffers
+) where {T <: Complex}
+ values, vectors = _seed(matrix)
+ _match!(values, vectors, previous_values, previous_vectors,buffers)
+ @inbounds for mode in eachindex(values)
+ _align!(@view(vectors[:, mode]), @view(previous_vectors[:, mode]))
+ end
+ return values, vectors
+end
+
+# Complex eigenpair equations with the bilinear normalization tᵀt = 1.
+function eigenpair_residual!(residual, x, matrix)
+ n = size(matrix, 1)
+ vector = @view x[1:n]
+ mul!(@view(residual[1:n]), matrix, vector)
+ constraint = -one(eltype(x))
+ @inbounds for row in 1:n
+ residual[row] -= x[end] * vector[row]
+ constraint += vector[row] * vector[row]
+ end
+ residual[end] = constraint
+ return residual
+end
+
+function eigenpair_jacobian!(jacobian, x, matrix)
+ n = size(matrix, 1)
+ copyto!(@view(jacobian[1:n, 1:n]), matrix)
+ @inbounds for row in 1:n
+ jacobian[row, row] -= x[end]
+ jacobian[row, end] = -x[row]
+ jacobian[end, row] = 2x[row]
+ end
+ jacobian[end, end] = zero(eltype(jacobian))
+ return jacobian
+end
+
+# Greedy square assignment: choose the smallest remaining entry.
+function greedy_assignment!(assignment, cost)
+ for _ in eachindex(assignment)
+ entry = argmin(cost)
+ assignment[entry[1]] = entry[2]
+ @views cost[entry[1], :] .= Inf
+ @views cost[:, entry[2]] .= Inf
+ end
+ return assignment
+end
diff --git a/src/engine/modalanalysis/formulas/chrysochos2014.jl b/src/engine/modalanalysis/formulas/chrysochos2014.jl
new file mode 100644
index 000000000..4393ed6cd
--- /dev/null
+++ b/src/engine/modalanalysis/formulas/chrysochos2014.jl
@@ -0,0 +1,414 @@
+
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Levenberg–Marquardt tracking of each complex eigenpair,
+initialized from the preceding frequency.
+
+**Expression.**
+
+```math
+\\widetilde{\\mathbf S}=\\frac{\\mathbf Y\\mathbf Z}
+{-\\omega^2\\mu_0\\varepsilon_0}-\\mathbf I,\\qquad
+\\widetilde{\\mathbf S}\\mathbf t=\\lambda\\mathbf t,\\qquad
+\\mathbf t^T\\mathbf t=1.
+```
+
+The complex residual is represented as a real least-squares system and solved
+with a damped normal-equation step.
+
+**Reference.** A. I. Chrysochos, T. A. Papadopoulos, and G. K. Papagiannis,
+“Robust Calculation of Frequency-Dependent Transmission-Line Transformation
+Matrices Using the Levenberg–Marquardt Method,” *IEEE Transactions on Power
+Delivery*, 29(4), 1621–1629, 2014. DOI: 10.1109/TPWRD.2013.2284504.
+
+"""
+function description(::Type{<:Formula{:chrysochos2014}}; compact::Bool=false)
+ compact ? "Chrysochos" : "Chrysochos et al. Levenberg–Marquardt modal transformation (2014)"
+end
+
+function levenberg_marquardt_residual!(
+ ::Formula{:chrysochos2014},
+ residual::AbstractVector{R},
+ x::AbstractVector{R},
+ real_matrix::AbstractMatrix{R},
+ imaginary_matrix::AbstractMatrix{R}
+) where {R <: Real}
+ n = size(real_matrix, 1)
+ real_vector = @view x[1:n]
+ imaginary_vector = @view x[(n + 1):(2n)]
+ real_value = x[2n + 1]
+ imaginary_value = x[2n + 2]
+ real_constraint = -one(R)
+ imaginary_constraint = zero(R)
+ @inbounds for row in 1:n
+ real_result = -real_value * real_vector[row] +
+ imaginary_value * imaginary_vector[row]
+ imaginary_result = -imaginary_value * real_vector[row] -
+ real_value * imaginary_vector[row]
+ for column in 1:n
+ real_result += real_matrix[row, column] * real_vector[column] -
+ imaginary_matrix[row, column] * imaginary_vector[column]
+ imaginary_result += imaginary_matrix[row, column] * real_vector[column] +
+ real_matrix[row, column] * imaginary_vector[column]
+ end
+ residual[row] = real_result
+ residual[n + row] = imaginary_result
+ real_constraint += real_vector[row]^2 - imaginary_vector[row]^2
+ imaginary_constraint += 2 * real_vector[row] * imaginary_vector[row]
+ end
+ residual[2n + 1] = real_constraint
+ residual[2n + 2] = imaginary_constraint
+ return residual
+end
+
+function levenberg_marquardt_jacobian!(
+ ::Formula{:chrysochos2014},
+ jacobian::AbstractMatrix{R},
+ x::AbstractVector{R},
+ real_matrix::AbstractMatrix{R},
+ imaginary_matrix::AbstractMatrix{R}
+) where {R <: Real}
+ n = size(real_matrix, 1)
+ real_vector = @view x[1:n]
+ imaginary_vector = @view x[(n + 1):(2n)]
+ real_value = x[2n + 1]
+ imaginary_value = x[2n + 2]
+ fill!(jacobian, zero(R))
+ @inbounds for row in 1:n
+ for column in 1:n
+ diagonal = row == column
+ jacobian[row, column] = real_matrix[row, column] -
+ (diagonal ? real_value : zero(R))
+ jacobian[row, n + column] = -imaginary_matrix[row, column] +
+ (diagonal ? imaginary_value : zero(R))
+ jacobian[n + row, column] = imaginary_matrix[row, column] -
+ (diagonal ? imaginary_value : zero(R))
+ jacobian[n + row, n + column] = real_matrix[row, column] -
+ (diagonal ? real_value : zero(R))
+ end
+ jacobian[row, 2n + 1] = -real_vector[row]
+ jacobian[row, 2n + 2] = imaginary_vector[row]
+ jacobian[n + row, 2n + 1] = -imaginary_vector[row]
+ jacobian[n + row, 2n + 2] = -real_vector[row]
+ jacobian[2n + 1, row] = 2 * real_vector[row]
+ jacobian[2n + 1, n + row] = -2 * imaginary_vector[row]
+ jacobian[2n + 2, row] = 2 * imaginary_vector[row]
+ jacobian[2n + 2, n + row] = 2 * real_vector[row]
+ end
+ return jacobian
+end
+
+function levenberg_marquardt_step!(
+ formula::Formula{:chrysochos2014},
+ vector::AbstractVector{T},
+ value::T,
+ iteration_options::NamedTuple,
+ buffers
+) where {T <: Complex}
+ n = length(vector)
+ R = typeof(real(zero(T)))
+ requested_tolerance = convert(R, iteration_options.convergence)
+ tolerance = max(R(100) * eps(R), requested_tolerance^2)
+ iterations = iteration_options.max_iterations
+ iterations isa Integer && iterations > 0 || throw(DomainError(
+ iterations,
+ "max_iterations must be a positive integer"
+ ))
+ damping = convert(R, iteration_options.damping)
+ isfinite(damping) && damping > zero(R) || throw(DomainError(
+ damping,
+ "damping must be finite and positive"
+ ))
+
+ normalize_bilinear!(vector) || _unit!(vector) || return value, false, 0
+ @inbounds for index in 1:n
+ buffers.x[index] = real(vector[index])
+ buffers.x[n + index] = imag(vector[index])
+ end
+ buffers.x[2n + 1] = real(value)
+ buffers.x[2n + 2] = imag(value)
+
+ converged = false
+ performed = 0
+ maximum_damping = inv(eps(R))
+ for iteration in 1:iterations
+ performed=iteration
+ levenberg_marquardt_residual!(
+ formula,
+ buffers.residual,
+ buffers.x,
+ buffers.real_matrix,
+ buffers.imaginary_matrix
+ )
+ residual_norm = norm(buffers.residual, Inf)
+ if residual_norm <= tolerance
+ converged = true
+ break
+ end
+ levenberg_marquardt_jacobian!(
+ formula,
+ buffers.jacobian,
+ buffers.x,
+ buffers.real_matrix,
+ buffers.imaginary_matrix
+ )
+ mul!(buffers.gradient, transpose(buffers.jacobian), buffers.residual)
+ norm(buffers.gradient, Inf) <= tolerance && (converged = true; break)
+ mul!(buffers.hessian, transpose(buffers.jacobian), buffers.jacobian)
+ copyto!(buffers.system, buffers.hessian)
+ diagonal_scale = one(R)
+ @inbounds for index in axes(buffers.hessian, 1)
+ diagonal_scale = max(
+ diagonal_scale,
+ abs(buffers.hessian[index, index])
+ )
+ end
+ floor = eps(R) * diagonal_scale
+ @inbounds for index in axes(buffers.system, 1)
+ buffers.system[index, index] += damping *
+ max(buffers.hessian[index, index], floor)
+ end
+ copyto!(buffers.step, buffers.gradient)
+ buffers.step .*= -one(R)
+ factorization = lu!(buffers.system; check = false)
+ issuccess(factorization) || return value, false, performed
+ ldiv!(factorization, buffers.step)
+ all(isfinite, buffers.step) || return value, false, performed
+ buffers.candidate .= buffers.x .+ buffers.step
+ levenberg_marquardt_residual!(
+ formula,
+ buffers.candidate_residual,
+ buffers.candidate,
+ buffers.real_matrix,
+ buffers.imaginary_matrix
+ )
+ old_cost = dot(buffers.residual, buffers.residual)
+ new_cost = dot(buffers.candidate_residual, buffers.candidate_residual)
+ if isfinite(new_cost) && new_cost < old_cost
+ copyto!(buffers.x, buffers.candidate)
+ damping = max(damping / R(3), eps(R))
+ if norm(buffers.step, Inf) <= tolerance *
+ (tolerance + norm(buffers.x, Inf))
+ converged = true
+ break
+ end
+ else
+ damping *= R(10)
+ damping <= maximum_damping || return value, false, performed
+ end
+ end
+
+ @inbounds for index in 1:n
+ vector[index] = complex(buffers.x[index], buffers.x[n + index])
+ end
+ _unit!(vector) || return value, false, performed
+ return complex(buffers.x[2n + 1], buffers.x[2n + 2]), converged, performed
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Calculate current modal eigenvectors with the Chrysochos-Papadopoulos,
+Papagiannis Levenberg–Marquardt formulation. At each frequency, the current
+eigenproblem is scaled as
+
+```math
+\\widetilde{S} = \\frac{YZ}{-\\omega^2\\mu_0\\epsilon_0} - I, \\qquad
+\\widetilde{S}t = \\lambda t,
+```
+
+then each complex eigenpair is solved independently through its equivalent
+real residual with the constraint ``t^Tt=1``. The preceding frequency supplies
+the initial eigenpair.
+
+# Arguments
+
+- `lp`: fully coupled phase-domain line parameters, with `Z` in \\[Ω/m\\], `Y`
+ in \\[S/m\\], and frequency in \\[Hz\\].
+- `values`: modal controls containing the modal-residue `tolerance`, LM
+ `convergence`, `max_iterations`, and initial `damping` coefficient.
+
+# Returns
+
+- Frequency-dependent phase-to-modal voltage and current operators.
+
+# Notes
+
+The implementation uses an analytic real Jacobian and a monotone damped
+normal-equation step. A conventional eigensolution matched to the preceding
+frequency is retained if an iterative slice is singular or does not converge.
+
+# Reference
+
+A. I. Chrysochos, T. A. Papadopoulos, and G. K. Papagiannis, *Robust
+Calculation of Frequency-Dependent Transmission-Line Transformation Matrices
+Using the Levenberg–Marquardt Method*, IEEE Transactions on Power Delivery,
+29(4), 2014. DOI: 10.1109/TPWRD.2013.2284504.
+"""
+function initialize_buffers(::Formula{:chrysochos2014}, ::Type{T}, input,
+ plan, buffers) where {T <: Complex}
+ n = plan.n
+ R = typeof(real(zero(T)))
+ order = 2n + 2
+ return merge(buffers,(
+ normalized_shifted_eigenproblem=Matrix{T}(undef,n,n),
+ least_squares=(
+ x = Vector{R}(undef, order),
+ candidate = Vector{R}(undef, order),
+ residual = Vector{R}(undef, order),
+ candidate_residual = Vector{R}(undef, order),
+ jacobian = Matrix{R}(undef, order, order),
+ gradient = Vector{R}(undef, order),
+ hessian = Matrix{R}(undef, order, order),
+ system = Matrix{R}(undef, order, order),
+ step = Vector{R}(undef, order),
+ real_matrix = Matrix{R}(undef, n, n),
+ imaginary_matrix = Matrix{R}(undef, n, n)),
+ eigenpair_assignment=(cost=Matrix{R}(undef,n,n),u=zeros(R,n+1),v=zeros(R,n+1),
+ matching=zeros(Int,n+1),way=zeros(Int,n+1),minimums=Vector{R}(undef,n+1),
+ used=falses(n+1),assignment=Vector{Int}(undef,n),
+ ordered_values=Vector{T}(undef,n),ordered_vectors=Matrix{T}(undef,n,n),
+ residual=Vector{T}(undef,n))))
+end
+
+function decompose!(formula::Formula{:chrysochos2014}, workspace::ModalAnalysisWorkspace,
+ parameters::NamedTuple, options::FormulationOptions)
+ iteration_options = options.data.iteration
+ impedance = workspace.input.Z
+ admittance = workspace.input.Y
+ frequencies = workspace.input.f
+ n, _, nfrequencies = size(impedance)
+ T = eltype(workspace.Ti)
+ R = typeof(real(zero(T)))
+ buffers = workspace.buffers
+ admittance_impedance_product = buffers.admittance_impedance_product
+ normalized_shifted_eigenproblem = buffers.normalized_shifted_eigenproblem
+ least_squares = buffers.least_squares
+ previous_eigenvalues = buffers.previous_eigenvalues
+ previous_eigenvectors = buffers.previous_eigenvectors
+ eigenvalues = buffers.eigenvalues
+ eigenvectors = buffers.eigenvectors
+ propagation_eigenvalues = buffers.propagation_eigenvalues
+ convergence = convert(R, iteration_options.convergence)
+ validation_tolerance = max(R(100)*eps(R), convergence^2)
+ missed = workspace.diagnostics.missed_frequencies
+ fallback = workspace.diagnostics.fallback_frequencies
+
+ @inbounds for frequency_index in 1:nfrequencies
+ copyto!(workspace.buffers.Zslice,@view(impedance[:,:,frequency_index]))
+ copyto!(workspace.buffers.Yslice,@view(admittance[:,:,frequency_index]))
+ workspace.buffers.Zslice ./= workspace.input.root_scale
+ workspace.buffers.Yslice ./= workspace.input.root_scale
+ Zslice=workspace.buffers.Zslice
+ Yslice=workspace.buffers.Yslice
+ frequency = convert(R, frequencies[frequency_index])
+ frequency > zero(R) || throw(DomainError(frequency,
+ "Chrysochos modal analysis requires positive frequencies"))
+ mul!(admittance_impedance_product,Yslice,Zslice)
+ unit = one(frequency)
+ omega = 2*(unit*π)*frequency
+ epsilon0 = vacuum_permittivity(typeof(frequency))
+ mu0 = vacuum_permeability(typeof(frequency))
+ scale = -(omega^2)*epsilon0*mu0
+ normalized_shifted_eigenproblem .= admittance_impedance_product ./ scale
+ for mode in 1:n
+ normalized_shifted_eigenproblem[mode,mode] -= one(T)
+ end
+ for index in eachindex(normalized_shifted_eigenproblem)
+ least_squares.real_matrix[index] = real(normalized_shifted_eigenproblem[index])
+ least_squares.imaginary_matrix[index] = imag(normalized_shifted_eigenproblem[index])
+ end
+ if frequency_index == 1
+ seed_values, seed_vectors = _seed(normalized_shifted_eigenproblem)
+ copyto!(previous_eigenvalues, seed_values)
+ copyto!(previous_eigenvectors, seed_vectors)
+ else
+ copyto!(eigenvalues, previous_eigenvalues)
+ copyto!(eigenvectors, previous_eigenvectors)
+ failed_mode = 0
+ for mode in 1:n
+ reference = @view previous_eigenvectors[:,mode]
+ value, converged, iterations = levenberg_marquardt_step!(
+ formula, @view(eigenvectors[:,mode]),
+ previous_eigenvalues[mode], iteration_options, least_squares)
+ workspace.diagnostics.iterations[mode,frequency_index]=iterations
+ workspace.diagnostics.converged[mode,frequency_index]=converged
+ eigenvalues[mode] = value
+ _align!(@view(eigenvectors[:,mode]), reference)
+ if !converged
+ failed_mode = mode
+ break
+ end
+ end
+ for mode in 1:n
+ propagation_eigenvalues[mode] = (eigenvalues[mode]+one(T))*scale
+ end
+ if failed_mode != 0 || !diagonalizes!(eigenvectors,
+ propagation_eigenvalues,admittance_impedance_product,validation_tolerance,
+ buffers.eigenpair_assignment.residual)
+ push!(missed, frequency_index)
+ if iteration_options.fallback === :matched
+ push!(fallback, frequency_index)
+ fallback_values, fallback_vectors = recompute_matched_eigenpairs!(
+ admittance_impedance_product,previous_eigenvalues,
+ previous_eigenvectors,buffers.eigenpair_assignment)
+ for mode in 1:n
+ eigenvalues[mode] = fallback_values[mode]/scale-one(T)
+ end
+ copyto!(eigenvectors, fallback_vectors)
+ end
+ end
+ copyto!(previous_eigenvalues, eigenvalues)
+ copyto!(previous_eigenvectors, eigenvectors)
+ end
+ copyto!(@view(workspace.Ti[:,:,frequency_index]),previous_eigenvectors)
+ for mode in 1:n
+ vector = @view workspace.Ti[:,mode,frequency_index]
+ _unit!(vector) || throw(ArgumentError("current eigenvector has zero norm"))
+ mul!(buffers.voltage_vector,Zslice,vector)
+ divisor = norm(buffers.voltage_vector)
+ isfinite(divisor) && !iszero(divisor) ||
+ throw(ArgumentError("voltage eigenvector has zero or undefined norm"))
+ @views workspace.Tv[:,mode,frequency_index] .= buffers.voltage_vector ./ divisor
+ root = sqrt((previous_eigenvalues[mode]+one(T))*scale)
+ (real(root)<0 || (iszero(real(root)) && imag(root)<0)) && (root=-root)
+ workspace.roots[mode,frequency_index] = root*workspace.input.root_scale
+ eigenvalue=(previous_eigenvalues[mode]+one(T))*scale
+ mul!(buffers.eigenpair_assignment.residual,admittance_impedance_product,vector)
+ buffers.eigenpair_assignment.residual .-= eigenvalue .* vector
+ denominator=(norm(admittance_impedance_product,Inf)+abs(eigenvalue))*norm(vector,Inf)
+ numerator=norm(buffers.eigenpair_assignment.residual,Inf)
+ workspace.diagnostics.eigen_residual[mode,frequency_index]=
+ iszero(denominator) ? (iszero(numerator) ? zero(R) : R(Inf)) :
+ numerator/denominator
+ end
+ end
+ isempty(missed) || @warn ":chrysochos2014 missed numerical targets" count=length(missed) frequencies=copy(missed) fallback_count=length(fallback)
+ return workspace
+end
+
+function formulation_options(::Expression{<:Formula{:chrysochos2014}, typeof(decompose!)})
+ return FormulationOptions((iteration = (
+ convergence = 1e-8, max_iterations = 100, damping = 1e-3, fallback = :matched),))
+end
+
+function formulation_options(::Expression{<:Formula{:chrysochos2014}, typeof(decompose!)},
+ ::Val{:iteration}, defaults::NamedTuple, supplied::NamedTuple)
+ isempty(setdiff(keys(supplied), keys(defaults))) ||
+ throw(ArgumentError("unknown modal iteration controls"))
+ options = merge(defaults, supplied)
+ for key in (:convergence, :damping)
+ value = getproperty(options, key)
+ value isa Real && isfinite(value) && value > 0 ||
+ throw(ArgumentError("$key must be finite and positive"))
+ end
+ options.max_iterations isa Integer && !(options.max_iterations isa Bool) &&
+ options.max_iterations > 0 ||
+ throw(ArgumentError("max_iterations must be a positive integer"))
+ options.fallback in (:matched, :none) ||
+ throw(ArgumentError("fallback must be :matched or :none"))
+ return options
+end
+
+:chrysochos2014
diff --git a/src/engine/modalanalysis/formulas/default.jl b/src/engine/modalanalysis/formulas/default.jl
new file mode 100644
index 000000000..a30388453
--- /dev/null
+++ b/src/engine/modalanalysis/formulas/default.jl
@@ -0,0 +1,12 @@
+"""
+$(TYPEDSIGNATURES)
+
+Route the default selection to the explicit `:chrysochos2014` implementation.
+No numerical equation is owned by `:default`.
+"""
+description(::Type{<:Formula{:default}}; compact::Bool=false) =
+ compact ? "Default" : "Default routing to :chrysochos2014"
+
+Formula{:default}(; kwargs...) = Formula{:chrysochos2014}(; kwargs...)
+
+:default
diff --git a/src/engine/modalanalysis/formulas/vieira2026.jl b/src/engine/modalanalysis/formulas/vieira2026.jl
new file mode 100644
index 000000000..b1a6e49f7
--- /dev/null
+++ b/src/engine/modalanalysis/formulas/vieira2026.jl
@@ -0,0 +1,416 @@
+"""
+$(TYPEDSIGNATURES)
+
+Track current eigenpairs with Vieira's complex implementation of the
+Chrysochos-Papadopoulos-Papagiannis Levenberg–Marquardt method:
+
+```math
+S = \\frac{YZ}{-(2\\pi f)^2\\mu_0\\epsilon_0}-I,\\qquad
+r(t,\\lambda) = \\begin{bmatrix}(S-\\lambda I)t\\\\t^Tt-1\\end{bmatrix},\\qquad
+J = \\begin{bmatrix}S-\\lambda I&-t\\\\2t^T&0\\end{bmatrix}.
+```
+
+Here `Z` and `Y` are per-length matrices in \\[Ω/m\\] and \\[S/m\\], and `f`
+is frequency in \\[Hz\\]. Complex SVD least-squares steps minimize the Euclidean
+residual norm. A two-sample predictor, greedy eigenvalue matching and clustered
+eigenvector refinement track the supplied samples. Failed tracking uses a
+correlation-matched eigensolution at the same frequency. No samples are inserted.
+Internal vectors use the bilinear constraint. Returned `Ti` and paired `Tv`
+columns have unit Euclidean norm and map modal quantities to phase quantities.
+
+`iteration` controls `convergence=1e-13` and `max_iterations=100`. `tracking`
+controls `predictor_tolerance=0.25`, `eigenvalue_tolerance=1e-5`,
+`cluster_tolerance=1e-3` and `order_by_velocity=true`. The latter orders all
+samples by decreasing phase constant at the final frequency. Numerical misses
+are diagnostics, not rejection of finite results.
+
+Adapted from P. H. N. Vieira's `eig_levenberg_marquardt` implementation in
+[Parametric-Eigenvalues-and-Vectors-of-Transmission-Lines](https://github.com/pedrohnv/Parametric-Eigenvalues-and-Vectors-of-Transmission-Lines).
+The method is from A. I. Chrysochos, T. A. Papadopoulos and G. K. Papagiannis, *Robust Calculation of Frequency-Dependent Transmission-Line Transformation Matrices Using the
+Levenberg–Marquardt Method*, IEEE Transactions on Power Delivery 29(4),
+1621–1629 (2014), DOI: 10.1109/TPWRD.2013.2284504. The implementation uses the
+package vacuum permittivity, 8.8541878128e-12 F/m, and evaluates `s = j2πf`.
+"""
+function description(::Type{<:Formula{:vieira2026}}; compact::Bool = false)
+ return compact ? "Vieira" :
+ "Vieira complex Levenberg–Marquardt modal transformation (2026)"
+end
+
+function formulation_options(::Expression{<:Formula{:vieira2026}, typeof(decompose!)})
+ return FormulationOptions((
+ iteration = (convergence = 1e-13, max_iterations = 100),
+ tracking = (predictor_tolerance = 0.25, eigenvalue_tolerance = 1e-5,
+ cluster_tolerance = 1e-3, order_by_velocity = true)))
+end
+
+function formulation_options(::Expression{<:Formula{:vieira2026}, typeof(decompose!)},
+ ::Val{:iteration}, defaults::NamedTuple, supplied::NamedTuple)
+ isempty(setdiff(keys(supplied), keys(defaults))) ||
+ throw(ArgumentError("unknown modal iteration controls"))
+ options = merge(defaults, supplied)
+ options.convergence isa Real && isfinite(options.convergence) &&
+ options.convergence > 0 ||
+ throw(ArgumentError("convergence must be finite and positive"))
+ options.max_iterations isa Integer && !(options.max_iterations isa Bool) &&
+ options.max_iterations > 0 ||
+ throw(ArgumentError("max_iterations must be a positive integer"))
+ return options
+end
+
+function formulation_options(::Expression{<:Formula{:vieira2026}, typeof(decompose!)},
+ ::Val{:tracking}, defaults::NamedTuple, supplied::NamedTuple)
+ isempty(setdiff(keys(supplied), keys(defaults))) ||
+ throw(ArgumentError("unknown modal tracking controls"))
+ options = merge(defaults, supplied)
+ for key in (:predictor_tolerance, :eigenvalue_tolerance, :cluster_tolerance)
+ value = getproperty(options, key)
+ value isa Real && isfinite(value) && value > 0 ||
+ throw(ArgumentError("$key must be finite and positive"))
+ end
+ options.order_by_velocity isa Bool ||
+ throw(ArgumentError("order_by_velocity must be Bool"))
+ return options
+end
+
+function initialize_buffers(
+ ::Formula{:vieira2026}, ::Type{T}, input, plan, buffers) where {T <: Complex}
+ n = plan.n
+ R = typeof(real(zero(T)))
+ order = n + 1
+ return merge(buffers,
+ (
+ normalized_shifted_eigenproblem = Matrix{T}(undef, n, n),
+ spectral_factor = Matrix{T}(undef, n, n),
+ prediction = (older_vectors = Matrix{T}(undef, n, n),
+ older_values = Vector{T}(undef, n), vectors = Matrix{T}(undef, n, n),
+ values = Vector{T}(undef, n)),
+ least_squares = (
+ x = Vector{T}(undef, order), candidate = Vector{T}(undef, order),
+ residual = Vector{T}(undef, order),
+ candidate_residual = Vector{T}(undef, order),
+ jacobian = Matrix{T}(undef, order, order),
+ system = Matrix{T}(undef, 2order, order), rhs = Vector{T}(undef, 2order),
+ column_norms = Vector{R}(undef, order), step = Vector{T}(undef, order),
+ projection = Vector{T}(undef, order)),
+ eigenpair_assignment = (
+ cost = Matrix{R}(undef, n, n), assignment = Vector{Int}(undef, n),
+ labels = Vector{Int}(undef, n), stack = Vector{Int}(undef, n),
+ columns = Vector{Int}(undef, n), tracks = Vector{Int}(undef, n),
+ cluster_assignment = Vector{Int}(undef, n), isolated = trues(n),
+ system = Matrix{T}(undef, n, n), rhs = Matrix{T}(undef, n, n),
+ coefficients = Matrix{T}(undef, n, n), projection = Matrix{T}(undef, n, n)),
+ eigen_residual = Vector{T}(undef, n),
+ mode_order = (indices = Vector{Int}(undef, n),
+ residual = Vector{Union{Nothing, R}}(undef, n),
+ iterations = Vector{Union{Nothing, Int}}(undef, n),
+ converged = Vector{Union{Nothing, Bool}}(undef, n))))
+end
+
+# Truncated SVD minimum-norm solve. Matrix is disposable factorization storage.
+function minimum_norm!(solution, matrix::AbstractMatrix{T}, rhs, projection) where {T <:
+ Complex}
+ factor = svd!(matrix)
+ R = typeof(real(zero(T)))
+ cutoff = eps(R) * max(size(matrix)...) * first(factor.S)
+ mul!(projection, adjoint(factor.U), rhs)
+ @inbounds for column in axes(projection, 2), row in axes(projection, 1)
+
+ singular = factor.S[row]
+ projection[row, column] = singular > cutoff ? projection[row, column] / singular :
+ zero(T)
+ end
+ mul!(solution, adjoint(factor.Vt), projection)
+ return solution
+end
+
+function levenberg_marquardt_step!(::Formula{:vieira2026}, vector::AbstractVector{T},
+ value::T, matrix, options, buffers) where {T <: Complex}
+ n = length(vector)
+ order = n + 1
+ R = typeof(real(zero(T)))
+ tolerance = convert(R, options.convergence)
+ copyto!(@view(buffers.x[1:n]), vector)
+ buffers.x[end] = value
+ eigenpair_residual!(buffers.residual, buffers.x, matrix)
+ cost = real(dot(buffers.residual, buffers.residual))
+ damping = zero(R)
+ iterations = 0
+ for iteration in 1:options.max_iterations
+ sqrt(cost) < tolerance && break
+ iterations = iteration
+ eigenpair_jacobian!(buffers.jacobian, buffers.x, matrix)
+ for column in 1:order
+ buffers.column_norms[column] = norm(@view(buffers.jacobian[:, column]))
+ end
+ candidate_cost = cost
+ while true
+ rows = iszero(damping) ? order : 2order
+ copyto!(@view(buffers.system[1:order, :]), buffers.jacobian)
+ @views buffers.rhs[1:order] .= -buffers.residual
+ if !iszero(damping)
+ fill!(@view(buffers.system[(order + 1):end, :]), zero(T))
+ fill!(@view(buffers.rhs[(order + 1):end]), zero(T))
+ for column in 1:order
+ buffers.system[order + column, column] = sqrt(damping) *
+ buffers.column_norms[column]
+ end
+ end
+ minimum_norm!(buffers.step, @view(buffers.system[1:rows, :]),
+ @view(buffers.rhs[1:rows]), buffers.projection)
+ buffers.candidate .= buffers.x .+ buffers.step
+ eigenpair_residual!(buffers.candidate_residual, buffers.candidate, matrix)
+ candidate_cost = real(dot(buffers.candidate_residual, buffers.candidate_residual))
+ (candidate_cost < cost || damping > R(1e8)) && break
+ damping = iszero(damping) ? R(1e-6) : 10damping
+ end
+ candidate_cost < cost || break
+ copyto!(buffers.x, buffers.candidate)
+ copyto!(buffers.residual, buffers.candidate_residual)
+ cost = candidate_cost
+ damping = damping <= R(1e-6) ? zero(R) : damping / 10
+ end
+ copyto!(vector, @view(buffers.x[1:n]))
+ return buffers.x[end], sqrt(cost) < tolerance, iterations
+end
+
+function refine_eigenpairs!(::Formula{:vieira2026}, values, vectors, previous_vectors,
+ prediction, eigensystem, options, buffers)
+ n = length(values)
+ R = eltype(buffers.cost)
+ @inbounds for column in 1:n, row in 1:n
+
+ buffers.cost[row, column] = abs(values[row] - eigensystem.values[column])
+ end
+ greedy_assignment!(buffers.assignment, buffers.cost)
+ eigenvalue_limit = R(options.eigenvalue_tolerance) *
+ max(one(R), maximum(abs, eigensystem.values))
+ any(
+ row -> abs(values[row] - eigensystem.values[buffers.assignment[row]]) >
+ eigenvalue_limit, 1:n) &&
+ return false
+
+ fill!(buffers.labels, 0)
+ clusters = 0
+ for seed in 1:n
+ buffers.labels[seed] == 0 || continue
+ clusters += 1
+ buffers.labels[seed] = clusters
+ pending = 1
+ buffers.stack[pending] = seed
+ while pending > 0
+ row = buffers.stack[pending]
+ pending -= 1
+ for column in 1:n
+ buffers.labels[column] == 0 || continue
+ gap = abs(eigensystem.values[row] - eigensystem.values[column])
+ limit = R(options.cluster_tolerance) *
+ max(abs(1 + eigensystem.values[row]),
+ abs(1 + eigensystem.values[column]), R(1e-3))
+ if gap < limit
+ buffers.labels[column] = clusters
+ pending += 1
+ buffers.stack[pending] = column
+ end
+ end
+ end
+ end
+ @inbounds for mode in 1:n
+ values[mode] = eigensystem.values[buffers.assignment[mode]]
+ end
+ fill!(buffers.isolated, true)
+ for cluster in 1:clusters
+ count = 0
+ tracks = 0
+ for mode in 1:n
+ if buffers.labels[mode] == cluster
+ count += 1
+ buffers.columns[count] = mode
+ end
+ if buffers.labels[buffers.assignment[mode]] == cluster
+ tracks += 1
+ buffers.tracks[tracks] = mode
+ end
+ end
+ count == 1 && continue
+ for column in 1:count
+ track = buffers.tracks[column]
+ buffers.isolated[track] = false
+ copyto!(@view(buffers.system[:, column]), @view(previous_vectors[:, track]))
+ copyto!(@view(buffers.rhs[:, column]), @view(eigensystem.vectors[:, buffers.columns[column]]))
+ end
+ coefficients = @view buffers.coefficients[1:count, 1:count]
+ minimum_norm!(coefficients, @view(buffers.system[:, 1:count]),
+ @view(buffers.rhs[:, 1:count]), @view(buffers.projection[1:count, 1:count]))
+ cost = @view buffers.cost[1:count, 1:count]
+ cost .= .-abs.(coefficients)
+ assignment = @view buffers.cluster_assignment[1:count]
+ greedy_assignment!(assignment, cost)
+ for column in 1:count
+ track = buffers.tracks[column]
+ source = buffers.columns[assignment[column]]
+ copyto!(@view(vectors[:, track]), @view(eigensystem.vectors[:, source]))
+ values[track] = eigensystem.values[source]
+ end
+ end
+ largest_change = zero(R)
+ for mode in 1:n
+ value_change = abs(values[mode] - prediction.values[mode]) /
+ max(abs(prediction.values[mode]), R(1e-3))
+ largest_change = max(largest_change, value_change)
+ if buffers.isolated[mode]
+ change = zero(R)
+ magnitude = zero(R)
+ for row in 1:n
+ change = max(change, abs(vectors[row, mode] -
+ prediction.vectors[row, mode]))
+ magnitude = max(magnitude, abs(prediction.vectors[row, mode]))
+ end
+ largest_change = max(largest_change, change / magnitude)
+ end
+ end
+ return largest_change <= R(options.predictor_tolerance)
+end
+
+function decompose!(formula::Formula{:vieira2026}, workspace::ModalAnalysisWorkspace,
+ parameters::NamedTuple, options::FormulationOptions)
+ buffers = workspace.buffers
+ input = workspace.input
+ diagnostics = workspace.diagnostics
+ iteration = options.data.iteration
+ tracking = options.data.tracking
+ prediction = buffers.prediction
+ assignment = buffers.eigenpair_assignment
+ n, _, nf = size(workspace.Ti)
+ T = eltype(workspace.Ti)
+ R = typeof(real(zero(T)))
+ unit = one(R)
+ epsilon0 = vacuum_permittivity(R)
+ mu0 = vacuum_permeability(R)
+ for frequency in 1:nf
+ copyto!(buffers.Zslice, @view(input.Z[:, :, frequency]))
+ copyto!(buffers.Yslice, @view(input.Y[:, :, frequency]))
+ buffers.Zslice ./= input.root_scale
+ buffers.Yslice ./= input.root_scale
+ mul!(buffers.admittance_impedance_product, buffers.Yslice, buffers.Zslice)
+ omega = 2 * (unit * π) * R(input.f[frequency])
+ scale = -(omega^2) * epsilon0 * mu0
+ matrix = buffers.normalized_shifted_eigenproblem
+ matrix .= buffers.admittance_impedance_product ./ scale
+ for mode in 1:n
+ matrix[mode, mode] -= one(T)
+ end
+ copyto!(buffers.spectral_factor, matrix)
+ eigensystem = eigen!(buffers.spectral_factor)
+ for mode in 1:n
+ vector = @view eigensystem.vectors[:, mode]
+ vector ./= sqrt(sum(value -> value * value, vector))
+ end
+ if frequency == 1
+ copyto!(buffers.eigenvalues, eigensystem.values)
+ copyto!(buffers.eigenvectors, eigensystem.vectors)
+ else
+ coefficient = frequency == 2 ? zero(R) :
+ clamp(
+ R(input.f[frequency] - input.f[frequency - 1]) /
+ R(input.f[frequency - 1] - input.f[frequency - 2]), -one(R), one(R))
+ if frequency == 2
+ copyto!(prediction.vectors, buffers.previous_eigenvectors)
+ copyto!(prediction.values, buffers.previous_eigenvalues)
+ else
+ prediction.vectors .= buffers.previous_eigenvectors .+
+ coefficient .* (buffers.previous_eigenvectors .-
+ prediction.older_vectors)
+ prediction.values .= buffers.previous_eigenvalues .+
+ coefficient .*
+ (buffers.previous_eigenvalues .- prediction.older_values)
+ end
+ copyto!(buffers.eigenvectors, prediction.vectors)
+ for mode in 1:n
+ value, converged, iterations = levenberg_marquardt_step!(formula,
+ @view(buffers.eigenvectors[:, mode]), prediction.values[mode], matrix,
+ iteration, buffers.least_squares)
+ buffers.eigenvalues[mode] = value
+ diagnostics.iterations[mode, frequency] = iterations
+ diagnostics.converged[mode, frequency] = converged
+ end
+ matched = refine_eigenpairs!(formula, buffers.eigenvalues,
+ buffers.eigenvectors, buffers.previous_eigenvectors, prediction, eigensystem, tracking, assignment)
+ if !matched
+ # Preserve the reference's square correlation solve and greedy assignment.
+ copyto!(assignment.system, buffers.previous_eigenvectors)
+ copyto!(assignment.coefficients, eigensystem.vectors)
+ ldiv!(lu!(assignment.system), assignment.coefficients)
+ assignment.cost .= .-abs.(assignment.coefficients)
+ greedy_assignment!(assignment.assignment, assignment.cost)
+ for mode in 1:n
+ source = assignment.assignment[mode]
+ copyto!(@view(buffers.eigenvectors[:, mode]), @view(eigensystem.vectors[:, source]))
+ buffers.eigenvalues[mode] = eigensystem.values[source]
+ end
+ push!(diagnostics.fallback_frequencies, frequency)
+ end
+ if !matched || any(!, @view(diagnostics.converged[:, frequency]))
+ push!(diagnostics.missed_frequencies, frequency)
+ end
+ for mode in 1:n
+ real(dot(@view(buffers.previous_eigenvectors[:, mode]),
+ @view(buffers.eigenvectors[:, mode]))) < 0 &&
+ (@views buffers.eigenvectors[:, mode] .*= -one(T))
+ end
+ copyto!(prediction.older_vectors, buffers.previous_eigenvectors)
+ copyto!(prediction.older_values, buffers.previous_eigenvalues)
+ end
+ copyto!(buffers.previous_eigenvectors, buffers.eigenvectors)
+ copyto!(buffers.previous_eigenvalues, buffers.eigenvalues)
+ buffers.propagation_eigenvalues .= (buffers.eigenvalues .+ one(T)) .* scale
+ for mode in 1:n
+ vector = @view workspace.Ti[:, mode, frequency]
+ copyto!(vector, @view(buffers.eigenvectors[:, mode]))
+ _unit!(vector) ||
+ throw(ArgumentError("current eigenvector has zero or undefined norm"))
+ mul!(buffers.voltage_vector, buffers.Zslice, vector)
+ divisor = norm(buffers.voltage_vector)
+ isfinite(divisor) && !iszero(divisor) ||
+ throw(ArgumentError("voltage eigenvector has zero or undefined norm"))
+ @views workspace.Tv[:, mode, frequency] .= buffers.voltage_vector ./ divisor
+ eigenvalue = buffers.propagation_eigenvalues[mode]
+ root = sqrt(eigenvalue)
+ (real(root) < 0 || (iszero(real(root)) && imag(root) < 0)) && (root = -root)
+ workspace.roots[mode, frequency] = root * input.root_scale
+ mul!(buffers.eigen_residual, buffers.admittance_impedance_product, vector)
+ buffers.eigen_residual .-= eigenvalue .* vector
+ denominator = (norm(buffers.admittance_impedance_product, Inf) + abs(eigenvalue)) *
+ norm(vector, Inf)
+ numerator = norm(buffers.eigen_residual, Inf)
+ diagnostics.eigen_residual[mode, frequency] = iszero(denominator) ?
+ (iszero(numerator) ? zero(R) :
+ R(Inf)) : numerator / denominator
+ end
+ end
+ if tracking.order_by_velocity
+ order = buffers.mode_order
+ sortperm!(order.indices, @view(workspace.roots[:, end]); by = value -> -imag(value))
+ for frequency in 1:nf
+ copyto!(buffers.eigenvectors, @view(workspace.Ti[:, :, frequency]))
+ copyto!(buffers.previous_eigenvectors, @view(workspace.Tv[:, :, frequency]))
+ copyto!(buffers.eigenvalues, @view(workspace.roots[:, frequency]))
+ copyto!(order.residual, @view(diagnostics.eigen_residual[:, frequency]))
+ copyto!(order.iterations, @view(diagnostics.iterations[:, frequency]))
+ copyto!(order.converged, @view(diagnostics.converged[:, frequency]))
+ for mode in 1:n
+ source = order.indices[mode]
+ copyto!(@view(workspace.Ti[:, mode, frequency]), @view(buffers.eigenvectors[:, source]))
+ copyto!(@view(workspace.Tv[:, mode, frequency]), @view(buffers.previous_eigenvectors[:, source]))
+ workspace.roots[mode, frequency] = buffers.eigenvalues[source]
+ diagnostics.eigen_residual[mode, frequency] = order.residual[source]
+ diagnostics.iterations[mode, frequency] = order.iterations[source]
+ diagnostics.converged[mode, frequency] = order.converged[source]
+ end
+ end
+ end
+ isempty(diagnostics.missed_frequencies) ||
+ @warn ":vieira2026 missed numerical targets" frequencies=copy(diagnostics.missed_frequencies) fallback_count=length(diagnostics.fallback_frequencies)
+ return workspace
+end
+
+:vieira2026
diff --git a/src/engine/modalanalysis/formulas/wedepohl1996.jl b/src/engine/modalanalysis/formulas/wedepohl1996.jl
new file mode 100644
index 000000000..2cd228af2
--- /dev/null
+++ b/src/engine/modalanalysis/formulas/wedepohl1996.jl
@@ -0,0 +1,194 @@
+"""
+$(TYPEDSIGNATURES)
+
+Track current eigenpairs by Newton–Raphson refinement of the normalized
+admittance-impedance product:
+
+```math
+A = YZ/\\|YZ\\|_2,\\qquad
+r(t,\\lambda) = \\begin{bmatrix}(A-\\lambda I)t\\\\t^Tt-1\\end{bmatrix},\\qquad
+J = \\begin{bmatrix}A-\\lambda I&-t\\\\2t^T&0\\end{bmatrix}.
+```
+
+Here `Z` and `Y` are per-length matrices in \\[Ω/m\\] and \\[S/m\\]. Each
+eigenpair starts from the previous stored frequency. The seed is ordered by
+decreasing attenuation. Failed iterations or duplicate eigenpairs use a direct
+eigendecomposition at the same frequency, greedily matched by column overlap.
+The calculation uses the existing physical evaluation and frequency samples.
+
+`iteration.convergence=1e-9` bounds the largest absolute Newton correction in
+the normalized problem. `iteration.max_iterations=60` limits each eigenpair.
+Misses are recorded and warned about, without discarding the fallback result.
+
+Adapted from `eig_newton` in the supplied UniversalLineModel `modal.jl`, based on
+L. M. Wedepohl, H. V. Nguyen and G. D. Irwin, *Frequency-dependent transformation
+matrices for untransposed transmission lines using Newton-Raphson method*,
+IEEE Transactions on Power Systems 11(3), 1538–1546 (1996),
+DOI: 10.1109/59.535695. The selection identifier is `:wedepohl1996`.
+"""
+function description(::Type{<:Formula{:wedepohl1996}}; compact::Bool = false)
+ compact ? "Wedepohl" :
+ "Wedepohl–Nguyen–Irwin Newton–Raphson modal transformation (1996)"
+end
+
+function formulation_options(::Expression{<:Formula{:wedepohl1996}, typeof(decompose!)})
+ FormulationOptions((iteration = (convergence = 1e-9, max_iterations = 60),))
+end
+
+function formulation_options(::Expression{<:Formula{:wedepohl1996}, typeof(decompose!)},
+ ::Val{:iteration}, defaults::NamedTuple, supplied::NamedTuple)
+ isempty(setdiff(keys(supplied), keys(defaults))) ||
+ throw(ArgumentError("unknown modal iteration controls"))
+ options=merge(defaults, supplied)
+ options.convergence isa Real && isfinite(options.convergence) &&
+ options.convergence>0 ||
+ throw(ArgumentError("convergence must be finite and positive"))
+ options.max_iterations isa Integer && !(options.max_iterations isa Bool) &&
+ options.max_iterations>0 ||
+ throw(ArgumentError("max_iterations must be a positive integer"))
+ return options
+end
+
+function initialize_buffers(
+ ::Formula{:wedepohl1996}, ::Type{T}, input, plan, buffers) where {T <: Complex}
+ n=plan.n
+ R=typeof(real(zero(T)))
+ return merge(buffers,
+ (
+ normalized_eigenproblem = Matrix{T}(undef, n, n),
+ spectral_factor = Matrix{T}(undef, n, n),
+ newton = (x = Vector{T}(undef, n+1), residual = Vector{T}(undef, n+1),
+ jacobian = Matrix{T}(undef, n+1, n+1)),
+ eigenpair_assignment = (
+ cost = Matrix{R}(undef, n, n), indices = Vector{Int}(undef, n)),
+ eigen_residual = Vector{T}(undef, n)))
+end
+
+function newton_eigenpair!(
+ vector::AbstractVector{T}, value::T, matrix, options, buffers) where {T <: Complex}
+ n=length(vector)
+ R=typeof(real(zero(T)))
+ _unit!(vector) || return value, false, 0
+ copyto!(@view(buffers.x[1:n]), vector)
+ buffers.x[end]=value
+ correction=R(Inf)
+ iterations=0
+ for iteration in 1:options.max_iterations
+ iterations=iteration
+ eigenpair_jacobian!(buffers.jacobian, buffers.x, matrix)
+ eigenpair_residual!(buffers.residual, buffers.x, matrix)
+ factor=lu!(buffers.jacobian; check = false)
+ issuccess(factor) || break
+ ldiv!(factor, buffers.residual)
+ correction=maximum(abs, buffers.residual)
+ buffers.x .-= buffers.residual
+ all(isfinite, buffers.x) || break
+ correction<=R(options.convergence) && break
+ end
+ copyto!(vector, @view(buffers.x[1:n]))
+ valid=all(isfinite, buffers.x) && _unit!(vector)
+ return buffers.x[end], valid && correction<=R(options.convergence), iterations
+end
+
+function decompose!(::Formula{:wedepohl1996}, workspace::ModalAnalysisWorkspace,
+ parameters::NamedTuple, options::FormulationOptions)
+ buffers=workspace.buffers
+ input=workspace.input
+ diagnostics=workspace.diagnostics
+ n, _, nf=size(workspace.Ti)
+ T=eltype(workspace.Ti)
+ R=typeof(real(zero(T)))
+ for frequency in 1:nf
+ copyto!(buffers.Zslice, @view(input.Z[:, :, frequency]))
+ copyto!(buffers.Yslice, @view(input.Y[:, :, frequency]))
+ buffers.Zslice ./= input.root_scale
+ buffers.Yslice ./= input.root_scale
+ matrix=buffers.admittance_impedance_product
+ mul!(matrix, buffers.Yslice, buffers.Zslice)
+ use_newton_result=frequency>1
+ if frequency>1
+ copyto!(buffers.spectral_factor, matrix)
+ scale=first(svdvals!(buffers.spectral_factor))
+ iszero(scale) && (scale=one(R))
+ buffers.normalized_eigenproblem .= matrix ./ scale
+ copyto!(buffers.eigenvectors, buffers.previous_eigenvectors)
+ for mode in 1:n
+ value, converged, iterations=newton_eigenpair!(
+ @view(buffers.eigenvectors[:, mode]), buffers.previous_eigenvalues[mode]/scale,
+ buffers.normalized_eigenproblem, options.data.iteration, buffers.newton)
+ buffers.eigenvalues[mode]=value*scale
+ diagnostics.iterations[mode, frequency]=iterations
+ diagnostics.converged[mode, frequency]=converged
+ use_newton_result &= converged
+ end
+ if use_newton_result
+ for first_mode in 1:n, second_mode in (first_mode + 1):n
+
+ overlap=abs(dot(@view(buffers.eigenvectors[:, first_mode]),
+ @view(buffers.eigenvectors[:, second_mode])))
+ gap=abs(buffers.eigenvalues[first_mode]-buffers.eigenvalues[second_mode])
+ if one(R)-overlap-real(sqrt(value)))
+ else
+ for column in 1:n, row in 1:n
+
+ assignment.cost[row, column]=-abs(dot(
+ @view(buffers.previous_eigenvectors[:, row]),
+ @view(eigensystem.vectors[:, column])))
+ end
+ greedy_assignment!(assignment.indices, assignment.cost)
+ push!(diagnostics.missed_frequencies, frequency)
+ push!(diagnostics.fallback_frequencies, frequency)
+ end
+ for mode in 1:n
+ source=assignment.indices[mode]
+ buffers.eigenvalues[mode]=eigensystem.values[source]
+ copyto!(@view(buffers.eigenvectors[:, mode]), @view(eigensystem.vectors[:, source]))
+ end
+ end
+ for mode in 1:n
+ vector=@view(buffers.eigenvectors[:, mode])
+ _unit!(vector) ||
+ throw(ArgumentError("current eigenvector has zero or undefined norm"))
+ if frequency>1 &&
+ real(dot(@view(buffers.previous_eigenvectors[:, mode]), vector))<0
+ vector .*= -one(T)
+ end
+ copyto!(@view(workspace.Ti[:, mode, frequency]), vector)
+ mul!(buffers.voltage_vector, buffers.Zslice, vector)
+ divisor=norm(buffers.voltage_vector)
+ isfinite(divisor) && !iszero(divisor) ||
+ throw(ArgumentError("voltage eigenvector has zero or undefined norm"))
+ @views workspace.Tv[:, mode, frequency] .= buffers.voltage_vector ./ divisor
+ value=buffers.eigenvalues[mode]
+ root=sqrt(value)
+ (real(root)<0 || (iszero(real(root)) && imag(root)<0)) && (root=-root)
+ workspace.roots[mode, frequency]=root*input.root_scale
+ mul!(buffers.eigen_residual, matrix, vector)
+ buffers.eigen_residual .-= value .* vector
+ denominator=(norm(matrix, Inf)+abs(value))*norm(vector, Inf)
+ numerator=norm(buffers.eigen_residual, Inf)
+ diagnostics.eigen_residual[mode, frequency]=iszero(denominator) ?
+ (iszero(numerator) ? zero(R) :
+ R(Inf)) : numerator/denominator
+ end
+ copyto!(buffers.previous_eigenvectors, buffers.eigenvectors)
+ copyto!(buffers.previous_eigenvalues, buffers.eigenvalues)
+ end
+ isempty(diagnostics.missed_frequencies) ||
+ @warn ":wedepohl1996 missed numerical targets" frequencies=copy(diagnostics.missed_frequencies) fallback_count=length(diagnostics.fallback_frequencies)
+ return workspace
+end
+
+:wedepohl1996
diff --git a/src/engine/modalanalysis/formulations.jl b/src/engine/modalanalysis/formulations.jl
new file mode 100644
index 000000000..dcb523d58
--- /dev/null
+++ b/src/engine/modalanalysis/formulations.jl
@@ -0,0 +1,133 @@
+"""
+$(TYPEDEF)
+
+Select a modal decomposition with model parameters and normalized numerical
+controls. The modal workspace supplies common numerical storage. The selected
+formula implements `decompose!` and allocates additional operation-specific
+work through `initialize_buffers`.
+
+$(TYPEDFIELDS)
+"""
+struct Formula{ID, P <: NamedTuple, O <: FormulationOptions} <: AbstractFormulation
+ "Resolved model parameters."
+ parameters::P
+ "Normalized numerical sections for the modal algorithm."
+ options::O
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct a modal decomposition with model parameters and numerical controls.
+Custom formulations extend `initialize_buffers` and `decompose!`.
+"""
+function Formula{ID}(; parameters::NamedTuple=(;), options::Union{NamedTuple, FormulationOptions} = FormulationOptions()) where {ID}
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ isempty(parameters) || throw(ArgumentError("modal :$ID has no physical parameters"))
+ selected = Formula{ID, typeof(parameters), typeof(options)}(parameters, options)
+ normalized = formulation_options(Expression(selected, decompose!), options)
+ return Formula{ID, typeof(parameters), typeof(normalized)}(parameters, normalized)
+end
+
+Formula(identifier::Symbol; kwargs...) = Formula(Val(identifier); kwargs...)
+Formula(::Val{ID}; kwargs...) where {ID} = Formula{ID}(; kwargs...)
+Formula(selected::Formula) = selected
+
+function Formula(selection::FormulaDefinition{ID, Order}) where {ID, Order}
+ Order === :default || throw(ArgumentError("order applies only to equivalent_earth"))
+ selection.equivalent_earth === nothing || throw(ArgumentError(
+ "modal formulas cannot consume equivalent_earth"))
+ return Formula{ID}(; parameters=selection.parameters, options=selection.options)
+end
+
+formula_id(::Formula{ID}) where {ID} = ID
+formula_id(::Type{<:Formula{ID}}) where {ID} = ID
+description(value::Formula; compact::Bool=false) = description(typeof(value); compact)
+formulation_options(value::Formula) = value.options
+
+"""Expose a selected modal equation and its model and numerical controls."""
+Base.NamedTuple(value::Formula) = (identifier=formula_id(value),
+ parameters=value.parameters, options=value.options.data)
+
+Base.pairs(::Type{<:Formula}; quantity=nothing) = pairs((;))
+
+"""
+$(TYPEDEF)
+
+Own a modal computation's requested declaration and resolved equation.
+The requested formula and controls are retained before default resolution and
+normalization. The resolved formula and effective controls are stored separately.
+
+$(TYPEDFIELDS)
+"""
+struct ModalAnalysisFormulation{F <: AbstractFormulation, D} <: AbstractFormulation
+ "Resolved modal decomposition."
+ formula::F
+ "Requested selection before default resolution and control normalization."
+ definition::D
+end
+
+ModalAnalysisFormulation() = ModalAnalysisFormulation(:default)
+
+function _modal_formulation(identifier::Symbol, controls::NamedTuple)
+ selection = formula(identifier; controls...)
+ return ModalAnalysisFormulation(Formula(selection), selection)
+end
+
+function _modal_formulation(selection::FormulaDefinition, controls::NamedTuple)
+ isempty(controls) || throw(ArgumentError(
+ "formula(...) already contains its modal parameters and numerical controls"))
+ return ModalAnalysisFormulation(Formula(selection), selection)
+end
+
+function _modal_formulation(selected::AbstractFormulation, controls::NamedTuple)
+ isempty(controls) || throw(ArgumentError(
+ "a completed modal formulation cannot receive additional controls"))
+ return ModalAnalysisFormulation(selected, selected)
+end
+
+function _modal_formulation(selected::ModalAnalysisFormulation, controls::NamedTuple)
+ isempty(controls) || throw(ArgumentError(
+ "a completed modal formulation cannot receive additional controls"))
+ return selected
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Select one or more modal computations. A symbol, formula declaration, or
+completed user-owned formulation selects one equation. `Grid` and `Gridspace`
+vary complete selections with product or zip composition.
+"""
+function ModalAnalysisFormulation(selection; combine::Symbol=:product, kwargs...)
+ return parameterize(ModalAnalysisFormulation, _modal_formulation,
+ (selection, (; kwargs...)); combine)
+end
+
+formula_id(::Type{<:ModalAnalysisFormulation}) = :modal
+formula_id(::ModalAnalysisFormulation) = :modal
+description(::Type{<:ModalAnalysisFormulation}; compact::Bool=false) = "modal"
+description(::ModalAnalysisFormulation; compact::Bool=false) = "modal"
+description(::Type{ModalAnalysisFormulation}, ::Val{:transformation}; compact::Bool=false) = "modal operators"
+description(::Type{ModalAnalysisFormulation},selected::AbstractFormulation;
+ compact::Bool=false,quantity=nothing) = description(selected;compact)
+description(::Type{ModalAnalysisFormulation},selected::Pair{<:AbstractFormulation,<:NamedTuple};
+ compact::Bool=false,quantity=nothing) =
+ description(ModalAnalysisFormulation,first(selected);compact,quantity)
+Base.pairs(::Type{ModalAnalysisFormulation}; quantity=nothing) = pairs((transformation=Formula,))
+formulation_options(::ModalAnalysisFormulation) = FormulationOptions()
+
+function Base.pairs(value::ModalAnalysisFormulation; quantity=nothing)
+ return pairs(ModalAnalysisFormulation,
+ (methods=(transformation=value.formula,),
+ requested=(transformation=value.definition,), options=(;)); quantity)
+end
+
+function Base.pairs(::Type{ModalAnalysisFormulation}, retained::NamedTuple; quantity=nothing)
+ return pairs(LineParametersFormulation, retained; quantity, owner=ModalAnalysisFormulation)
+end
+
+function Base.NamedTuple(value::ModalAnalysisFormulation)
+ return (backend=:modal, requested=(transformation=NamedTuple(value.definition),),
+ methods=(transformation=NamedTuple(value.formula),), options=(;))
+end
diff --git a/src/engine/modalanalysis/interfaces.jl b/src/engine/modalanalysis/interfaces.jl
new file mode 100644
index 000000000..59362743a
--- /dev/null
+++ b/src/engine/modalanalysis/interfaces.jl
@@ -0,0 +1,79 @@
+"""
+$(TYPEDEF)
+
+Select the LineCableModels modal-transformation backend.
+"""
+struct LineCableModelsModal end
+
+"""
+$(TYPEDEF)
+
+Store frequency-dependent modal-to-phase voltage and current bases.
+
+For every frequency sample, `Tv` and `Ti` satisfy `Vₚ = Tv Vₘ` and
+`Iₚ = Ti Iₘ`.
+
+$(TYPEDFIELDS)
+"""
+struct ModalOperators{V <: AbstractArray, I <: AbstractArray} <: Engine.AbstractModalOperators
+ "Modal-to-phase voltage tensor."
+ Tv::V
+ "Modal-to-phase current tensor."
+ Ti::I
+
+ function ModalOperators(Tv::V, Ti::I) where {
+ V <: AbstractArray,
+ I <: AbstractArray
+ }
+ ndims(Tv) == 3 || throw(
+ DimensionMismatch("voltage operators must be an n×n×nfreq tensor")
+ )
+ ndims(Ti) == 3 || throw(
+ DimensionMismatch("current operators must be an n×n×nfreq tensor")
+ )
+ size(Tv) == size(Ti) || throw(DimensionMismatch(
+ "voltage and current operators must have equal n×n×nfreq dimensions"
+ ))
+ size(Tv, 1) == size(Tv, 2) || throw(
+ DimensionMismatch("modal operators must be square")
+ )
+ return new{V, I}(Tv, Ti)
+ end
+end
+
+Base.size(maps::ModalOperators) = size(maps.Tv)
+
+"""
+Return the modal operators stored in modal-domain line parameters.
+"""
+function operators(parameters::LineParameters{T, U, D}) where {T, U, D <: ModalDomain}
+ parameters.domain.operators
+end
+
+function selectdomain(domain::ModalDomain, selected)
+ maps = domain.operators
+ Tv = Array(view(maps.Tv, :, :, selected))
+ Ti = Array(view(maps.Ti, :, :, selected))
+ selected_maps = ModalOperators(Tv, Ti)
+ roots = Array(view(domain.gamma, :, selected))
+ return ModalDomain(selected_maps, roots)
+end
+
+function selectdetails(retained::ComputationDetails, ::ModalDomain, selected)
+ record=retained.data
+ haskey(record,:modal) || return retained
+ modal=record.modal
+ haskey(modal,:diagnostics) || return retained
+ diagnostics=modal.diagnostics
+ original=selected isa Colon ? collect(eachindex(diagnostics.z_coupling)) : collect(selected)
+ fallback=findall(in(diagnostics.fallback_frequencies),original)
+ missed=findall(in(diagnostics.missed_frequencies),original)
+ sliced=merge(diagnostics,(fallback_frequencies=fallback,
+ missed_frequencies=missed,
+ z_coupling=diagnostics.z_coupling[selected],
+ y_coupling=diagnostics.y_coupling[selected],
+ eigen_residual=diagnostics.eigen_residual[:,selected],
+ iterations=diagnostics.iterations[:,selected],
+ converged=diagnostics.converged[:,selected]))
+ return ComputationDetails(merge(record,(modal=merge(modal,(diagnostics=sliced,)),)))
+end
diff --git a/src/engine/modalanalysis/observations.jl b/src/engine/modalanalysis/observations.jl
new file mode 100644
index 000000000..280270e0f
--- /dev/null
+++ b/src/engine/modalanalysis/observations.jl
@@ -0,0 +1,403 @@
+# Scientific units belong to the scalar quantity, independently of presentation.
+Units.quantity(::typeof(gamma)) = Units.Quantity{:propagation_constant}()
+Units.quantity(::typeof(alpha)) = Units.Quantity{:attenuation_constant}()
+Units.quantity(::typeof(beta)) = Units.Quantity{:phase_constant}()
+Units.quantity(::typeof(velocity)) = Units.Quantity{:phase_velocity}()
+Units.quantity(::typeof(Zc)) = Units.Quantity{:characteristic_impedance}()
+Units.quantity(::typeof(Yc)) = Units.Quantity{:characteristic_admittance}()
+Units.quantity(::typeof(H)) = Units.Quantity{:forward_response}()
+Units.quantity(::typeof(Tv)) = Units.Quantity{:voltage_basis}()
+Units.quantity(::typeof(Ti)) = Units.Quantity{:current_basis}()
+Units.quantity(value::Base.Fix2{typeof(H)}) = value.x.field===:voltage ?
+ Units.Quantity{:voltage_propagation_function}() :
+ Units.Quantity{:current_propagation_function}()
+Units.quantity(value::Base.Fix2{typeof(Zc)}) = Units.Quantity{:phase_characteristic_impedance}()
+Units.quantity(value::Base.Fix2{typeof(Yc)}) = Units.Quantity{:phase_characteristic_admittance}()
+
+function Units.quantity(value::Base.Fix2{typeof(H)},transform::Function)
+ name=value.x.field===:voltage ? :voltage_propagation_function :
+ :current_propagation_function
+ return _modal_bound_quantity(name,transform)
+end
+Units.quantity(::Base.Fix2{typeof(Zc)},transform::Function) =
+ _modal_bound_quantity(:phase_characteristic_impedance,transform)
+Units.quantity(::Base.Fix2{typeof(Yc)},transform::Function) =
+ _modal_bound_quantity(:phase_characteristic_admittance,transform)
+
+_modal_bound_quantity(name::Symbol,::typeof(abs)) = Units.Quantity{(name,:magnitude)}()
+_modal_bound_quantity(name::Symbol,::typeof(angle)) = Units.Quantity{(name,:phase_angle)}()
+_modal_bound_quantity(name::Symbol,::typeof(real)) = Units.Quantity{(name,:real)}()
+_modal_bound_quantity(name::Symbol,::typeof(imag)) = Units.Quantity{(name,:imag)}()
+
+for (selector, name) in ((gamma,:propagation_constant),(Zc,:characteristic_impedance),
+ (Yc,:characteristic_admittance),(H,:forward_response),(Tv,:voltage_basis),
+ (Ti,:current_basis))
+ @eval begin
+ Units.quantity(::typeof($selector),::typeof(abs)) = Units.Quantity{($(QuoteNode(name)),:magnitude)}()
+ Units.quantity(::typeof($selector),::typeof(angle)) = Units.Quantity{($(QuoteNode(name)),:phase_angle)}()
+ Units.native_unit(::Units.Quantity{($(QuoteNode(name)),:magnitude)}) = Units.native_unit(Units.Quantity{$(QuoteNode(name))}())
+ Units.display_unit(::Units.Quantity{($(QuoteNode(name)),:magnitude)}) = Units.display_unit(Units.Quantity{$(QuoteNode(name))}())
+ Units.native_unit(::Units.Quantity{($(QuoteNode(name)),:phase_angle)}) = Units.units(:base,:radian)
+ Units.display_unit(::Units.Quantity{($(QuoteNode(name)),:phase_angle)}) = Units.units(:base,:degree)
+ Units.label(::Units.Quantity{($(QuoteNode(name)),:magnitude)}) = Units.label(Units.Quantity{$(QuoteNode(name))}()) * " magnitude"
+ Units.label(::Units.Quantity{($(QuoteNode(name)),:phase_angle)}) = Units.label(Units.Quantity{$(QuoteNode(name))}()) * " angle"
+ Units.symbol(::Units.Quantity{($(QuoteNode(name)),:magnitude)}) = "|" * Units.symbol(Units.Quantity{$(QuoteNode(name))}()) * "|"
+ Units.symbol(::Units.Quantity{($(QuoteNode(name)),:phase_angle)}) = "∠" * Units.symbol(Units.Quantity{$(QuoteNode(name))}())
+ end
+end
+Units.quantity(::typeof(gamma),::typeof(real)) = Units.quantity(alpha)
+Units.quantity(::typeof(gamma),::typeof(imag)) = Units.quantity(beta)
+Units.quantity(::typeof(Zc),::typeof(real)) = Units.Quantity{:characteristic_resistance}()
+Units.quantity(::typeof(Zc),::typeof(imag)) = Units.Quantity{:characteristic_reactance}()
+Units.quantity(::typeof(Yc),::typeof(real)) = Units.Quantity{:characteristic_conductance}()
+Units.quantity(::typeof(Yc),::typeof(imag)) = Units.Quantity{:characteristic_susceptance}()
+Units.quantity(::typeof(H),::typeof(real)) = Units.Quantity{:forward_response_real}()
+Units.quantity(::typeof(H),::typeof(imag)) = Units.Quantity{:forward_response_imag}()
+Units.quantity(::typeof(Tv),::typeof(real)) = Units.Quantity{:voltage_basis_real}()
+Units.quantity(::typeof(Tv),::typeof(imag)) = Units.Quantity{:voltage_basis_imag}()
+Units.quantity(::typeof(Ti),::typeof(real)) = Units.Quantity{:current_basis_real}()
+Units.quantity(::typeof(Ti),::typeof(imag)) = Units.Quantity{:current_basis_imag}()
+
+Units.native_unit(::Units.Quantity{:attenuation_constant}) = Units.units(:base,:neper;per=(:base,:meter))
+Units.display_unit(::Units.Quantity{:attenuation_constant}) = Units.units(:base,:neper;per=(:kilo,:meter))
+Units.label(::Units.Quantity{:attenuation_constant}) = "Attenuation constant"
+Units.symbol(::Units.Quantity{:attenuation_constant}) = "α"
+Units.native_unit(::Units.Quantity{:phase_constant}) = Units.units(:base,:radian;per=(:base,:meter))
+Units.display_unit(::Units.Quantity{:phase_constant}) = Units.units(:base,:radian;per=(:kilo,:meter))
+Units.label(::Units.Quantity{:phase_constant}) = "Phase constant"
+Units.symbol(::Units.Quantity{:phase_constant}) = "β"
+Units.native_unit(::Units.Quantity{:phase_velocity}) = Units.units(:base,:meter;per=(:base,:second))
+Units.display_unit(::Units.Quantity{:phase_velocity}) = Units.native_unit(Units.Quantity{:phase_velocity}())
+Units.label(::Units.Quantity{:phase_velocity}) = "Phase velocity"
+Units.symbol(::Units.Quantity{:phase_velocity}) = "vₚ"
+for (name,base,scientific,sym) in (
+ (:characteristic_resistance,:characteristic_impedance,"Characteristic resistance","R꜀"),
+ (:characteristic_reactance,:characteristic_impedance,"Characteristic reactance","X꜀"),
+ (:characteristic_conductance,:characteristic_admittance,"Characteristic conductance","G꜀"),
+ (:characteristic_susceptance,:characteristic_admittance,"Characteristic susceptance","B꜀"),
+ (:forward_response_real,:forward_response,"Real propagation function","Re(H)"),
+ (:forward_response_imag,:forward_response,"Imaginary propagation function","Im(H)"),
+ (:voltage_basis_real,:voltage_basis,"Real modal-to-phase voltage transformation","Re(Tv)"),
+ (:voltage_basis_imag,:voltage_basis,"Imaginary modal-to-phase voltage transformation","Im(Tv)"),
+ (:current_basis_real,:current_basis,"Real modal-to-phase current transformation","Re(Ti)"),
+ (:current_basis_imag,:current_basis,"Imaginary modal-to-phase current transformation","Im(Ti)"))
+ @eval begin
+ Units.native_unit(::Units.Quantity{$(QuoteNode(name))}) = Units.native_unit(Units.Quantity{$(QuoteNode(base))}())
+ Units.display_unit(::Units.Quantity{$(QuoteNode(name))}) = Units.display_unit(Units.Quantity{$(QuoteNode(base))}())
+ Units.label(::Units.Quantity{$(QuoteNode(name))}) = $scientific
+ Units.symbol(::Units.Quantity{$(QuoteNode(name))}) = $sym
+ end
+end
+Units.native_unit(::Units.Quantity{:propagation_constant}) =
+ Units.units(:base,:dimensionless;per=(:base,:meter))
+Units.display_unit(::Units.Quantity{:propagation_constant}) =
+ Units.units(:base,:dimensionless;per=(:kilo,:meter))
+Units.native_unit(::Units.Quantity{:characteristic_impedance}) = Units.units(:base,:ohm)
+Units.display_unit(::Units.Quantity{:characteristic_impedance}) = Units.units(:base,:ohm)
+Units.native_unit(::Units.Quantity{:characteristic_admittance}) = Units.units(:base,:siemens)
+Units.display_unit(::Units.Quantity{:characteristic_admittance}) = Units.units(:base,:siemens)
+for name in (:forward_response,:voltage_basis,:current_basis)
+ @eval begin
+ Units.native_unit(::Units.Quantity{$(QuoteNode(name))}) = Units.units(:base,:dimensionless)
+ Units.display_unit(::Units.Quantity{$(QuoteNode(name))}) = Units.units(:base,:dimensionless)
+ end
+end
+Units.label(::Units.Quantity{:propagation_constant}) = "Propagation constant"
+Units.label(::Units.Quantity{:characteristic_impedance}) = "Characteristic impedance"
+Units.label(::Units.Quantity{:characteristic_admittance}) = "Characteristic admittance"
+Units.label(::Units.Quantity{:forward_response}) = "Propagation function"
+Units.label(::Units.Quantity{:voltage_basis}) = "Modal-to-phase voltage transformation"
+Units.label(::Units.Quantity{:current_basis}) = "Modal-to-phase current transformation"
+Units.symbol(::Units.Quantity{:propagation_constant}) = "γ"
+Units.symbol(::Units.Quantity{:characteristic_impedance}) = "Zc"
+Units.symbol(::Units.Quantity{:characteristic_admittance}) = "Yc"
+Units.symbol(::Units.Quantity{:forward_response}) = "H"
+Units.symbol(::Units.Quantity{:voltage_basis}) = "Tv"
+Units.symbol(::Units.Quantity{:current_basis}) = "Ti"
+
+for (name,parent,scientific,sym) in (
+ (:phase_characteristic_impedance,:characteristic_impedance,"Phase-domain characteristic impedance","Zc,phase"),
+ (:phase_characteristic_admittance,:characteristic_admittance,"Phase-domain characteristic admittance","Yc,phase"),
+ (:voltage_propagation_function,:forward_response,"Voltage propagation function","Hᵥ"),
+ (:current_propagation_function,:forward_response,"Current propagation function","Hᵢ"))
+ @eval begin
+ Units.native_unit(::Units.Quantity{$(QuoteNode(name))}) = Units.native_unit(Units.Quantity{$(QuoteNode(parent))}())
+ Units.display_unit(::Units.Quantity{$(QuoteNode(name))}) = Units.display_unit(Units.Quantity{$(QuoteNode(parent))}())
+ Units.label(::Units.Quantity{$(QuoteNode(name))}) = $scientific
+ Units.symbol(::Units.Quantity{$(QuoteNode(name))}) = $sym
+ Units.native_unit(::Units.Quantity{($(QuoteNode(name)),:magnitude)}) = Units.native_unit(Units.Quantity{$(QuoteNode(name))}())
+ Units.display_unit(::Units.Quantity{($(QuoteNode(name)),:magnitude)}) = Units.display_unit(Units.Quantity{$(QuoteNode(name))}())
+ Units.label(::Units.Quantity{($(QuoteNode(name)),:magnitude)}) = $scientific * " magnitude"
+ Units.symbol(::Units.Quantity{($(QuoteNode(name)),:magnitude)}) = "|" * $sym * "|"
+ Units.native_unit(::Units.Quantity{($(QuoteNode(name)),:phase_angle)}) = Units.units(:base,:radian)
+ Units.display_unit(::Units.Quantity{($(QuoteNode(name)),:phase_angle)}) = Units.units(:base,:degree)
+ Units.label(::Units.Quantity{($(QuoteNode(name)),:phase_angle)}) = $scientific * " angle"
+ Units.symbol(::Units.Quantity{($(QuoteNode(name)),:phase_angle)}) = "∠" * $sym
+ Units.native_unit(::Units.Quantity{($(QuoteNode(name)),:real)}) = Units.native_unit(Units.Quantity{$(QuoteNode(name))}())
+ Units.display_unit(::Units.Quantity{($(QuoteNode(name)),:real)}) = Units.display_unit(Units.Quantity{$(QuoteNode(name))}())
+ Units.label(::Units.Quantity{($(QuoteNode(name)),:real)}) = "Real " * lowercase($scientific)
+ Units.symbol(::Units.Quantity{($(QuoteNode(name)),:real)}) = "Re(" * $sym * ")"
+ Units.native_unit(::Units.Quantity{($(QuoteNode(name)),:imag)}) = Units.native_unit(Units.Quantity{$(QuoteNode(name))}())
+ Units.display_unit(::Units.Quantity{($(QuoteNode(name)),:imag)}) = Units.display_unit(Units.Quantity{$(QuoteNode(name))}())
+ Units.label(::Units.Quantity{($(QuoteNode(name)),:imag)}) = "Imaginary " * lowercase($scientific)
+ Units.symbol(::Units.Quantity{($(QuoteNode(name)),:imag)}) = "Im(" * $sym * ")"
+ end
+end
+
+const _phase_Zc = Base.Fix2(Zc,(domain=PhaseDomain,))
+const _phase_Yc = Base.Fix2(Yc,(domain=PhaseDomain,))
+const _voltage_H = Base.Fix2(H,(domain=PhaseDomain,field=:voltage))
+const _current_H = Base.Fix2(H,(domain=PhaseDomain,field=:current))
+
+# Finite representation bindings include only domain and, for H, field.
+function _validate_phase_selector(selector::Base.Fix2)
+ selector.f in (H,Zc,Yc) || throw(ArgumentError("unsupported finite representation selector"))
+ value=selector.x
+ value isa NamedTuple || throw(ArgumentError("representation binding must be a NamedTuple"))
+ if selector.f===H
+ keys(value)==(:domain,:field) && value.domain===PhaseDomain &&
+ value.field in (:voltage,:current) || throw(ArgumentError("invalid phase H binding"))
+ else
+ keys(value)==(:domain,) && value.domain===PhaseDomain ||
+ throw(ArgumentError("invalid phase characteristic binding"))
+ end
+ return selector
+end
+
+const _ModalLineParameters = LineParameters{T,U,D} where {T<:Complex,U<:Real,D<:ModalDomain}
+for selector in (gamma,Zc,Yc,Tv,Ti)
+ @eval function observe(source::_ModalLineParameters,::typeof($selector),indices...)
+ values=$selector(source)
+ return isempty(indices) ? values : getindex(values,indices...)
+ end
+end
+for selector in (gamma,alpha,beta,velocity,Zc,Yc,H,Tv,Ti)
+ @eval function observe(source::PropagationParameters,::typeof($selector),indices...)
+ values=$selector(source)
+ return isempty(indices) ? values : getindex(values,indices...)
+ end
+end
+function observe(source::Union{_ModalLineParameters,PropagationParameters},
+ selector::Base.Fix2{typeof(Zc)},indices...)
+ _validate_phase_selector(selector)
+ values=Zc(source,PhaseDomain)
+ return isempty(indices) ? values : getindex(values,indices...)
+end
+function observe(source::Union{_ModalLineParameters,PropagationParameters},
+ selector::Base.Fix2{typeof(Yc)},indices...)
+ _validate_phase_selector(selector)
+ values=Yc(source,PhaseDomain)
+ return isempty(indices) ? values : getindex(values,indices...)
+end
+function observe(source::PropagationParameters,
+ selector::Base.Fix2{typeof(H)},indices...)
+ _validate_phase_selector(selector)
+ values=H(source,PhaseDomain;field=selector.x.field)
+ return isempty(indices) ? values : getindex(values,indices...)
+end
+const _ModalTransform=Union{typeof(abs),typeof(angle),typeof(real),typeof(imag)}
+for selector in (gamma,Zc,Yc,Tv,Ti), source_type in (:_ModalLineParameters,:PropagationParameters)
+ @eval observe(source::$source_type,::typeof($selector),
+ transform::_ModalTransform,indices...) = transform.(observe(source,$selector,indices...))
+end
+@eval observe(source::PropagationParameters,::typeof(H),
+ transform::_ModalTransform,indices...) = transform.(observe(source,H,indices...))
+for selector in (Zc,Yc), source_type in (:_ModalLineParameters,:PropagationParameters)
+ @eval observe(source::$source_type,bound::Base.Fix2{typeof($selector)},
+ transform::_ModalTransform,indices...) = transform.(observe(source,bound,indices...))
+end
+observe(source::PropagationParameters,bound::Base.Fix2{typeof(H)},
+ transform::_ModalTransform,indices...) = transform.(observe(source,bound,indices...))
+
+_modal_transform_requests(selectors) = Tuple((selector,transform) for selector in selectors
+ for transform in (abs,angle,real,imag))
+
+function observables(::Type{<:LineParameters{T,U,D}}) where {T<:Complex,U<:Real,D<:ModalDomain}
+ selectors=(gamma,Zc,Yc,Tv,Ti,_phase_Zc,_phase_Yc)
+ return (observables(LineParameters)...,selectors...,
+ _modal_transform_requests(selectors)...)
+end
+function observables(::Type{<:PropagationParameters})
+ selectors=(gamma,Zc,Yc,H,Tv,Ti,_phase_Zc,_phase_Yc,_voltage_H,_current_H)
+ return (selectors...,alpha,beta,velocity,_modal_transform_requests(selectors)...)
+end
+
+Commons.normalize_observation_selector(::typeof(alpha)) = (gamma,real)
+Commons.normalize_observation_selector(::typeof(beta)) = (gamma,imag)
+Commons.normalize_observation_selector(::Val{:alpha}) = Commons.normalize_observation_selector(alpha)
+Commons.normalize_observation_selector(::Val{:beta}) = Commons.normalize_observation_selector(beta)
+
+function _normalize_modal_request(source,request)
+ identity=request_identity(request)
+ prefix=identity isa Tuple ? identity : (identity,)
+ selector=first(prefix)
+ selector in observables(typeof(source)) ||
+ selector in (gamma,alpha,beta,velocity,Zc,Yc,H,Tv,Ti) ||
+ throw(ArgumentError("unsupported modal quantity"))
+ selector isa Base.Fix2 && _validate_phase_selector(selector)
+ length(prefix)<=2 && (length(prefix)==1 || prefix[2] in (abs,angle,real,imag)) ||
+ throw(ArgumentError("unsupported modal transform"))
+ rank=selector in (Tv,Ti,_phase_Zc,_phase_Yc,_voltage_H,_current_H) ? 3 : 2
+ indices=request_indices(request)
+ isempty(indices) && (indices=ntuple(_->Colon(),rank))
+ length(indices)==rank || throw(DimensionMismatch("modal request requires $rank selectors"))
+ dimensions=rank==2 ? size(gamma(source)) : size(Tv(source))
+ map(observation_indices,indices,dimensions)
+ return (prefix...,indices...)
+end
+
+function Commons.observation_requests(source::_ModalLineParameters,requests::Tuple;complete_pairs::Bool=false)
+ selected=isempty(requests) ? (Engine.Z,Engine.Y,gamma,Zc,Yc,Tv,Ti) : requests
+ primary=Tuple(filter(request -> begin
+ identity=request_identity(request)
+ first(identity isa Tuple ? identity : (identity,)) in
+ (Engine.Z,Engine.Y,Engine.R,Engine.X,Engine.L,Engine.G,Engine.B,Engine.C,real,imag,abs,angle)
+ end,selected))
+ derived=Tuple(filter(request -> request ∉ primary,selected))
+ normalized_primary=isempty(primary) ? () :
+ invoke(Commons.observation_requests,
+ Tuple{LineParameters,Tuple},
+ source,primary;complete_pairs).retained
+ normalized_derived=_modal_component_requests(source,derived;complete_pairs)
+ retained=(normalized_primary...,normalized_derived...)
+ allunique(retained) || throw(ArgumentError("observation requests must be distinct"))
+ return (retained=retained,displayed=selected)
+end
+
+function Commons.observation_requests(source::PropagationParameters,requests::Tuple;complete_pairs::Bool=false)
+ selected=isempty(requests) ? (gamma,Zc,Yc,H,Tv,Ti,velocity) : requests
+ normalized=_modal_component_requests(source,selected;complete_pairs)
+ allunique(normalized) || throw(ArgumentError("observation requests must be distinct"))
+ return (retained=normalized,displayed=selected)
+end
+
+function _modal_component_requests(source,selected;complete_pairs)
+ normalized=Tuple[]
+ complex_selectors=(gamma,Zc,Yc,H,Tv,Ti,_phase_Zc,_phase_Yc,_voltage_H,_current_H)
+ for request in selected
+ item=_normalize_modal_request(source,request)
+ identity=Commons.normalize_observation_selector(request_identity(item))
+ prefix=identity isa Tuple ? identity : (identity,)
+ item=(prefix...,request_indices(item)...)
+ if identity in complex_selectors
+ append!(normalized,((identity,real,request_indices(item)...),
+ (identity,imag,request_indices(item)...)))
+ else
+ push!(normalized,item)
+ end
+ end
+ allunique(normalized) || throw(ArgumentError("observation requests must be distinct"))
+ for item in copy(normalized)
+ identity=request_identity(item)
+ identity isa Tuple && length(identity)==2 && last(identity) in (real,imag,abs,angle) || continue
+ selector=first(identity)
+ selector in complex_selectors || continue
+ other=last(identity)===real ? imag :
+ last(identity)===imag ? real : last(identity)===abs ? angle : abs
+ companion=(selector,other,request_indices(item)...)
+ companion in normalized && continue
+ complete_pairs || throw(ArgumentError("$(nameof(selector isa Base.Fix2 ? selector.f : selector)) requires a complete component pair"))
+ push!(normalized,companion)
+ end
+ return Tuple(normalized)
+end
+
+function _modal_coordinates(source,request,values)
+ identity=request_identity(request)
+ selector=first(identity isa Tuple ? identity : (identity,))
+ dims=size(values)
+ indices=request_indices(request)
+ selected=map(observation_indices,indices,dims)
+ n=size(Tv(source),1)
+ modes=string.(1:n)
+ phase=get(details(source isa PropagationParameters ? source.parameters : source).data,
+ :phase_coordinates,nothing)
+ phase=phase===nothing ? string.(1:n) : string.(phase)
+ mixed=selector in (Tv,Ti)
+ phase_matrix=selector in (_phase_Zc,_phase_Yc,_voltage_H,_current_H)
+ labels=mixed || phase_matrix ? phase : modes
+ if length(dims)==2
+ return (kind=:vector,indices,axis=:mode,axis_label="Mode",
+ positions=selected[1],
+ samples=selected[2],frequencies=copy(frequencies(source)[selected[2]]),
+ frequency_unit=Units.units(:base,:hertz),extent=(n,length(frequencies(source))),
+ labels,domain=:ModalDomain)
+ end
+ record=(kind=:matrix,indices,rows=selected[1],columns=selected[2],samples=selected[3],
+ frequencies=copy(frequencies(source)[selected[3]]),
+ frequency_unit=Units.units(:base,:hertz),extent=(n,n,length(frequencies(source))),
+ labels,domain=mixed || phase_matrix ? :PhaseDomain : :ModalDomain)
+ return mixed ? merge(record,(column_labels=modes,column_domain=:ModalDomain)) : record
+end
+
+function _modal_observation_quantity(source,request;unit=nothing,clip=true,atol=nothing,frequencies=nothing)
+ frequencies===nothing || frequencies==LineCableModels.frequencies(source) ||
+ throw(ArgumentError("derived modal observation uses stored frequencies"))
+ atol===nothing || throw(ArgumentError("derived modal quantities have no engineering cutoff"))
+ identity=request_identity(request)
+ prefix=identity isa Tuple ? identity : (identity,)
+ selector=first(prefix)
+ transform=length(prefix)==2 ? prefix[2] : identity
+ raw=observe(source,selector)
+ indices=request_indices(request)
+ original=getindex(raw,indices...)
+ values=length(prefix)==2 ? transform.(original) : original
+ available=Engine.resolution_available.(original) .& Engine.resolution_available.(values)
+ origin=length(prefix)==2 && transform===angle ? iszero.(nominal.(original)) : false
+ magnitude_origin=length(prefix)==2 && transform===abs ?
+ iszero.(nominal.(original)) : false
+ available=available .& .!origin
+ reasons=broadcast(available,origin,magnitude_origin) do valid,at_zero,at_magnitude_origin
+ valid ? nothing : at_zero ? :undefined_phase :
+ at_magnitude_origin ? :undefined_first_order_magnitude : :nonfinite_value
+ end
+ resolved=broadcast((value,valid)->valid ? value : missing,values,available)
+ q=Commons.request_quantity(request)
+ native=Units.native_unit(q,basis(source))
+ target=unit===nothing ? Units.display_unit(q,basis(source)) : unit
+ T=typeof(float(real(nominal(zero(eltype(raw))))))
+ factor=Units.scale_factor(native,target,T)
+ assumptions=Engine.observation_assumptions(source,selector)
+ undefined=any(x -> x===:undefined_first_order_magnitude,
+ reasons isa AbstractArray ? reasons : (reasons,))
+ components=undefined ? (nominal_magnitude=Commons.detach(abs.(nominal.(original))),
+ real=Commons.detach(real.(original)),imaginary=Commons.detach(imag.(original)),
+ unit=Units.native_unit(selector isa Base.Fix2 ? selector.f : selector,basis(source))) : nothing
+ return (request,quantity=q,family=Symbol(nameof(selector isa Base.Fix2 ? selector.f : selector)),
+ statistic=:value,values=Commons.detach(resolved,factor),unit=target,
+ basis=basis(source),coordinates=_modal_coordinates(source,request,raw),
+ assumptions,thresholds=nothing,available,engineering_zero=false,clipped=false,
+ missing_reason=reasons,unavailable_components=components)
+end
+
+function Engine.observation_assumptions(
+ source::Union{_ModalLineParameters,PropagationParameters},selector)
+ retained=details(source).data
+ upstream=get(retained,:selections,nothing)
+ modal=get(retained,:modal,nothing)
+ return upstream===nothing || modal===nothing ? nothing :
+ (upstream=(Z=get(upstream,:Z,nothing),Y=get(upstream,:Y,nothing)),
+ modal=get(modal,:effective,nothing),rotate=get(modal,:rotate,nothing))
+end
+
+function Commons.observation_quantity(source::_ModalLineParameters,request;kwargs...)
+ identity=request_identity(request)
+ selector=first(identity isa Tuple ? identity : (identity,))
+ if selector in (Engine.Z,Engine.Y,Engine.R,Engine.X,Engine.L,Engine.G,Engine.B,Engine.C)
+ return invoke(Commons.observation_quantity,
+ Tuple{LineParameters,Any},
+ source,request;kwargs...)
+ end
+ return _modal_observation_quantity(source,request;kwargs...)
+end
+Commons.observation_quantity(source::PropagationParameters,request;kwargs...) =
+ _modal_observation_quantity(source,request;kwargs...)
+
+function Commons.observation_gridpoint(source::PropagationParameters)
+ original=Commons.observation_gridpoint(source.parameters)
+ inputs=original.inputs===nothing ? (segment=(line_length=source.line_length,),) :
+ merge(original.inputs,(segment=(line_length=source.line_length,),))
+ return merge(original,(id=get(source.details.data,:gridpoint,nothing),
+ source_gridpoint=get(source.details.data,:source_gridpoint,nothing),inputs,
+ transformation=get(source.details.data,:modal,nothing)))
+end
diff --git a/src/engine/modalanalysis/problems.jl b/src/engine/modalanalysis/problems.jl
new file mode 100644
index 000000000..6b4aa7f1d
--- /dev/null
+++ b/src/engine/modalanalysis/problems.jl
@@ -0,0 +1,40 @@
+"""
+$(TYPEDEF)
+
+Define modal analysis of one completed phase-domain frequency scan.
+
+$(TYPEDFIELDS)
+"""
+struct ModalAnalysisProblem{P <: LineParameters} <: AbstractProblemDefinition
+ "Source line parameters."
+ parameters::P
+
+ function ModalAnalysisProblem{P}(parameters::P) where {P <: LineParameters}
+ return validate(new{P}(parameters))
+ end
+end
+
+function validate(problem::ModalAnalysisProblem)
+ validate(problem.parameters)
+ parameters = problem.parameters
+ size(parameters.Z, 1) > 0 || throw(ArgumentError("modal analysis requires at least one mode"))
+ isempty(parameters.f) && throw(ArgumentError("modal analysis requires frequency samples"))
+ all(>(zero(eltype(parameters.f))), parameters.f) ||
+ throw(DomainError(parameters.f, "modal analysis requires positive frequencies"))
+ all(>(zero(eltype(parameters.f))), diff(parameters.f)) ||
+ throw(ArgumentError("modal analysis requires strictly increasing frequencies"))
+ all(isfinite, parameters.Z.values) ||
+ throw(DomainError(parameters.Z.values, "series impedance must be finite"))
+ all(isfinite, parameters.Y.values) ||
+ throw(DomainError(parameters.Y.values, "shunt admittance must be finite"))
+ return problem
+end
+
+function ModalAnalysisProblem(
+ parameters::LineParameters{T, U, PhaseDomain}
+) where {T, U}
+ return ModalAnalysisProblem{typeof(parameters)}(parameters)
+end
+
+Base.eltype(problem::ModalAnalysisProblem) = eltype(problem.parameters)
+Base.eltype(::Type{ModalAnalysisProblem{P}}) where {P} = eltype(P)
diff --git a/src/engine/modalanalysis/propagation.jl b/src/engine/modalanalysis/propagation.jl
new file mode 100644
index 000000000..e8731bac3
--- /dev/null
+++ b/src/engine/modalanalysis/propagation.jl
@@ -0,0 +1,177 @@
+"""
+ PropagationParameters(modal; line_length=line_length(modal))
+
+Bind per-unit-length modal parameters to one line segment.
+The source normalization length and active segment length are independent.
+"""
+struct PropagationParameters{P <: LineParameters, L <: Real, D <: ComputationDetails} <:
+ AbstractCoreResult
+ parameters::P
+ line_length::L
+ details::D
+
+ function PropagationParameters(parameters::P, line_length::L,
+ details::D) where
+ {P <: LineParameters, L <: Real, D <: ComputationDetails}
+ Engine.domain(parameters)===ModalDomain || throw(ArgumentError(
+ "PropagationParameters require modal-domain coefficients"))
+ basis(parameters)===:pul || throw(ArgumentError(
+ "PropagationParameters require per-unit-length coefficients"))
+ _segment_length(line_length)
+ haskey(details.data, :gridpoint) && haskey(details.data, :source_gridpoint) ||
+ throw(ArgumentError("segment details must retain gridpoint ancestry"))
+ retained=get(details.data, :segment, nothing)
+ retained isa NamedTuple && haskey(retained, :normalization_length) &&
+ haskey(retained, :line_length) ||
+ throw(ArgumentError("segment details must retain the completed length description"))
+ isequal(retained.line_length, line_length) ||
+ throw(ArgumentError("segment details must describe the stored line_length"))
+ return new{P, L, D}(parameters, line_length, details)
+ end
+end
+
+function _segment_length(value)
+ value isa Real && !(value isa Bool) && isfinite(nominal(value)) &&
+ nominal(value) >= 0 ||
+ throw(ArgumentError("segment line_length must be finite and nonnegative"))
+ return value
+end
+
+function per_unit_length(parameters::LineParameters{
+ T, U, D, :pul}) where {T, U, D <: ModalDomain}
+ return parameters
+end
+
+function per_unit_length(parameters::LineParameters{
+ T, U, D, :total}) where {T, U, D <: ModalDomain}
+ ell0 = line_length(parameters)
+ ell0 isa Real && !(ell0 isa Bool) && isfinite(nominal(ell0)) &&
+ nominal(ell0) > 0 || throw(ArgumentError(
+ "total modal coefficients require a known positive source normalization length"))
+ modal = ModalDomain(parameters.domain.operators, gamma(parameters) ./ ell0)
+ return LineParameters(modal,
+ SeriesImpedance(parameters.Z.values ./ ell0; basis = :pul),
+ ShuntAdmittance(parameters.Y.values ./ ell0; basis = :pul),
+ parameters.f, parameters.details)
+end
+
+function PropagationParameters(parameters::LineParameters{T, U, D};
+ line_length = LineCableModels.line_length(parameters), combine::Symbol = :product) where {
+ T, U, D <: ModalDomain}
+ return parameterize(PropagationParameters, _bind_segment, (parameters, line_length); combine)
+end
+
+function _bind_segment(parameters::LineParameters{T, U, D}, line_length) where {
+ T, U, D <: ModalDomain}
+ line_length === nothing && throw(ArgumentError(
+ "segment line_length is required when the source has no declared length"))
+ segment_length = _segment_length(line_length)
+ per_unit_length_parameters = per_unit_length(parameters)
+ source_length = LineCableModels.line_length(parameters)
+ source_gridpoint = get(parameters.details.data, :gridpoint, nothing)
+ segment_gridpoint = isequal(segment_length, source_length) ? source_gridpoint :
+ Commons.gridpoint_id()
+ source_record=(;
+ (key=>value
+ for (key, value) in pairs(parameters.details.data)
+ if key ∉ (:timing, :comparison_unsupported))...)
+ segment_details = merge(source_record,
+ (gridpoint = segment_gridpoint, source_gridpoint,
+ segment = (line_length = segment_length, normalization_length = source_length)))
+ completed_details = ComputationDetails(segment_details)
+ return PropagationParameters(per_unit_length_parameters, segment_length, completed_details)
+end
+
+function PropagationParameters(source::PropagationParameters;
+ line_length = source.line_length, combine::Symbol = :product)
+ return parameterize(PropagationParameters, _bind_segment, (source, line_length); combine)
+end
+
+function _bind_segment(source::PropagationParameters, line_length)
+ segment_length = _segment_length(line_length)
+ if isequal(segment_length, source.line_length)
+ return source
+ end
+ source_record=(;
+ (key=>value
+ for (key, value) in pairs(source.details.data)
+ if key ∉ (:timing, :comparison_unsupported))...)
+ segment_details = merge(source_record,
+ (gridpoint = Commons.gridpoint_id(),
+ source_gridpoint = get(source.details.data, :gridpoint, nothing),
+ segment = merge(source.details.data.segment, (line_length = segment_length,))))
+ return PropagationParameters(source.parameters, segment_length, ComputationDetails(segment_details))
+end
+
+line_length(source::PropagationParameters) = source.line_length
+basis(::PropagationParameters) = :pul
+frequencies(source::PropagationParameters) = frequencies(source.parameters)
+details(source::PropagationParameters) = source.details
+Tv(source::PropagationParameters) = Tv(source.parameters)
+Ti(source::PropagationParameters) = Ti(source.parameters)
+gamma(source::PropagationParameters) = gamma(source.parameters)
+
+"""Return modal attenuation constants in inverse metres, ordered mode × frequency."""
+alpha(source::PropagationParameters) = real.(gamma(source))
+
+"""Return modal phase constants in radians per metre, ordered mode × frequency."""
+beta(source::PropagationParameters) = imag.(gamma(source))
+
+"""Return phase velocities in metres per second from `2πf / beta`, ordered mode × frequency."""
+function velocity(source::PropagationParameters)
+ constants=beta(source)
+ f=frequencies(source)
+ return map(CartesianIndices(constants)) do index
+ value=constants[index]
+ T=promote_type(typeof(float(real(nominal(value)))), typeof(float(nominal(f[index[2]]))))
+ (T(2)*T(π)*f[index[2]])/value
+ end
+end
+function Zc(source::PropagationParameters, domain::Type{<:Engine.LineParamsDomain} = ModalDomain)
+ Zc(source.parameters, domain)
+end
+function Yc(source::PropagationParameters, domain::Type{<:Engine.LineParamsDomain} = ModalDomain)
+ Yc(source.parameters, domain)
+end
+
+function Base.getindex(source::PropagationParameters,
+ selected::Union{
+ Integer, AbstractRange{<:Integer}, AbstractVector{<:Integer}, Colon})
+ parameters=source.parameters[selected]
+ retained=ComputationDetails(merge(source.details.data,
+ (modal = get(parameters.details.data, :modal, nothing),)))
+ return PropagationParameters(parameters, source.line_length, retained)
+end
+
+"""Forward-wave factors over the segment's bound physical length."""
+H(source::PropagationParameters) = H(source, ModalDomain)
+
+function H(source::PropagationParameters, ::Type{ModalDomain}; field = nothing)
+ field === nothing || throw(ArgumentError("modal H does not accept a field"))
+ return exp.(-gamma(source) .* source.line_length)
+end
+
+function H(source::PropagationParameters, ::Type{PhaseDomain}; field)
+ field in (:voltage, :current) ||
+ throw(ArgumentError("field must be :voltage or :current"))
+ return _phase_H(source, Val(field))
+end
+
+function _phase_H(source::PropagationParameters, ::Val{:voltage})
+ return _similarity_H(H(source), Tv(source))
+end
+function _phase_H(source::PropagationParameters, ::Val{:current})
+ return _similarity_H(H(source), Ti(source))
+end
+
+function _similarity_H(factors, maps)
+ n, nf = size(factors)
+ S=promote_type(eltype(factors), eltype(maps))
+ result=Array{S, 3}(undef, n, n, nf)
+ for frequency in 1:nf
+ basis_matrix=@view maps[:, :, frequency]
+ @views result[:, :, frequency] .= (basis_matrix * Diagonal(factors[:, frequency])) /
+ basis_matrix
+ end
+ return result
+end
diff --git a/src/engine/modalanalysis/quantities.jl b/src/engine/modalanalysis/quantities.jl
new file mode 100644
index 000000000..49f3378be
--- /dev/null
+++ b/src/engine/modalanalysis/quantities.jl
@@ -0,0 +1,52 @@
+"""Return the retained modal-to-phase voltage basis (phase × mode × frequency)."""
+Tv(parameters::LineParameters{T,U,D}) where {T,U,D<:ModalDomain} =
+ parameters.domain.operators.Tv
+
+"""Return the retained modal-to-phase current basis (phase × mode × frequency)."""
+Ti(parameters::LineParameters{T,U,D}) where {T,U,D<:ModalDomain} =
+ parameters.domain.operators.Ti
+
+"""Return the retained propagation roots (mode × frequency)."""
+gamma(parameters::LineParameters{T,U,D}) where {T,U,D<:ModalDomain} =
+ parameters.domain.gamma
+
+function _characteristic(parameters::LineParameters{T,U,D}, quantity::Val,
+ ::Type{ModalDomain}) where {T,U,D<:ModalDomain}
+ coefficients = quantity isa Val{:Zc} ? parameters.Z.values : parameters.Y.values
+ roots = gamma(parameters)
+ n, _, nf = size(coefficients)
+ S = promote_type(T,eltype(roots))
+ result = Array{S,2}(undef,n,nf)
+ for frequency in 1:nf, mode in 1:n
+ result[mode,frequency] = coefficients[mode,mode,frequency] / roots[mode,frequency]
+ end
+ return result
+end
+
+function _characteristic(parameters::LineParameters{T,U,D}, quantity::Val,
+ ::Type{PhaseDomain}) where {T,U,D<:ModalDomain}
+ diagonal = _characteristic(parameters,quantity,ModalDomain)
+ maps = operators(parameters)
+ n,nf = size(diagonal)
+ S = promote_type(eltype(diagonal),eltype(maps.Tv),eltype(maps.Ti))
+ result = Array{S,3}(undef,n,n,nf)
+ for frequency in 1:nf
+ if quantity isa Val{:Zc}
+ left = @view maps.Tv[:,:,frequency]
+ right = @view maps.Ti[:,:,frequency]
+ else
+ left = @view maps.Ti[:,:,frequency]
+ right = @view maps.Tv[:,:,frequency]
+ end
+ @views result[:,:,frequency] .= (left * Diagonal(diagonal[:,frequency])) / right
+ end
+ return result
+end
+
+"""Characteristic impedance of the selected diagonal modal approximation."""
+Zc(parameters::LineParameters{T,U,D}, domain::Type{<:Engine.LineParamsDomain}=ModalDomain) where {T,U,D<:ModalDomain} =
+ _characteristic(parameters,Val(:Zc),domain)
+
+"""Characteristic admittance of the selected diagonal modal approximation."""
+Yc(parameters::LineParameters{T,U,D}, domain::Type{<:Engine.LineParamsDomain}=ModalDomain) where {T,U,D<:ModalDomain} =
+ _characteristic(parameters,Val(:Yc),domain)
diff --git a/src/engine/modalanalysis/textdisplay.jl b/src/engine/modalanalysis/textdisplay.jl
new file mode 100644
index 000000000..998f18e99
--- /dev/null
+++ b/src/engine/modalanalysis/textdisplay.jl
@@ -0,0 +1,96 @@
+TextDisplay.name(::Type{<:ModalAnalysisProblem}) = "ModalAnalysisProblem"
+function Base.summary(io::IO, problem::ModalAnalysisProblem)
+ print(io, "Modal analysis problem, ", size(problem.parameters.Z, 1), " modes")
+end
+function Base.show(io::IO, problem::ModalAnalysisProblem)
+ print(io, "ModalAnalysisProblem(", size(problem.parameters.Z, 1), " modes, ",
+ length(problem.parameters.f), " frequencies)")
+end
+function Base.show(io::IO, ::MIME"text/plain", problem::ModalAnalysisProblem)
+ get(io, :compact, false) && return show(io, problem)
+ return TextDisplay.tree(io,
+ "Modal analysis problem",
+ (
+ (label = "source phase-domain LineParameters", noun = "fields"),
+ (label = "modes $(size(problem.parameters.Z,1))", noun = "fields"),
+ (
+ label = "frequency $(first(problem.parameters.f))–$(last(problem.parameters.f)) Hz ($(length(problem.parameters.f)) samples)",
+ noun = "fields")
+ ))
+end
+
+TextDisplay.name(::Type{<:ModalAnalysisFormulation}) = "ModalAnalysisFormulation"
+function Base.summary(io::IO, formulation::ModalAnalysisFormulation)
+ print(io, "Modal analysis formulation, :", formula_id(formulation.formula))
+end
+function Base.show(io::IO, formulation::ModalAnalysisFormulation)
+ print(io, "ModalAnalysisFormulation(:", formula_id(formulation.formula), ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", formulation::ModalAnalysisFormulation)
+ get(io, :compact, false) && return show(io, formulation)
+ return TextDisplay.tree(io,
+ "Modal analysis formulation",
+ (
+ (label = "formula :$(formula_id(formulation.formula))", noun = "fields"),
+ (
+ label = "controls $(length(formulation_options(formulation.formula).data)) sections",
+ noun = "fields")
+ ))
+end
+
+TextDisplay.name(::Type{<:ModalAnalysisWorkspace}) = "ModalAnalysisWorkspace"
+function Base.summary(io::IO, workspace::ModalAnalysisWorkspace)
+ print(io, "Modal analysis workspace, ", size(workspace.Ti, 1), " modes")
+end
+function Base.show(io::IO, workspace::ModalAnalysisWorkspace)
+ print(io, "ModalAnalysisWorkspace(", join(size(workspace.Ti), '×'), ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", workspace::ModalAnalysisWorkspace)
+ get(io, :compact, false) && return show(io, workspace)
+ return TextDisplay.tree(io,
+ "Modal analysis workspace",
+ (
+ (label = "bases $(join(size(workspace.Ti),'×'))", noun = "fields"),
+ (label = "roots $(join(size(workspace.roots),'×'))", noun = "fields")
+ ))
+end
+
+TextDisplay.name(::Type{<:ModalOperators}) = "ModalOperators"
+function Base.summary(io::IO, maps::ModalOperators)
+ print(io, "Modal operators, ", join(size(maps.Tv), '×'))
+end
+function Base.show(io::IO, maps::ModalOperators)
+ print(io, "ModalOperators(Tv=", join(size(maps.Tv), '×'),
+ ", Ti=", join(size(maps.Ti), '×'), ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", maps::ModalOperators)
+ get(io, :compact, false) && return show(io, maps)
+ return TextDisplay.tree(io,
+ "Modal operators",
+ (
+ (label = "Tv $(join(size(maps.Tv),'×'))", noun = "fields"),
+ (label = "Ti $(join(size(maps.Ti),'×'))", noun = "fields")
+ ))
+end
+
+TextDisplay.name(::Type{<:PropagationParameters}) = "PropagationParameters"
+function Base.summary(io::IO, source::PropagationParameters)
+ print(io, "Propagation parameters over ", source.line_length, " m")
+end
+function Base.show(io::IO, source::PropagationParameters)
+ print(io, "PropagationParameters(length=", source.line_length, ", modes=",
+ size(Tv(source), 1), ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", source::PropagationParameters)
+ get(io, :compact, false) && return show(io, source)
+ return TextDisplay.tree(io,
+ "Propagation parameters · line segment",
+ (
+ (label = "length $(source.line_length) m", noun = "fields"),
+ (label = "modes $(size(Tv(source),1))", noun = "fields"),
+ (
+ label = "frequency $(first(frequencies(source)))–$(last(frequencies(source))) Hz ($(length(frequencies(source))) samples)",
+ noun = "fields"),
+ (label = "basis :pul", noun = "fields")
+ ))
+end
diff --git a/src/engine/observed_inputs.jl b/src/engine/observed_inputs.jl
new file mode 100644
index 000000000..fb79f14ef
--- /dev/null
+++ b/src/engine/observed_inputs.jl
@@ -0,0 +1,196 @@
+# This projection is restricted to physical declarations owned by the package.
+# It retains their named values, not constructed problems or geometry caches.
+_input_record(value::Union{Number,Symbol,AbstractString,Nothing,Missing,Type}) = value
+_input_record(value::NamedTuple) = map(_input_record, value)
+_input_record(value::Tuple) = map(_input_record, value)
+_input_record(value::AbstractArray) = map(_input_record, value)
+_input_record(value::Pair) = _input_record(first(value)) => _input_record(last(value))
+_input_record(value::AbstractDict) = Dict(_input_record(k) => _input_record(v) for (k,v) in value)
+
+function _input_record(value::Union{DataModel.AbstractCablePart,DataModel.AbstractShape,
+ DataModel.Pose2,DataModel.Shell,DataModel.Ring,DataModel.Polar,DataModel.Fill,
+ DataModel.Lattice,DataModel.Helix,DataModel.LayRatio,DataModel.Pitch,
+ DataModel.LayAngle,DataModel.FillFactor,DataModel.AssemblyMember,
+ Materials.AbstractMaterial,Earth.AbstractEarthModel,Earth.AbstractEarthLayer,
+ Earth.AbstractEarthMaterial})
+ fields = fieldnames(typeof(value))
+ return merge((kind=nameof(typeof(value)),field_descriptions=Commons.input_fields(typeof(value))),
+ NamedTuple{fields}(map(name -> _input_record(getfield(value,name)), fields)))
+end
+
+_input_record(design::CableDesign) = (cable_id=design.cable_id,
+ origin=_input_record(design.origin),
+ terminal_order=copy(design.terminal_order))
+
+function _input_record(system::LineCableSystem)
+ return (system_id=system.system_id, field_descriptions=Commons.input_fields(typeof(system)), line_length=system.line_length,
+ designs=_input_record(system.designs), positions=_input_record(system.positions),
+ input_positions=_input_record(system.input_positions), clearances=copy(system.clearances),
+ connections=_input_record(system.connections), environment=_input_record(system.environment),
+ terminal_order=copy(system.terminal_order), connection_order=copy(system.connection_order))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Capture physical declarations from a completed package-owned problem. Store the
+returned record in result details at completion, once per physical point. Later
+observation reads that record without evaluating problem builders.
+"""
+function completed_inputs(problem::LineParametersProblem)
+ Record=NamedTuple{(:system,:temperature,:earth_props,:frequencies,:field_descriptions),
+ Tuple{NamedTuple,typeof(problem.temperature),NamedTuple,typeof(problem.frequencies),NamedTuple}}
+ return Record((_input_record(problem.system),problem.temperature,_input_record(problem.earth_props),
+ copy(problem.frequencies),(temperature=(name="temperature",unit="°C"),)))
+end
+function completed_inputs(problem::CableConstantsProblem)
+ Record=NamedTuple{(:design,:temperature,:frequency,:field_descriptions),
+ Tuple{NamedTuple,typeof(problem.temperature),typeof(problem.frequency),NamedTuple}}
+ return Record((_input_record(problem.design),problem.temperature,problem.frequency,
+ (temperature=(name="temperature",unit="°C"),frequency=(name="frequency",unit="Hz"))))
+end
+
+# Capture owner-scoped meanings and names while the selected objects exist.
+# Common-field omission later compares selections, never punctuation.
+"""
+$(TYPEDSIGNATURES)
+
+Capture actual formulation selections, controls, and structured description
+fields when a result completes.
+"""
+function completed_formulation(formulation, declaration::NamedTuple=NamedTuple(formulation))
+ function control_fields(owner, controls, path=())
+ captured=NamedTuple[]
+ for (key,value) in pairs(controls)
+ route=(path...,key)
+ if value isa NamedTuple && key !== :equivalent_earth
+ append!(captured,control_fields(owner,value,route))
+ else
+ text=key === :equivalent_earth ?
+ description(FormulaDefinition,Val(key),value;compact=true) :
+ description(owner,Val(key),value;compact=true)
+ push!(captured,(scope=route,value=Commons.detach(value),text))
+ end
+ end
+ return captured
+ end
+ function fields(quantity)
+ selections=pairs(formulation;quantity)
+ map(collect(selections)) do (scope,selected)
+ owner,route=scope
+ name=isempty(route) ? "" : description(owner,Val(first(route));compact=true)
+ length(route)>1 && (name *= "("*join(string.(Base.tail(route)),",")*")")
+ controls=selected isa Pair ? Commons.detach(last(selected)) : (;)
+ # The description compares applied controls, including defaults.
+ # Keep the original selection identity used by scientific grouping.
+ effective=get(declaration,:methods,nothing)
+ for key in route
+ effective=effective isa NamedTuple ? get(effective,key,nothing) : nothing
+ end
+ applied=isempty(route) || !(effective isa NamedTuple) ? controls : merge(controls,
+ (; (key=>effective[key] for key in (:parameters,:options,:equivalent_earth)
+ if haskey(effective,key) && effective[key]!==nothing)...))
+ leaf=selected isa Pair ? first(selected) : selected
+ control_owner=isempty(route) ? owner : typeof(leaf)
+ # Routes are the scientific slots declared by pairs(owner), shared
+ # by backends using the same constitutive meaning.
+ (meaning=route,
+ selection=(identifier=formula_id(selected),controls),name,
+ value=isempty(route) ? description(selected;compact=true) : description(owner,selected;compact=true),
+ summary=isempty(route) ? description(owner,formulation;quantity,compact=true) : nothing,
+ control_fields=control_fields(control_owner,applied))
+ end
+ end
+ return NamedTuple{(:formulations,:selections,:formulation_fields),
+ Tuple{NamedTuple,NamedTuple,NamedTuple}}((declaration,
+ (Z=formula_id(formulation,Z),Y=formula_id(formulation,Y)),
+ (all=fields(nothing),Z=fields(Z),Y=fields(Y))))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Complete `formulation` as the line-parameter computation of `workspace` applied it. Each
+earth formula records the equivalent earth that its calculation in the plan consumed. A
+reduction that the plan resolved through `:default` appears as that formula's
+`equivalent_earth`, as an explicit one does. The requested selections remain as declared.
+"""
+function completed_formulation(formulation::LineParametersFormulation,
+ workspace::LineParametersWorkspace)
+ declaration = NamedTuple(formulation)
+ length(workspace.input.earth.layers) > 2 ||
+ return completed_formulation(formulation, declaration)
+ slots = (:earth_impedance, :earth_admittance)
+ # Each formula without an explicit `equivalent_earth` that the plan reduced, with the
+ # reduction its calculation consumed.
+ reduced = Pair[]
+ for calculation in workspace.plan.earth.calculations
+ calculation.earth isa EarthModel && continue
+ for entry in (calculation.impedance, calculation.admittance)
+ entry === nothing || entry.formula.equivalent_earth !== nothing ||
+ push!(reduced, entry.formula => calculation.earth)
+ end
+ end
+ function record(selected, retained)
+ selected isa NamedTuple && return map(record, selected, retained)
+ index = findfirst(entry -> first(entry) === selected, reduced)
+ index === nothing && return retained
+ return merge(retained, (equivalent_earth = NamedTuple(last(reduced[index])),))
+ end
+ recorded = merge(declaration.methods,
+ map(record, formulation.methods[slots], declaration.methods[slots]))
+ return completed_formulation(formulation,
+ typeof(declaration)((declaration.backend, declaration.requested, recorded, declaration.options)))
+end
+
+# Description contents and runtime geometry do not specialize the result type.
+"""
+$(TYPEDSIGNATURES)
+
+Store captured completion records without specializing the result type on runtime
+geometry or nested description contents. Persistence uses this same operation.
+"""
+function completion_details(record::NamedTuple{names,T}) where {names,T}
+ types=map(value -> value isa NamedTuple ? NamedTuple : typeof(value),values(record))
+ return ComputationDetails(NamedTuple{names,Tuple{types...}}(values(record)))
+end
+
+function Commons.observation_gridpoint(source::Union{LineParameters,CableConstants})
+ retained=details(source).data
+ inputs=get(retained,:inputs,nothing)
+ return Commons.detach((id=get(retained,:gridpoint,nothing), inputs,
+ source_gridpoint=get(retained,:source_gridpoint,nothing),
+ formulations=get(retained,:formulations,nothing),formulation_fields=get(retained,:formulation_fields,(;)),
+ coordinates=get(retained,:coordinates,source isa CableConstants ? source.cores : nothing),
+ uncertainty=get(retained,:uncertainty,nothing),
+ uncertainty_descriptions=get(retained,:uncertainty_descriptions,nothing),
+ transformation=get(retained,:modal,nothing),
+ missing_reason=inputs===nothing ? :physical_inputs_not_supplied : nothing))
+end
+
+# Updating an association reuses numerical storage. It does not recompute or
+# copy a physical problem. External result owners can supply their own method.
+"""
+$(TYPEDSIGNATURES)
+
+Associate a completed result with its original gridpoint identity and additional
+captured fields. Built-in results retain their numerical arrays and replace only
+completion details. External result owners may extend this completion operation.
+"""
+retain_gridpoint(source, id; fields=(;)) = source
+function retain_gridpoint(source::LineParameters, id; fields=(;))
+ retained=completion_details(merge(details(source).data,fields,(gridpoint=id,)))
+ return LineParameters(source.domain,source.Z,source.Y,source.f,retained)
+end
+function retain_gridpoint(source::CableConstants, id; fields=(;))
+ retained=completion_details(merge(details(source).data,fields,(gridpoint=id,)))
+ return CableConstants(source.cores,source.R,source.L,source.C,source.G,source.frequency,retained)
+end
+
+function line_length(source::AbstractCoreResult)
+ inputs=get(details(source).data,:inputs,nothing)
+ inputs===nothing && return nothing
+ system=get(inputs,:system,nothing)
+ return system===nothing ? nothing : system.line_length
+end
+line_length(source) = nothing
diff --git a/src/engine/options.jl b/src/engine/options.jl
new file mode 100644
index 000000000..102dc8208
--- /dev/null
+++ b/src/engine/options.jl
@@ -0,0 +1,200 @@
+"""
+$(TYPEDSIGNATURES)
+
+Normalize the line-parameter reductions. `reduce_bundle=true` merges the
+conductors of each active phase, `kron_reduction=true` eliminates conductors with
+phase zero, and `ideal_transposition=false` leaves the retained matrices
+untransposed. With `ideal_transposition=true`, the retained ``Z`` and
+potential-coefficient matrix ``P`` are averaged over cyclic transposition before
+``P`` is inverted to ``Y``. `LineCableModelsFEM` shares these options.
+"""
+function formulation_options(
+ ::Type{LineParametersFormulation},
+ record::FormulationOptions
+)::FormulationOptions
+ options = record.data
+ allowed = (
+ :reduce_bundle,
+ :kron_reduction,
+ :ideal_transposition
+ )
+ unknown = filter(key -> key ∉ allowed, keys(options))
+ isempty(unknown) || throw(ArgumentError(
+ "unknown line-parameter formulation options: $(sort!(collect(unknown)))",
+ ))
+ normalized = merge(
+ (
+ reduce_bundle = true,
+ kron_reduction = true,
+ ideal_transposition = false
+ ),
+ options
+ )
+ all(name -> getproperty(normalized, name) isa Bool,
+ (:reduce_bundle, :kron_reduction, :ideal_transposition)) || throw(ArgumentError(
+ "reduction and transposition options must be Bool",
+ ))
+ return FormulationOptions(;
+ reduce_bundle = normalized.reduce_bundle,
+ kron_reduction = normalized.kron_reduction,
+ ideal_transposition = normalized.ideal_transposition
+ )
+end
+
+function computation_options(
+ ::Type{LineCableModelsCoaxial},
+ record::ComputationOptions
+)::ComputationOptions
+ options = record.data
+ allowed = (:verbosity, :output_basis, :trace, :on_result, :timing)
+ unknown = filter(key -> key ∉ allowed, keys(options))
+ isempty(unknown) || throw(ArgumentError(
+ "unknown LineCableModelsCoaxial computation options: $(sort!(collect(unknown)))",
+ ))
+ normalized = merge(
+ (verbosity = (default = 0,), output_basis = :pul,
+ trace = false, on_result = nothing, timing = false),
+ options
+ )
+ levels = verbosity(normalized.verbosity)
+ basis_value = normalized.output_basis
+ basis_value in (:pul, :total) || throw(ArgumentError(
+ "output_basis must be :pul or :total; got $(repr(basis_value))",
+ ))
+ normalized.trace isa Bool || throw(ArgumentError("trace must be Bool"))
+ normalized.timing isa Bool || throw(ArgumentError("timing must be Bool"))
+ basis = basis_value === :pul ? (output_basis = Val(:pul),) :
+ (output_basis = Val(:total),)
+ execution = merge((verbosity = levels,), basis,
+ (trace = Val(false), on_result = normalized.on_result, timing = normalized.timing))
+ normalized.trace && return ComputationOptions(merge(execution, (trace = Val(true),)))
+ return ComputationOptions(execution)
+end
+
+
+description(::Type{<:Union{LineParametersFormulation,LineCableModelsFEM}},
+ ::Val{:reduce_bundle},value::Bool;compact::Bool=false) = "bundle reduction="*string(value)
+description(::Type{<:Union{LineParametersFormulation,LineCableModelsFEM}},
+ ::Val{:kron_reduction},value::Bool;compact::Bool=false) = "Kron reduction="*string(value)
+description(::Type{<:Union{LineParametersFormulation,LineCableModelsFEM}},
+ ::Val{:ideal_transposition},value::Bool;compact::Bool=false) = "ideal transposition="*string(value)
+
+"""Describe the active FEM field equations without executing a field solve."""
+description(::Type{LineCableModelsFEM},::Val{:physics},value::Symbol;compact::Bool=false) =
+ description(LineCableModelsFEM,Val(:physics),Val(value);compact)
+description(::Type{LineCableModelsFEM},::Val{:physics},::Val{Symbol("quasi-tem")};compact::Bool=false) =
+ "quasi-TEM"
+description(::Type{LineCableModelsFEM},::Val{:physics},::Val{Symbol("quasi-fw")};compact::Bool=false) =
+ "quasi-full-wave"
+
+function formulation_options(::Type{LineCableModelsFEM}, record::FormulationOptions)::FormulationOptions
+ options = record.data
+ physics = get(options, :physics, :quasi_tem)
+ physics isa Union{Symbol, AbstractString} || throw(ArgumentError(
+ "physics must be :quasi_tem or :quasi_fw"))
+ physics = Symbol(replace(String(physics), '_' => '-'))
+ physics in (Symbol("quasi-tem"), Symbol("quasi-fw")) || throw(ArgumentError(
+ "physics must be :quasi_tem or :quasi_fw (also accepted as hyphenated strings or Symbols)"))
+ reductions = formulation_options(LineParametersFormulation,
+ FormulationOptions(; (key => value for (key, value) in pairs(options) if key !== :physics)...))
+ return FormulationOptions(; reductions.data..., physics)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Validate Gmsh/GetDP execution controls supplied to `compute(...; options)`.
+The field model is selected separately by `formulation_options(LineCableModelsFEM, ...)`.
+
+# Arguments
+
+- `owner`: the `LineCableModelsFEM` computation owner.
+- `options`: caller-supplied named tuple. Supported keys and defaults are:
+ - `ui=false`: open the Gmsh graphical interface.
+ - `plot_field_maps=false`: emit spatial field maps for every solve.
+ - `mesh_mode=:reuse`: reuse compatible meshes. `:remesh` regenerates them.
+ - `mesh_path=nothing`: optional existing `.msh` path.
+ - `domain_skin_depths=2.0`: minimum finite earth-domain radius in conductive
+ skin depths \\[dimensionless\\]. The layout can require a larger radius.
+ Must be finite and positive. Changing it retains local mesh-size targets.
+ - `keep_run_directory=false`: retain successful run artifacts.
+ - `getdp_executable=nothing`: executable override. Otherwise resolve the
+ environment override, package artifact, or unsupported-platform `PATH` fallback.
+ - `gmsh_verbosity=2`, `getdp_verbosity=2`: native message levels from 0 through 5.
+ - `frequency_workers=2`: maximum concurrent frequency solver processes.
+ - `solver_threads=1`: BLAS and OpenMP threads per solver process.
+ - `verbosity=(default=0,)`: logging in Julia levels from 0 through 2.
+ - `output_basis=:pul`: per-unit-length matrices. `:total` scales by line length.
+ - `trace=false`: retain `Z_primitive`, `P_primitive` and a copied `phase_map`
+ under `details(result).data.fem.primitive`. The primitive arrays refer to the
+ completed FEM scan arrays.
+ - `timing=false`: retain fresh complete-scan measurements in result details.
+ - `on_result=nothing`: optional callback `(problem, index, result)`.
+ - `log_file=nothing`: optional Julia log path.
+ - `resume_run_directory=nothing`: resume a compatible path or `:latest`.
+
+# Returns
+
+- A fixed-key [`ComputationOptions`](@ref) named tuple. Paths and integer controls
+ are normalized. `output_basis` and `trace` are lowered to `Val` values.
+
+# Errors
+
+- `ArgumentError`: unknown keys, invalid controls or empty paths.
+"""
+function computation_options(::Type{LineCableModelsFEM}, record::ComputationOptions)::ComputationOptions
+ options = record.data
+ defaults = (ui=false, plot_field_maps=false, mesh_mode=:reuse,
+ mesh_path=nothing, domain_skin_depths=2.0,
+ keep_run_directory=false, getdp_executable=nothing,
+ gmsh_verbosity=2, getdp_verbosity=2, frequency_workers=2, solver_threads=1,
+ log_file=nothing, resume_run_directory=nothing)
+ standard_keys = (:verbosity, :output_basis, :trace, :on_result, :timing)
+ unknown = setdiff(keys(options), (keys(defaults)..., standard_keys...))
+ isempty(unknown) || throw(ArgumentError(
+ "unknown LineCableModelsFEM computation options: $(Tuple(unknown))"))
+ standard = computation_options(LineCableModelsCoaxial,
+ ComputationOptions(; (key => value for (key, value) in pairs(options) if key in standard_keys)...))
+ normalized = merge(defaults,
+ (; (key => value for (key, value) in pairs(options) if key in keys(defaults))...))
+ for name in (:ui, :plot_field_maps, :keep_run_directory)
+ getproperty(normalized, name) isa Bool || throw(ArgumentError("$name must be Bool"))
+ end
+ normalized.mesh_mode in (:reuse, :remesh) || throw(ArgumentError(
+ "mesh_mode must be :reuse or :remesh"))
+ radius_factor = normalized.domain_skin_depths
+ radius_factor isa Real && !(radius_factor isa Bool) &&
+ isfinite(radius_factor) && 0 < radius_factor <= floatmax(Float64) &&
+ Float64(radius_factor) > 0 || throw(ArgumentError(
+ "domain_skin_depths must be a finite positive Float64-representable number"))
+ for name in (:mesh_path, :getdp_executable, :log_file)
+ value = getproperty(normalized, name)
+ value isa Union{Nothing, AbstractString} || throw(ArgumentError(
+ "$name must be a path string or nothing"))
+ value === nothing || !isempty(value) || throw(ArgumentError("$name cannot be empty"))
+ end
+ for name in (:gmsh_verbosity, :getdp_verbosity)
+ value = getproperty(normalized, name)
+ value isa Integer && !(value isa Bool) && value in 0:5 || throw(ArgumentError(
+ "$name must be an integer from 0 through 5"))
+ end
+ for name in (:frequency_workers, :solver_threads)
+ value = getproperty(normalized, name)
+ value isa Integer && !(value isa Bool) && 1 <= value <= typemax(Int) ||
+ throw(ArgumentError("$name must be a positive integer"))
+ end
+ resume = normalized.resume_run_directory
+ resume === nothing || resume === :latest || resume isa AbstractString && !isempty(resume) ||
+ throw(ArgumentError("resume_run_directory must be nothing, :latest, or a nonempty path string"))
+ return ComputationOptions(; standard.data...,
+ ui=normalized.ui, plot_field_maps=normalized.plot_field_maps,
+ mesh_mode=normalized.mesh_mode,
+ mesh_path=normalized.mesh_path === nothing ? nothing : String(normalized.mesh_path),
+ domain_skin_depths=Float64(radius_factor),
+ keep_run_directory=normalized.keep_run_directory,
+ getdp_executable=normalized.getdp_executable === nothing ? nothing : String(normalized.getdp_executable),
+ gmsh_verbosity=Int(normalized.gmsh_verbosity), getdp_verbosity=Int(normalized.getdp_verbosity),
+ frequency_workers=Int(normalized.frequency_workers), solver_threads=Int(normalized.solver_threads),
+ log_file=normalized.log_file === nothing ? nothing : String(normalized.log_file),
+ resume_run_directory=resume isa AbstractString ? String(resume) : resume)
+end
diff --git a/src/engine/pipeimpedance/PipeImpedance.jl b/src/engine/pipeimpedance/PipeImpedance.jl
new file mode 100644
index 000000000..8d6cfbd34
--- /dev/null
+++ b/src/engine/pipeimpedance/PipeImpedance.jl
@@ -0,0 +1,41 @@
+"""
+ LineCableModels.Engine.PipeImpedance
+
+Own pipe-type formula selections and backend applicability. No analytical
+pipe-type implementation is supplied yet.
+"""
+module PipeImpedance
+import ...Commons: FormulationOptions, formulas, formulation_options, validate
+import ...LineCableModels: FormulaDefinition
+
+export Formula, formula_id, formulas
+
+using DocStringExtensions: TYPEDEF
+#! explicit-imports: off
+# Expanded in the docstrings of the included formula files.
+using DocStringExtensions: TYPEDSIGNATURES
+#! explicit-imports: on
+import ..Engine: PipeImpedanceFormulation
+import ...LineCableModels: formula_id
+#! explicit-imports: off
+# These bindings are consumed by the formula definitions below.
+import ...LineCableModels: description
+#! explicit-imports: on
+import ...DataModel
+using ...DataModel: CableDesign
+
+include("interface.jl")
+
+#! explicit-imports: off
+const FORMULAS = (
+ include("formulas/none.jl"),
+ include("formulas/default.jl"),
+)
+#! explicit-imports: on
+
+"""
+Return registered pipe selections, including the explicit default selection.
+"""
+formulas(::Type{<:Formula}) = FORMULAS
+
+end
diff --git a/src/engine/pipeimpedance/formulas/default.jl b/src/engine/pipeimpedance/formulas/default.jl
new file mode 100644
index 000000000..b497536de
--- /dev/null
+++ b/src/engine/pipeimpedance/formulas/default.jl
@@ -0,0 +1,12 @@
+"""
+$(TYPEDSIGNATURES)
+
+Route the default selection to the explicit `:none` implementation.
+No numerical equation is owned by `:default`.
+"""
+description(::Type{<:Formula{:default}}; compact::Bool=false) =
+ compact ? "Default" : "Default routing to :none"
+
+Formula{:default}(; kwargs...) = Formula{:none}(; kwargs...)
+
+:default
diff --git a/src/engine/pipeimpedance/formulas/none.jl b/src/engine/pipeimpedance/formulas/none.jl
new file mode 100644
index 000000000..d11dab695
--- /dev/null
+++ b/src/engine/pipeimpedance/formulas/none.jl
@@ -0,0 +1,14 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** No additional pipe impedance for coaxial geometry.
+
+**Expression.** Ordinary coaxial assemblies do not require an additional pipe term.
+An eccentric or multicore conductive enclosure requires a pipe formulation:
+the coaxial formulation supplies none.
+
+**Applicability.** This selector does not add an author-labeled pipe equation or numerical approximation.
+"""
+description(::Type{<:Formula{:none}}; compact::Bool=false) = compact ? "No additional pipe term" : "No additional pipe impedance for coaxial geometry"
+
+:none
diff --git a/src/engine/pipeimpedance/interface.jl b/src/engine/pipeimpedance/interface.jl
new file mode 100644
index 000000000..976e25aa0
--- /dev/null
+++ b/src/engine/pipeimpedance/interface.jl
@@ -0,0 +1,81 @@
+"""
+$(TYPEDEF)
+
+Store a pipe-type impedance selection until the backend checks the topology.
+Unsupported pipes require a separate numerical formula. `Formula{ID}()` constructs a
+registered pipe-type selection. Unknown identifiers or controls raise `ArgumentError`.
+Backend applicability is checked against the design.
+"""
+struct Formula{ID} <: PipeImpedanceFormulation
+ function Formula{ID}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions()) where {ID}
+ ID in formulas(Formula) || throw(ArgumentError("unknown pipe-impedance formula :$ID"))
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ isempty(parameters) && isempty(options.data) ||
+ throw(ArgumentError("pipe-impedance :$ID accepts no model parameters or numerical controls"))
+ return new{ID}()
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Check that `backend` can compute `design` with the pipe-impedance formula `selected`.
+The check compares the conductor axes with each conductive enclosure: a design whose
+enclosure is eccentric or contains several cores has pipe topology. Because the built-in
+formulas do not add a pipe term, they admit coaxial topology only. A backend or formula
+that implements pipe topology adds its own method. Return `design`.
+
+# Errors
+
+- Throws `ArgumentError` for a pipe topology.
+"""
+function validate(design::CableDesign, selected::Formula, backend)
+ pipe() = throw(ArgumentError("Pipe-type cable formulation is not yet implemented for " *
+ "the $(description(backend)) backend. No pipe formulation is available."))
+ # Compare conductor axes, not wire positions. A concentric sheath remains
+ # ordinary coaxial geometry even when declared through pipe(...).
+ for wall in design.geometry.regions
+ wall.source.material.kind === :conductor || continue
+ annular_wall = wall.primitive isa DataModel.Annulus && wall.primitive.ri > 0
+ enclosed_wall = any(entry -> entry.pattern isa DataModel.EnclosureBoundary,
+ wall.placement.patterns)
+ annular_wall || enclosed_wall || continue
+ wall.primitive isa DataModel.Annulus || pipe()
+ axis = DataModel.radial_position(wall)
+ for terminal in design.terminal_order
+ terminal === wall.terminal && continue
+ sources = filter(region -> region.terminal === terminal, design.geometry.regions)
+ center = DataModel.conductor_zone_position(sources)
+ distance = hypot(center[1] - axis[1], center[2] - axis[2])
+ distance < wall.primitive.ri && !DataModel.same_radial_position(center, axis) &&
+ pipe()
+ end
+ end
+ return design
+end
+
+Formula(identifier::Symbol; kwargs...) = Formula(Val(identifier); kwargs...)
+Formula(::Val{ID}; kwargs...) where {ID} = Formula{ID}(; kwargs...)
+Formula(selected::PipeImpedanceFormulation) = selected
+
+function Formula(selection::FormulaDefinition{ID, Order}) where {ID, Order}
+ Order === :default || throw(ArgumentError("order applies only to equivalent_earth"))
+ isempty(selection.options.data) ||
+ throw(ArgumentError("deferred pipe contribution has no numerical options"))
+ selection.equivalent_earth === nothing ||
+ throw(ArgumentError("pipe contribution cannot consume equivalent_earth"))
+ return Formula{ID}(; parameters = selection.parameters)
+end
+
+formula_id(::Formula{ID}) where {ID} = ID
+formula_id(::Type{<:Formula{ID}}) where {ID} = ID
+# Identity-only dispatch also describes retained selections without constructors.
+description(value::Formula; compact::Bool = false) = description(typeof(value); compact)
+formulation_options(::Formula) = FormulationOptions()
+
+"""Expose the selected pipe equation as a native record."""
+Base.NamedTuple(value::Formula) = (identifier=formula_id(value),parameters=(;),options=(;))
+
+"""Iterate the independently selectable child slots admitted by this formula family."""
+Base.pairs(::Type{<:Formula}; quantity=nothing) = pairs((;))
diff --git a/src/engine/plot.jl b/src/engine/plot.jl
deleted file mode 100644
index b9fb3eeca..000000000
--- a/src/engine/plot.jl
+++ /dev/null
@@ -1,1179 +0,0 @@
-
-using Makie
-import Makie: plot
-
-include("../plotbuilder/plothelpers.jl")
-
-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 <: AbstractPlotSpec
- 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
-
-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;
- per_length::Bool = true,
-)
- 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, per_length)),
- ComponentMetadata(:imag, :impedance, sym.impedance,
- string(desc.impedance, " – imaginary part"),
- string("imag(", sym.impedance, ")"),
- UnitSpec(units.impedance, per_length)),
- ]
- else
- return ComponentMetadata[
- ComponentMetadata(:magnitude, :impedance, sym.impedance,
- string(desc.impedance, " – magnitude"),
- string("|", sym.impedance, "|"),
- UnitSpec(units.impedance, per_length)),
- 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, per_length)),
- ComponentMetadata(:inductance, :inductance, sym.inductance,
- desc.inductance,
- sym.inductance,
- UnitSpec(units.inductance, per_length)),
- ]
- else
- Base.error("Unsupported mode $(mode)")
- end
-end
-
-function components_for(
- obj::ShuntAdmittance,
- mode::Symbol,
- coord::Symbol;
- per_length::Bool = true,
-)
- 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, per_length)),
- ComponentMetadata(:imag, :admittance, sym.admittance,
- string(desc.admittance, " – imaginary part"),
- string("imag(", sym.admittance, ")"),
- UnitSpec(units.admittance, per_length)),
- ]
- else
- return ComponentMetadata[
- ComponentMetadata(:magnitude, :admittance, sym.admittance,
- string(desc.admittance, " – magnitude"),
- string("|", sym.admittance, "|"),
- UnitSpec(units.admittance, per_length)),
- 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, per_length)),
- ComponentMetadata(:capacitance, :capacitance, sym.capacitance,
- desc.capacitance,
- sym.capacitance,
- UnitSpec(units.capacitance, per_length)),
- ]
- 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,
- per_length::Bool = true,
-)
- 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; per_length = per_length)
- 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,
- per_length::Bool = true,
-)
- 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; per_length = per_length)
- 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,
- per_length::Bool = true,
-)
- 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,
- per_length = per_length,
- ),
- )
- 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,
- per_length = per_length,
- ),
- )
- 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,
- per_length::Bool = true,
- kwargs...,
-)
- specs = lineparameter_plot_specs(obj, freqs; per_length = per_length, 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,
- per_length::Bool = true,
- kwargs...,
-)
- specs = lineparameter_plot_specs(obj, freqs; per_length = per_length, kwargs...)
- return render_plot_specs(specs; backend = backend, display_plot = display_plot)
-end
-
-function plot(
- lp::LineParameters;
- backend = nothing,
- display_plot::Bool = true,
- per_length::Bool = true,
- kwargs...,
-)
- specs = lineparameter_plot_specs(lp; per_length = per_length, 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[])
-
- # ---- Override global tick formatter for this specialized plot ----------
- axis.xtickformat[] = Makie.automatic
- axis.ytickformat[] = Makie.automatic
-
- # ---- 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, x_vals_obs, [0]; 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
deleted file mode 100644
index cf1279d8e..000000000
--- a/src/engine/problemdefs.jl
+++ /dev/null
@@ -1,244 +0,0 @@
-
-"""
-$(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.r_ex > comp.conductor_group.r_in "Component $j in cable $i: conductor outer radius must be larger than inner radius"
- @assert comp.insulator_group.r_ex > comp.insulator_group.r_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.r_ex
- r_in_ins = comp.insulator_group.r_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.r_ex
- 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 = r_outer_i + r_outer_j
- tol = 1e-8 * max(min_allowed, 1.0)
-
-
- @assert dist + tol >= min_allowed """
- Cables $i and $j overlap!
- Center-to-center distance: $(dist + tol) m
- Minimum required distance: $(min_allowed) 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 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::Union{AbstractTransformFormulation, Nothing}
- "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::Union{AbstractTransformFormulation, Nothing},
- 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::Union{AbstractTransformFormulation, Nothing} = nothing,
- 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/problems.jl b/src/engine/problems.jl
new file mode 100644
index 000000000..0a162578b
--- /dev/null
+++ b/src/engine/problems.jl
@@ -0,0 +1,449 @@
+"""
+$(TYPEDEF)
+
+Define one scalar line-parameter computation over a completed cable system.
+Operating temperature and analysis frequencies are fields of the problem.
+
+$(TYPEDFIELDS)
+"""
+struct LineParametersProblem{
+ T <: Real,
+ S <: LineCableSystem{T},
+ E <: EarthModel{T}
+} <: AbstractProblemDefinition
+ "Physical cable system."
+ system::S
+ "Operating temperature \\[°C\\]."
+ temperature::T
+ "Static earth model."
+ earth_props::E
+ "Strictly positive, sorted analysis frequencies \\[Hz\\]."
+ frequencies::Vector{T}
+
+ function LineParametersProblem{T, S, E}(
+ system::S,
+ temperature::T,
+ earth_props::E,
+ frequencies::Vector{T}
+ ) where {
+ T <: Real,
+ S <: LineCableSystem{T},
+ E <: EarthModel{T}
+ }
+ return validate(new{T, S, E}(
+ system,
+ temperature,
+ earth_props,
+ frequencies
+ ))
+ end
+end
+
+Base.eltype(::LineParametersProblem{T}) where {T} = T
+Base.eltype(::Type{LineParametersProblem{T}}) where {T} = T
+line_length(problem::LineParametersProblem) = line_length(problem.system)
+
+function validate(problem::LineParametersProblem)
+ validate(problem.system)
+ validate(problem.earth_props)
+ DataModel.clearance_geometry(problem.system.designs, problem.system.positions;
+ required = problem.system.clearances, interface = true, adjust = false)
+ phases = unique(problem.system.connection_order)
+ active = filter(>(0), phases)
+ isempty(active) && throw(ArgumentError(
+ "at least one conductor must be assigned to an active phase",
+ ))
+ maximum(active) <= nphases(problem.system) || throw(DomainError(
+ active,
+ "an active-phase assignment exceeds the number of distinct active phases"
+ ))
+ isfinite(problem.temperature) || throw(DomainError(problem.temperature,
+ "operating temperature must be finite"))
+ isempty(problem.frequencies) && throw(ArgumentError("frequencies cannot be empty"))
+ all(value -> isfinite(value) && value > zero(value), problem.frequencies) ||
+ throw(DomainError(
+ problem.frequencies, "frequencies must be positive and finite"
+ ))
+ issorted(problem.frequencies) || throw(ArgumentError("frequencies must be sorted"))
+ return problem
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct a problem after promoting the system, operating temperature, static
+earth model and frequencies to one real scalar
+type.
+
+# Keywords
+
+- `temperature`: operating temperature \\[°C\\].
+- `earth_props`: static earth model.
+- `frequencies`: positive sorted analysis frequencies \\[Hz\\].
+"""
+function LineParametersProblem(
+ system::LineCableSystem;
+ temperature::Real = oftype(float(system.line_length), 20),
+ earth_props::EarthModel,
+ frequencies::AbstractVector{<:Real} = [oftype(float(system.line_length), 50)]
+)
+ isempty(frequencies) && throw(ArgumentError("frequencies cannot be empty"))
+ T = promote_type(
+ eltype(system), typeof(float(temperature)), eltype(earth_props),
+ typeof(float(first(frequencies)))
+ )
+ converted_system = DataModel.interface_clearance(convert(LineCableSystem{T}, system))
+ converted_earth = convert(EarthModel{T}, earth_props)
+ return LineParametersProblem{
+ T,
+ typeof(converted_system),
+ typeof(converted_earth)
+ }(
+ converted_system,
+ convert(T, float(temperature)),
+ converted_earth,
+ T[convert(T, float(value)) for value in frequencies]
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct a line-parameter problem from completed cable designs and physical
+placements.
+
+# Arguments
+
+- `designs`: one completed design, or a collection aligned with `placements`.
+- `placements`: one placement or a collection of physical placements.
+
+# Keywords
+
+- `connections`: terminal-to-active-phase declarations. Use one-based active
+ phase IDs and `0` for grounded or eliminated conductors.
+- `environment`: optional physical environment declaration.
+- `system_id`: stable system identifier.
+- `line_length`: physical line length in meters.
+- `temperature`: operating temperature in °C.
+- `earth_props`: static earth model.
+- `frequencies`: positive sorted analysis frequencies in Hz.
+- `combine`: rule used to combine designs and placements.
+
+# Returns
+
+One validated [`LineParametersProblem`](@ref) containing a completed
+[`LineCableSystem`](@ref).
+"""
+function LineParametersProblem(
+ designs::Union{CableDesign, AbstractVector{<:CableDesign}, Tuple},
+ placements,
+ connections,
+ environment,
+ system_id::AbstractString,
+ line_length::Real,
+ temperature::Real,
+ earth_props::EarthModel,
+ frequencies::AbstractVector{<:Real};
+ combine::Symbol = :product
+)
+ system = build(
+ LineCableSystem,
+ designs,
+ placements;
+ connections,
+ environment,
+ system_id,
+ line_length,
+ combine
+ )
+ return LineParametersProblem(system; temperature, earth_props, frequencies)
+end
+
+"""
+$(TYPEDEF)
+
+Store the physical methods selected for a line-parameter computation.
+
+$(TYPEDFIELDS)
+"""
+struct LineParametersFormulation{M <: NamedTuple, O <: FormulationOptions, D <: NamedTuple} <:
+ AbstractFormulation
+ "Owner-resolved physical methods. Context-dependent defaults remain deferred."
+ methods::M
+ "Shared physical computation options."
+ options::O
+ "Requested selections retained before owner and problem-context resolution."
+ definitions::D
+end
+
+"""Identify the owned coaxial computation without report numbering."""
+description(::Type{<:LineParametersFormulation}; compact::Bool=false) = description(LineCableModelsCoaxial;compact)
+description(::LineParametersFormulation; compact::Bool=false) = description(LineParametersFormulation;compact)
+formula_id(::Type{<:LineParametersFormulation}) = :coaxial
+formula_id(::LineParametersFormulation) = :coaxial
+
+"""
+$(TYPEDSIGNATURES)
+
+Describe the selected earth-return methods relevant to `quantity`. This compact
+summary identifies the analytical computation when compared with another
+backend. Individual constitutive selections remain separately described.
+"""
+function description(::Type{LineParametersFormulation}, source::LineParametersFormulation;
+ quantity=nothing, compact::Bool=false)
+ entries=pairs(source;quantity)
+ selected=[(route,value) for ((_,route),value) in entries
+ if !isempty(route) && first(route) in (:earth_impedance,:earth_admittance) &&
+ value !== nothing && !ismissing(value)]
+ isempty(selected) && return description(source;compact)
+ length(unique(formula_id(value) for (_,value) in selected))==1 &&
+ return description(last(first(selected));compact)
+ return join(map(selected) do (route,value)
+ name=description(LineParametersFormulation,Val(first(route));compact)
+ length(route)>1 && (name *= "("*join(string.(Base.tail(route)),",")*")")
+ name*"="*description(value;compact)
+ end," / ")
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Iterate ordered formula-slot owners relevant to a physical quantity. With
+`quantity=nothing`, retain every declared slot. This is a declaration read,
+not problem-dependent formula resolution.
+"""
+function Base.pairs(::Type{LineParametersFormulation}; quantity=nothing)
+ selected=(internal_impedance=InternalImpedance.Formula,
+ insulation_impedance=InsulationImpedance.Formula,
+ earth_impedance=EarthImpedance.Formula,
+ shunt_model=ShuntModel.Formula,
+ insulation_admittance=InsulationAdmittance.Formula,
+ semicon_admittance=SemiconAdmittance.Formula,
+ earth_admittance=EarthAdmittance.Formula,
+ earth_properties=Earth.FrequencyDependent.Formula,
+ pipe_impedance=PipeImpedance.Formula,
+ temperature_dependence=TemperatureDependent.Formula)
+ quantity===nothing && return pairs(selected)
+ q=quantity isa Units.Quantity ? quantity : Commons.request_quantity(quantity)
+ series=q in (Units.quantity(Z),Units.quantity(R),Units.quantity(X),Units.quantity(L),
+ Units.quantity(Z,abs),Units.quantity(Z,angle))
+ shunt=q in (Units.quantity(Y),Units.quantity(G),Units.quantity(B),Units.quantity(C),
+ Units.quantity(Y,abs),Units.quantity(Y,angle))
+ series || shunt || throw(ArgumentError("no line-formulation selections for $q"))
+ omitted=series ? (:shunt_model,:insulation_admittance,:semicon_admittance,:earth_admittance) :
+ (:internal_impedance,:insulation_impedance,:earth_impedance,:pipe_impedance)
+ return pairs((; (key=>value for (key,value) in pairs(selected) if key ∉ omitted)...))
+end
+
+description(::Type{LineParametersFormulation},::Val{:internal_impedance}; compact::Bool=false) = "internal Z"
+description(::Type{LineParametersFormulation},::Val{:insulation_impedance}; compact::Bool=false) = "insulation Z"
+description(::Type{LineParametersFormulation},::Val{:earth_impedance}; compact::Bool=false) = "earth Z"
+description(::Type{LineParametersFormulation},::Val{:shunt_model}; compact::Bool=false) = "shunt geometry"
+description(::Type{LineParametersFormulation},::Val{:insulation_admittance}; compact::Bool=false) = "insulation Y"
+description(::Type{LineParametersFormulation},::Val{:semicon_admittance}; compact::Bool=false) = "semicon Y"
+description(::Type{LineParametersFormulation},::Val{:earth_admittance}; compact::Bool=false) = "earth Y"
+description(::Type{LineParametersFormulation},::Val{:earth_properties}; compact::Bool=false) = "soil law"
+description(::Type{LineParametersFormulation},::Val{:pipe_impedance}; compact::Bool=false) = "pipe Z"
+description(::Type{LineParametersFormulation},::Val{:temperature_dependence}; compact::Bool=false) = "temperature law"
+
+"""Expose typed children and controls without serializing the formulation."""
+Base.pairs(value::LineParametersFormulation; quantity=nothing) =
+ pairs(LineParametersFormulation,(methods=value.methods,requested=value.definitions,options=value.options.data);quantity)
+formulation_options(value::LineParametersFormulation) = value.options
+
+"""
+$(TYPEDSIGNATURES)
+
+Iterate owner-scoped selections from a formulation's declared child interface.
+`retained.methods` contains typed children.
+`retained.requested` contains their explicit controls. `pairs(owner; quantity)`
+owns child order and relevance. The empty route identifies the owner itself.
+"""
+function Base.pairs(::Type{LineParametersFormulation}, retained::NamedTuple;
+ quantity=nothing,owner=LineParametersFormulation)
+ # A selection pair contains passive explicit controls and excludes option inputs.
+ explicit = function (value)
+ record = value isa Union{FormulaDefinition, AbstractFormulation} ? NamedTuple(value) : value
+ record isa NamedTuple || return (;)
+ return (; (key => record[key] for key in (:parameters, :options, :equivalent_earth)
+ if haskey(record, key) && record[key] !== nothing &&
+ !(record[key] isa NamedTuple && isempty(record[key])))...)
+ end
+ entries=Pair{Tuple,Any}[(owner,()) => (owner => retained.options)]
+ for (slot,family) in pairs(owner;quantity)
+ selected=retained.methods[slot]
+ requested=retained.requested[slot]
+ if selected isa NamedTuple
+ children = pairs(family)
+ issubset(keys(selected), (key for (key,_) in children)) || throw(ArgumentError(
+ "retained $slot selections contain unknown owning formula slots"))
+ for (route,_) in children
+ haskey(selected, route) || continue
+ value=selected[route]
+ controls=explicit(requested isa NamedTuple ? requested[route] : requested)
+ push!(entries,(owner,(slot,route)) => (value===nothing || ismissing(value) ? value : value => controls))
+ end
+ else
+ controls=explicit(requested)
+ push!(entries,(owner,(slot,)) => (selected===nothing || ismissing(selected) ? selected : selected => controls))
+ end
+ end
+ return entries
+end
+
+function LineParametersFormulation(methods::NamedTuple, options::FormulationOptions)
+ LineParametersFormulation(methods, options, methods)
+end
+
+function _line_formulation(
+ internal_impedance,
+ insulation_impedance,
+ earth_impedance,
+ shunt_model,
+ insulation_admittance,
+ semicon_admittance,
+ earth_admittance,
+ earth_properties,
+ pipe_impedance,
+ temperature_dependence,
+ options::FormulationOptions
+)
+ selected = LineParametersFormulation((;
+ internal_impedance = InternalImpedance.Formula(internal_impedance),
+ insulation_impedance = InsulationImpedance.Formula(insulation_impedance),
+ earth_impedance = EarthImpedance.Formula(earth_impedance),
+ shunt_model = ShuntModel.Formula(shunt_model),
+ insulation_admittance = InsulationAdmittance.Formula(insulation_admittance),
+ semicon_admittance = SemiconAdmittance.Formula(semicon_admittance),
+ earth_admittance = EarthAdmittance.Formula(earth_admittance),
+ earth_properties = earth_properties === nothing ? nothing :
+ Earth.FrequencyDependent.Formula(earth_properties),
+ pipe_impedance = PipeImpedance.Formula(pipe_impedance),
+ temperature_dependence = temperature_dependence === nothing ? nothing :
+ TemperatureDependent.Formula(temperature_dependence)),
+ formulation_options(LineParametersFormulation, options))
+ definitions = (; internal_impedance, insulation_impedance,
+ earth_impedance = earth_impedance isa NamedTuple ?
+ NamedTuple{keys(selected.methods.earth_impedance)}(earth_impedance) : earth_impedance,
+ shunt_model, insulation_admittance, semicon_admittance,
+ earth_admittance = earth_admittance isa NamedTuple ?
+ NamedTuple{keys(selected.methods.earth_admittance)}(earth_admittance) : earth_admittance,
+ earth_properties, pipe_impedance, temperature_dependence)
+ return LineParametersFormulation(selected.methods, selected.options, definitions)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Select the complete physical-method bundle for a line-parameter computation.
+`Formulation(; kwargs...)` and `Formulation(:coaxial; kwargs...)` call it.
+
+`shunt_model=:default` (or `:equivalent`) selects the equivalent annular layer as local shunt
+geometry.
+`:boundary` explicitly computes a lossless wire and tape boundary correction.
+This choice is independent of `insulation_admittance` and `semicon_admittance`,
+which select material constitutive laws. Geometric boundary numerical controls and an
+explicit fallback belong to `formula(:boundary; options, parameters)`.
+
+`internal_impedance` accepts one formula. A solid primitive requests only its `outer`
+surface, and a tubular primitive all three. Before the frequency loop, the computation
+checks that the formula has an expression for the surface impedances that it requests.
+
+`earth_impedance` and `earth_admittance` each accept one formula or a NamedTuple
+with the required subset of `air`, `earth`, and `mixed` selections. On air and one
+homogeneous earth, these select `(s,t)=(1,1)`, `(2,2)`, and the two cross-layer mutual
+directions. Actual kind and source and target method dispatch governs equation
+applicability. Missing cases have no implicit fallback. A recipe describes a homogeneous
+earth, so each of its formulas stops at layer 2, and a multilayer formula is used alone. On
+an earth with more layers, the slot takes one equivalent earth: the explicit reduction that
+its formulas agree on, or the `:default` reduction. Its routes then resolve on layers 1 and
+2. Model parameters and numerical options remain local to each selected entry.
+
+`temperature_dependence=formula(:default)` selects the Materials-owned linear
+resistivity law. `nothing` retains reference resistivity. Operating temperature
+belongs to the problem. Reference temperature and coefficients belong to each
+material.
+
+Each formula slot and the complete `options` tuple accepts either one scalar
+selection or an explicit
+[`Grid`](@ref LineCableModels.ParametricBuilder.Grid)/
+[`Gridspace`](@ref LineCableModels.ParametricBuilder.Gridspace) source. Scalar
+inputs return one [`LineParametersFormulation`](@ref). Varying inputs return a
+`Gridspace{LineParametersFormulation}` whose points contain only completed,
+owner-resolved formula values. `:default` routes to an explicit implementation.
+The scalar problem supplies its geometry and earth context for validation.
+
+`combine=:product` forms the Cartesian product among varying fields in this
+formulation. `combine=:zip` aligns equally sized fields and broadcasts
+singletons. This composition is independent of the Cartesian product between
+problem points and formulation points performed by
+[`Combinatorial`](@ref LineCableModels.ParametricBuilder.Combinatorial).
+"""
+function LineParametersFormulation(;
+ internal_impedance = formula(:default),
+ insulation_impedance = formula(:default),
+ earth_impedance = formula(:default),
+ shunt_model = formula(:default),
+ insulation_admittance = formula(:default),
+ semicon_admittance = formula(:default),
+ earth_admittance = formula(:default),
+ earth_properties = formula(:default),
+ pipe_impedance = formula(:default),
+ temperature_dependence = formula(:default),
+ options = FormulationOptions(),
+ combine::Symbol = :product
+)
+ values = (
+ internal_impedance,
+ insulation_impedance,
+ earth_impedance,
+ shunt_model,
+ insulation_admittance,
+ semicon_admittance,
+ earth_admittance,
+ earth_properties,
+ pipe_impedance,
+ temperature_dependence,
+ options
+ )
+ return parameterize(
+ LineParametersFormulation,
+ (inputs...) -> _line_formulation(inputs[1:end-1]..., last(inputs) isa NamedTuple ? FormulationOptions(last(inputs)) : last(inputs)),
+ values;
+ combine
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the coaxial formulation, [`LineParametersFormulation`](@ref), from the same keywords.
+"""
+Formulation(; kwargs...) = LineParametersFormulation(; kwargs...)
+
+Formulation(::Val{:coaxial}; kwargs...) = LineParametersFormulation(; kwargs...)
+
+"""
+$(TYPEDSIGNATURES)
+
+Expose complete requested and resolved formula selections and reduction options.
+Selections retain scientific identity and data. Serialization belongs to the writer.
+"""
+function Base.NamedTuple(value::LineParametersFormulation)
+ # Copy record containers, not their scalar leaves: measurement sources and
+ # other immutable scientific values keep their identities and correlations.
+ copy_containers(value) = value isa Union{NamedTuple, Tuple, AbstractArray} ?
+ map(copy_containers, value) : value
+ record = function (selected)
+ selected === nothing && return nothing
+ selected isa Symbol && return NamedTuple(formula(selected))
+ selected isa NamedTuple && return map(record,selected)
+ return NamedTuple(selected)
+ end
+ # Retain complete records without specializing result containers on each
+ # parameter tuple or concrete selection type. The stored values remain unchanged.
+ Record=NamedTuple{(:backend,:requested,:methods,:options),
+ Tuple{Symbol,NamedTuple,NamedTuple,NamedTuple}}
+ return Record((:coaxial,copy_containers(map(record,value.definitions)),
+ copy_containers(map(record,value.methods)),copy_containers(value.options.data)))
+end
diff --git a/src/engine/reduction.jl b/src/engine/reduction.jl
deleted file mode 100644
index 8ceed9531..000000000
--- a/src/engine/reduction.jl
+++ /dev/null
@@ -1,150 +0,0 @@
-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/semiconadmittance/SemiconAdmittance.jl b/src/engine/semiconadmittance/SemiconAdmittance.jl
new file mode 100644
index 000000000..264d4a33f
--- /dev/null
+++ b/src/engine/semiconadmittance/SemiconAdmittance.jl
@@ -0,0 +1,46 @@
+"""
+ LineCableModels.Engine.SemiconAdmittance
+
+Define registered constitutive relations for semiconducting-screen admittance.
+
+# Dependencies
+
+$(IMPORTS)
+
+"""
+module SemiconAdmittance
+import ...Commons: FormulationOptions, formulas
+using ...Commons: Functor
+import ...Commons: formulation_options
+
+export Formula, formula_id, formulas
+
+#! explicit-imports: off
+using DocStringExtensions: IMPORTS, TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+#! explicit-imports: on
+import ..Engine: SemiconAdmittanceFormulation, formula_id, validate
+import ...LineCableModels: FormulaDefinition, Expression
+using ...Materials: Material
+#! explicit-imports: off
+import ..Engine: description, conductivity
+using ...Commons: vacuum_permittivity
+#! explicit-imports: on
+
+include("interface.jl")
+
+public semicon_material
+
+#! explicit-imports: off
+const FORMULAS = (
+ include("formulas/default.jl"),
+ include("formulas/lossless.jl"),
+ include("formulas/lossy.jl"),
+)
+#! explicit-imports: on
+
+"""
+Return the built-in semicon-admittance formula identifiers.
+"""
+formulas(::Type{<:Formula}) = FORMULAS
+
+end # module SemiconAdmittance
diff --git a/src/engine/semiconadmittance/formulas/default.jl b/src/engine/semiconadmittance/formulas/default.jl
new file mode 100644
index 000000000..c8a84fb5c
--- /dev/null
+++ b/src/engine/semiconadmittance/formulas/default.jl
@@ -0,0 +1,12 @@
+"""
+$(TYPEDSIGNATURES)
+
+Route the default selection to the explicit `:lossless` implementation.
+No numerical equation is owned by `:default`.
+"""
+description(::Type{<:Formula{:default}}; compact::Bool=false) =
+ compact ? "Default" : "Default routing to :lossless"
+
+Formula{:default}(; kwargs...) = Formula{:lossless}(; kwargs...)
+
+:default
diff --git a/src/engine/semiconadmittance/formulas/lossless.jl b/src/engine/semiconadmittance/formulas/lossless.jl
new file mode 100644
index 000000000..dd0870d61
--- /dev/null
+++ b/src/engine/semiconadmittance/formulas/lossless.jl
@@ -0,0 +1,52 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Lossless semiconducting-screen approximation retaining
+displacement current while suppressing conduction and polarization loss.
+
+**Expression.**
+
+```math
+\\kappa=j\\omega\\varepsilon_0\\varepsilon_r.
+```
+
+This is the lossless specialization of the standard frequency-domain
+constitutive relation.
+"""
+function description(::Type{<:Formula{:lossless}}; compact::Bool=false)
+ compact ? "Lossless" : "Lossless semiconducting-screen admittivity"
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate lossless semiconducting-screen admittivity:
+
+```math
+\\kappa=j\\omega\\varepsilon_0\\varepsilon_r.
+```
+
+# Arguments
+
+- `functor`: the Functor of the evaluation point. Its input holds:
+ - `material`: semiconducting material and relative permittivity.
+ - `frequency`: evaluation frequency \\[Hz\\].
+ - `temperature`: operating temperature \\[°C\\].
+ - `options`: the normalized formulation options of the formula.
+- `workspace`: optional computation workspace supplying reusable numerical buffers.
+
+# Returns
+
+- Complex lossless admittivity \\[S/m\\].
+"""
+@inline function semicon_material(::Formula{:lossless}, functor, workspace)
+ (; material, frequency) = functor.input
+ T = typeof(frequency)
+ ε₀ = vacuum_permittivity(T)
+ ω = 2 * (one(T) * π) * frequency
+ return complex(zero(T), ω) * ε₀ * material.eps_r
+end
+
+formulation_options(::Expression{<:Formula{:lossless}, typeof(semicon_material)}) = FormulationOptions()
+
+:lossless
diff --git a/src/engine/semiconadmittance/formulas/lossy.jl b/src/engine/semiconadmittance/formulas/lossy.jl
new file mode 100644
index 000000000..872a42bb7
--- /dev/null
+++ b/src/engine/semiconadmittance/formulas/lossy.jl
@@ -0,0 +1,73 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Generic lossy complex-admittivity representation of a
+homogeneous semiconducting screen. Ohmic conduction, dielectric displacement,
+and an optional polarization-loss contribution are evaluated together.
+
+**Expression.** For resistivity ``\\rho``, real relative permittivity
+``\\varepsilon_r``, and polarization loss tangent ``\\tan\\delta_p``,
+
+```math
+\\kappa=\\frac{1}{\\rho}+\\omega\\varepsilon_0\\varepsilon_r\\tan\\delta_p+
+j\\omega\\varepsilon_0\\varepsilon_r.
+```
+
+The annular layer operator converts this material admittivity to
+``Y=2\\pi\\kappa/\\ln(b/a)``. This is the standard frequency-domain
+constitutive relation, not an author-specific empirical law. Ametani,
+Miyamoto, and Nagaoka (2004), Eqs. (14)-(15), remain a useful application
+reference for its zero-``\\tan\\delta_p`` semiconducting-screen specialization
+and radial series assembly.
+"""
+function description(::Type{<:Formula{:lossy}}; compact::Bool=false)
+ compact ? "Lossy" : "Lossy complex-admittivity semiconducting-screen model"
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate the standard lossy material admittivity for a semiconducting layer:
+
+```math
+\\kappa=\\frac{1}{\\rho}+\\omega\\varepsilon_0\\varepsilon_r\\tan\\delta_p+
+j\\omega\\varepsilon_0\\varepsilon_r.
+```
+
+`material.tan_delta` represents polarization loss only. Conduction is supplied
+by `material.rho`. The common coaxial operator applies the annular geometry.
+
+# Arguments
+
+- `functor`: the Functor of the evaluation point. Its input holds:
+ - `material`: semiconducting material properties, including resistivity and
+ relative permittivity.
+ - `frequency`: evaluation frequency \\[Hz\\].
+ - `temperature`: operating temperature \\[°C\\].
+ - `options`: the normalized formulation options of the formula.
+- `workspace`: optional computation workspace supplying reusable numerical buffers.
+
+# Returns
+
+- Complex screen admittivity \\[S/m\\].
+
+# Notes
+
+Ametani, Miyamoto, and Nagaoka (2004), DOI
+10.1109/TPWRD.2003.822502, is retained as an application reference for the
+semiconducting-screen specialization. The constitutive relation itself is
+standard frequency-domain electromagnetism.
+"""
+@inline function semicon_material(::Formula{:lossy}, functor, workspace)
+ (; material, frequency) = functor.input
+ T = typeof(frequency)
+ ε₀ = vacuum_permittivity(T)
+ ω = 2 * (one(T) * π) * frequency
+ displacement = complex(zero(T), ω) * ε₀ * material.eps_r
+ return conductivity(material.rho) + imag(displacement) * material.tan_delta +
+ displacement
+end
+
+formulation_options(::Expression{<:Formula{:lossy}, typeof(semicon_material)}) = FormulationOptions()
+
+:lossy
diff --git a/src/engine/semiconadmittance/interface.jl b/src/engine/semiconadmittance/interface.jl
new file mode 100644
index 000000000..818e047e8
--- /dev/null
+++ b/src/engine/semiconadmittance/interface.jl
@@ -0,0 +1,101 @@
+"""
+$(TYPEDEF)
+
+Select one semiconducting-screen constitutive relation by its stable literature
+identifier.
+
+Each formula implements `semicon_material(selected, functor, workspace)` on its concrete
+selection type. The input of `functor` holds the material, the frequency, the temperature
+and the options. The method returns the material's frequency-evaluated admittivity [S/m]. Geometry and
+radial series aggregation remain common Engine operations.
+
+$(TYPEDFIELDS)
+"""
+struct Formula{ID, P <: NamedTuple, O <: FormulationOptions} <: SemiconAdmittanceFormulation
+ "Resolved physical model parameters."
+ parameters::P
+ "Normalized numerical sections for this equation."
+ options::O
+end
+
+"""
+Evaluate one formula-owned semiconducting-material constitutive relation.
+"""
+function semicon_material end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct a selected formulation with model parameters and numerical controls.
+Custom formulations extend `semicon_material` on their own concrete selection type.
+Unknown controls fail before numerical evaluation.
+"""
+function Formula{ID}(; parameters::NamedTuple=(;), options::Union{NamedTuple, FormulationOptions} = FormulationOptions()) where {ID}
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ isempty(parameters) || throw(ArgumentError("formula :$ID has no configurable model parameters"))
+ selected = Formula{ID, typeof(parameters), typeof(options)}(parameters, options)
+ expression = Expression(selected, semicon_material)
+ normalized = formulation_options(expression, options)
+ return Formula{ID, typeof(parameters), typeof(normalized)}(parameters, normalized)
+end
+
+@inline function (formula::SemiconAdmittanceFormulation)(
+ material::Material{T},
+ frequency::T,
+ temperature::T; workspace = nothing
+) where {T <: Real}
+ functor = Functor(formula,
+ (; material, frequency, temperature, options = formula.options); workspace)
+ value = Expression(formula, semicon_material)(functor, workspace)
+ return convert(Complex{T}, validate(value, formula, T))
+end
+
+function (formula::SemiconAdmittanceFormulation)(
+ material::Material{T},
+ frequency::Real,
+ temperature::Real; workspace = nothing
+) where {T <: Real}
+ U = promote_type(
+ T,
+ typeof(float(frequency)),
+ typeof(float(temperature))
+ )
+ return formula(
+ convert(Material{U}, material),
+ convert(U, float(frequency)),
+ convert(U, float(temperature)); workspace
+ )
+end
+
+Formula(identifier::Symbol; kwargs...) = Formula(Val(identifier); kwargs...)
+Formula(::Val{ID}; kwargs...) where {ID} = Formula{ID}(; kwargs...)
+Formula(selected::SemiconAdmittanceFormulation) = selected
+
+function Formula(selection::FormulaDefinition{ID, Order}) where {ID, Order}
+ Order === :default || throw(ArgumentError("order applies only to equivalent_earth"))
+ selection.equivalent_earth === nothing || throw(ArgumentError(
+ "equivalent_earth applies only to external earth formulas"))
+ return Formula{ID}(; parameters = selection.parameters,
+ options = selection.options)
+end
+
+"""
+Return the stable identifier of a semicon-admittance formula.
+"""
+formula_id(::Formula{ID}) where {ID} = ID
+formula_id(::Type{<:Formula{ID}}) where {ID} = ID
+# Identity-only dispatch also describes retained selections without constructors.
+description(value::Formula; compact::Bool = false) = description(typeof(value); compact)
+formulation_options(value::Formula) = value.options
+
+"""
+$(TYPEDSIGNATURES)
+
+Expose the selected identity, model parameters, and numerical options as a native record.
+"""
+function Base.NamedTuple(value::Formula)
+ return (identifier=formula_id(value), parameters=value.parameters, options=value.options.data)
+end
+
+"""Iterate the independently selectable child slots admitted by this formula family."""
+Base.pairs(::Type{<:Formula}; quantity=nothing) = pairs((;))
diff --git a/src/engine/shuntmodel/ShuntModel.jl b/src/engine/shuntmodel/ShuntModel.jl
new file mode 100644
index 000000000..e6a929b81
--- /dev/null
+++ b/src/engine/shuntmodel/ShuntModel.jl
@@ -0,0 +1,44 @@
+"""
+ LineCableModels.Engine.ShuntModel
+
+Select the cable-local shunt geometry model independently of dielectric
+constitutive laws. The equivalent annular layer is the default. The geometric boundary
+approximation resolves eligible open wires and tapes inside a closed circular shield.
+
+# Dependencies
+
+$(IMPORTS)
+"""
+module ShuntModel
+import ...Commons: FormulationOptions, formulas
+using DocStringExtensions: TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+#! explicit-imports: off
+# Expanded in the module docstring, outside this module's analyzed expressions.
+using DocStringExtensions: IMPORTS
+#! explicit-imports: on
+import ..Engine: ShuntModelFormulation
+import ...LineCableModels: FormulaDefinition, formula_id, description
+import ...Commons: formulation_options, initialize_buffers
+using ...Commons: vacuum_permittivity
+import ...Materials: Material
+import ...DataModel: CableDesign
+import ...DataModel
+import ...TextDisplay
+import ..Engine: CableBlueprint, InternalShuntBlock, internal_shunt_response,
+ blueprint_dependencies, same_physical_state, numerical_magnitude
+import ..Engine: InsulationAdmittance, SemiconAdmittance
+import ...LineCableModels: nominal
+using LinearAlgebra: eigvals!, norm, I, lu, mul!, qr!, ColumnNorm, eigen,
+ SymTridiagonal, UpperTriangular
+using QuadGK: alloc_segbuf, quadgk!
+import SpecialFunctions
+
+export Formula, formulas
+
+include("interface.jl")
+include("geometry.jl")
+include("charge_collocation.jl")
+include("blueprint.jl")
+
+public BoundarySolveError
+end
diff --git a/src/engine/shuntmodel/blueprint.jl b/src/engine/shuntmodel/blueprint.jl
new file mode 100644
index 000000000..b4807b9e0
--- /dev/null
+++ b/src/engine/shuntmodel/blueprint.jl
@@ -0,0 +1,85 @@
+# Boundary coefficients are completed while constructing coaxial blueprints.
+# The solved-domain inventory lives only for this flattening call.
+const ShuntDomainReport = NamedTuple{
+ (:design, :terminals, :requested, :effective, :reason, :message),
+ Tuple{Int, UnitRange{Int}, Symbol, Symbol, Symbol, String}}
+
+# Each shunt model selects the formulations needed to construct its blueprint.
+# The equivalent annular layer model omits the dielectric selections handled during the
+# solve.
+function blueprint_dependencies(::ShuntModelFormulation, methods)
+ methods[(:shunt_model, :insulation_admittance, :semicon_admittance)]
+end
+blueprint_dependencies(::Formula{:equivalent}, methods) = methods[(:shunt_model,)]
+
+function internal_shunt_response(selected::ShuntModelFormulation, design,
+ geometry, T, methods, solutions, design_index)
+ domains=internal_shunt_domains(design, geometry, T; design_index)
+ return internal_shunt_response(selected, domains, methods, solutions)
+end
+
+function internal_shunt_response(
+ selected::Formula{:equivalent},
+ design::CableDesign, geometry, ::Type{T}, methods, solutions, design_index) where {T}
+ requested = formula_id(selected)
+ reports = ShuntDomainReport[(design_index, indices, requested,
+ :equivalent, :selected,
+ "Equivalent annular layers; no boundary extraction or audit")
+ for indices in geometry.assembly_ranges]
+ return (blocks = InternalShuntBlock{T}[],
+ details =
+ (requested, effective = :equivalent, solves = 0, domains = reports,
+ diagnostics = InternalShuntDiagnostic[]))
+end
+
+function internal_shunt_response(selected::Formula{:boundary},
+ domains::Vector{InternalShuntDomain{T}}, methods, solutions) where {T}
+ blocks = InternalShuntBlock{T}[]
+ diagnostics = InternalShuntDiagnostic[]
+ reports = ShuntDomainReport[]
+ for domain in domains
+ try
+ _shunt_lossless(methods) || throw(BoundarySolveError(:unsupported,
+ (; design = domain.design, terminals = domain.terminals),
+ "boundary shunt requires the built-in lossless dielectric laws"))
+ previous = findfirst(
+ value -> isequal(value.methods, methods) &&
+ _shunt_domain_equal(value.domain, domain),
+ solutions)
+ if previous === nothing
+ # The admitted lossless laws are independent of frequency and
+ # temperature. Their dielectric descriptors retain UQ sources.
+ result = internal_shunt_response(
+ selected, _shunt_values(domain, methods), domain;
+ level = selected.options.data.resolution,
+ integration = selected.options.data.integration, audit = selected.options.data.audit)
+ C = Matrix{T}(result.C)
+ P = lu(C) \ Matrix{T}(I, size(C, 1), size(C, 1))
+ diagnostic = result.diagnostic
+ push!(solutions, (; domain, methods, C, P, diagnostic))
+ else
+ source = solutions[previous]
+ C, P, diagnostic = source.C, source.P, source.diagnostic
+ end
+ push!(blocks, InternalShuntBlock(domain.assembly, domain.terminals, C, P))
+ push!(diagnostics, diagnostic)
+ push!(reports, (
+ domain.design, domain.terminals, :boundary, :boundary, :resolved, ""))
+ catch exception
+ exception isa BoundarySolveError || rethrow()
+ selected.parameters.fallback === :equivalent || rethrow()
+ @warn "Boundary shunt replaced by the equivalent annular layer" design=domain.design terminals=domain.terminals reason=exception.category
+ push!(reports,
+ (domain.design, domain.terminals, :boundary,
+ :equivalent, exception.category, sprint(showerror, exception)))
+ end
+ end
+ solved = Base.IdSet{Matrix{T}}()
+ foreach(block -> push!(solved, block.C), blocks)
+ effective = isempty(reports) ? :equivalent :
+ all(r -> r.effective === :boundary, reports) ? :boundary :
+ all(r -> r.effective === :equivalent, reports) ? :equivalent : :mixed
+ return (blocks,
+ details = (requested = :boundary, effective,
+ solves = length(solved), domains = reports, diagnostics))
+end
diff --git a/src/engine/shuntmodel/charge_collocation.jl b/src/engine/shuntmodel/charge_collocation.jl
new file mode 100644
index 000000000..5221ccbfd
--- /dev/null
+++ b/src/engine/shuntmodel/charge_collocation.jl
@@ -0,0 +1,1075 @@
+# Integrated finite-face charge element for the explicit boundary shunt model.
+# Coordinates are in m. Green functions use charge/(2pi*epsilon0).
+# Dense numerical arrays are local to a boundary solve, never a global cache.
+
+"""
+$(TYPEDEF)
+
+Report a recognized failure of the local geometric boundary approximation. Numerical
+failure is distinct from invalid physical input and must not trigger Monte
+Carlo rejection and resampling.
+
+$(TYPEDFIELDS)
+"""
+struct BoundarySolveError <: Exception
+ "Failure stage or unsupported model assumption."
+ category::Symbol
+ "Physical location and available numerical diagnostics."
+ context::NamedTuple
+ "Explanation of the failure."
+ message::String
+end
+
+function Base.showerror(io::IO, error::BoundarySolveError)
+ print(io, "BoundarySolveError(", error.category, "): ", error.message)
+ isempty(error.context) || print(io, "; ", error.context)
+end
+Base.show(io::IO, error::BoundarySolveError) = showerror(io, error)
+function Base.summary(io::IO, error::BoundarySolveError)
+ print(io, "BoundarySolveError(", error.category, ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", error::BoundarySolveError)
+ get(io, :compact, false) && return summary(io, error)
+ TextDisplay.tree(io,
+ sprint(summary, error),
+ (
+ (label = error.message, noun = "details"),
+ (label = sprint(show, error.context; context = :limit => true),
+ noun = "details")
+ );
+ noun = "details")
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Propagate the radial Dirichlet-to-Neumann load of one positive angular mode
+through concentric dielectric layers. In log-radius coordinates, the load
+transformation is the admittance form of the uniform-line input-impedance
+relation [Sunde1968](@cite), Section 1.6, Eqs. (1.39)-(1.42), p. 15:
+
+```math
+Y_{\\mathrm{in}}=Y_c\\frac{Y_L+Y_c\\tanh(m\\ell)}
+{Y_c+Y_L\\tanh(m\\ell)},\\qquad
+Y_c=\\varepsilon_r m,\\qquad\\ell=\\log(r_o/r_i).
+```
+
+Here the loads ``Y_L,Y_c,Y_{\\mathrm{in}}`` are dimensionless modal loads,
+not electrical admittances. The recursion starts with ``Y_L=\\infty`` at
+grounded metal. Applying the line relation to cylindrical Laplace modes is
+the log-radius construction used here, not a cable propagation calculation.
+
+# Arguments
+
+- `layers`: radially ordered layers with inner and outer radii \\[m\\] and
+ positive relative permittivities \\[dimensionless\\].
+- `m`: positive angular mode order.
+
+# Keywords
+
+- `reverse_layers=false`: reverse the layer order to propagate from the
+ outer grounded shield toward the host instead of from the inner conductor.
+
+# Returns
+
+- Modal load seen at the host boundary \\[dimensionless\\]. `Inf` when the
+ layer sequence is empty, representing an immediately adjacent metal boundary.
+"""
+function _shunt_load(layers, m; reverse_layers = false)
+ load = Inf
+ for layer in (reverse_layers ? Iterators.reverse(layers) : layers)
+ t = tanh(m * log(layer.ro/layer.ri))
+ characteristic = layer.epsilon * m
+ load = isinf(load) ? characteristic/t :
+ characteristic * (load + characteristic*t)/(characteristic + load*t)
+ end
+ load
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate the reflected Fourier modes of the local electrostatic Green function
+in a concentric layered annulus. The radial basis ``r^{\\pm m}``, with
+``1,\\log r`` for the zero mode, is the classical cylindrical Laplace basis.
+See [Schelkunoff1934](@cite), Eqs. (122)-(124), p. 573, in the small-radius-to-
+wavelength limit of cylindrical fields. Here it is used for the electrostatic
+potential. The layered-kernel strategy is also supported by [Bernal1997](@cite),
+Sections II-III, whose planar Prony/matrix-pencil kernel is not used here.
+
+With ``t=\\log(r/a)``, each source-free dielectric layer satisfies
+``d^2u_m/dt^2-m^2u_m=0``. Matching potential and normal displacement gives
+the radial load recursion in `_shunt_load`, terminated at grounded metal.
+The zero Fourier mode is evaluated separately in `_shunt_kernel`:
+
+```math
+G_0(r,s)=\\frac{R_< (R_{\\mathrm{total}}-R_>)}{R_{\\mathrm{total}}},
+\\qquad R(r)=R_{\\mathrm{left}}+\\frac{\\log(r/a)}{\\varepsilon_h}.
+```
+
+Here ``R_<`` and ``R_>`` are the smaller and larger of ``R(r),R(s)``.
+The dielectric log-radius sums ``R`` and host relative permittivity
+``\\varepsilon_h`` are dimensionless. The log-radius normalization follows
+the coaxial capacitance ``C=2\\pi\\varepsilon/\\log(r_o/r_i)`` \\[F/m\\], with
+absolute permittivity ``\\varepsilon`` \\[F/m\\]. See [Sunde1968](@cite),
+Section 1.5, Eq. (1.20), p. 11. Direct and nearest-interface logarithms
+are extracted analytically. Only the remaining reflections are truncated.
+For an adjacent dielectric, the extracted contrast is
+``(\\varepsilon_h-\\varepsilon_j)/(\\varepsilon_h+\\varepsilon_j)``, also used
+in [Campione2018](@cite), Section 2, Eq. (3). That paper uses a local planar
+approximation to the braid geometry. It supports these dielectric image
+factors, not this concentric annular Green function or its radial load recursion.
+
+# Arguments
+
+- `g`: host radii and layer boundaries \\[m\\], with positive relative
+ permittivities \\[dimensionless\\] and dielectric log-radius sums.
+- `modes`: number of retained positive Fourier modes.
+
+# Returns
+
+- `A`, `B`, `D`: smooth Fourier reflection coefficients \\[dimensionless\\].
+- `ra0`, `rb0`: reflection coefficients at the nearest inner and outer interfaces
+ \\[dimensionless\\]. The resulting kernel multiplies charge per unit length
+ divided by ``2\\pi\\varepsilon_0`` \\[V\\] to give potential \\[V\\].
+"""
+function _shunt_kernel_coefficients(g, modes)
+ A, B, D = zeros(modes), zeros(modes), zeros(modes)
+ ra0 = isempty(g.left) ? -1.0 :
+ (g.epsilon-last(g.left).epsilon)/(g.epsilon+last(g.left).epsilon)
+ rb0 = isempty(g.right) ? -1.0 :
+ (g.epsilon-first(g.right).epsilon)/(g.epsilon+first(g.right).epsilon)
+ for m in 1:modes
+ l = _shunt_load(g.left, m)/g.epsilon
+ r = _shunt_load(g.right, m; reverse_layers = true)/g.epsilon
+ ra = isinf(l) ? -1.0 : (m-l)/(m+l)
+ rb = isinf(r) ? -1.0 : (m-r)/(m+r)
+ denominator = g.epsilon*m*(1-ra*rb*(g.a/g.b)^(2m))
+ A[m] = ra/denominator-ra0/(g.epsilon*m)
+ B[m] = rb/denominator-rb0/(g.epsilon*m)
+ D[m] = ra*rb/denominator
+ end
+ (; A, B, D, ra0, rb0)
+end
+
+function _shunt_kernel(z, source, g, k; regular = false, split_images = false)
+ r, s = abs(z), abs(source)
+ Rr = g.Rleft + log(r/g.a)/g.epsilon
+ Rs = g.Rleft + log(s/g.a)/g.epsilon
+ result = min(Rr, Rs)*(g.Rtotal-max(Rr, Rs))/g.Rtotal
+ if split_images
+ result += (log(max(r, s))+k.ra0*log(s)+k.rb0*(2log(g.b)-log(r)))/g.epsilon
+ else
+ result += (regular ? log(max(r, s)) : log(max(r, s)/abs(z-source)))/g.epsilon
+ result -= k.ra0/g.epsilon*log(abs(1-g.a^2/(conj(z)*source)))
+ result -= k.rb0/g.epsilon*log(abs(1-z*conj(source)/g.b^2))
+ end
+ qa, qb = g.a^2/(r*s), r*s/g.b^2
+ qd = (g.a/g.b)^2
+ qc, qe = qd*r/s, qd*s/r
+ pa, pb, pc, pe = qa, qb, qc, qe
+ cosine = real(z*conj(source))/(r*s)
+ previous, current = 1.0, cosine
+ @inbounds for m in eachindex(k.A)
+ result += current*(k.A[m]*pa + k.B[m]*pb + k.D[m]*(pc+pe))
+ previous, current = current, 2cosine*current-previous
+ pa *= qa
+ pb *= qb
+ pc *= qc
+ pe *= qe
+ end
+ result
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Extend `buffers` with the kernel storage of one shunt assembly. It holds `plan.rows`
+targets, `plan.columns` sources, Fourier blocks of at most 64 of the `plan.modes` modes,
+and tape kernels with `plan.quadrature` nodes per face.
+"""
+function initialize_buffers(::Formula{:boundary}, ::Val{:kernel}, ::Type{T}, input, plan,
+ buffers) where {T}
+ (; rows, columns, modes, quadrature) = plan
+ return merge(buffers,
+ (ta = Vector{Complex{T}}(undef, rows), tb = Vector{Complex{T}}(undef, rows),
+ sa = Vector{Complex{T}}(undef, columns), sb = Vector{Complex{T}}(undef, columns),
+ tpa = Vector{Complex{T}}(undef, rows), tpb = Vector{Complex{T}}(undef, rows),
+ spa = Vector{Complex{T}}(undef, columns),
+ spb = Vector{Complex{T}}(undef, columns),
+ U = Matrix{T}(undef, rows, 4min(64, modes)),
+ V = Matrix{T}(undef, columns, 4min(64, modes)),
+ tape = Matrix{T}(undef, rows, quadrature)))
+end
+
+function _shunt_kernel_matrix!(matrix, targets, sources, g, k; regular = false,
+ split_images = false, buffers)
+ # Same Green function, batched into small Fourier blocks for BLAS. No dense
+ # N-by-modes cache: working storage is only 4*64 columns per geometric boundary set.
+ zero_modes = merge(k, (; A = (), B = (), D = ()))
+ @inbounds for j in eachindex(sources), i in eachindex(targets)
+
+ matrix[i, j] = _shunt_kernel(
+ targets[i], sources[j], g, zero_modes; regular, split_images)
+ end
+ nr, nc = length(targets), length(sources)
+ ta, tb = @view(buffers.ta[1:nr]), @view(buffers.tb[1:nr])
+ sa, sb = @view(buffers.sa[1:nc]), @view(buffers.sb[1:nc])
+ tpa, tpb = @view(buffers.tpa[1:nr]), @view(buffers.tpb[1:nr])
+ spa, spb = @view(buffers.spa[1:nc]), @view(buffers.spb[1:nc])
+ ta .= g.a ./ conj.(targets);
+ tb .= targets ./ g.b
+ sa .= g.a ./ conj.(sources);
+ sb .= sources ./ g.b
+ fill!(tpa, 1);
+ fill!(tpb, 1);
+ fill!(spa, 1);
+ fill!(spb, 1)
+ U, V = @view(buffers.U[1:nr, :]), @view(buffers.V[1:nc, :])
+ for first_mode in 1:64:length(k.A)
+ modes = first_mode:min(first_mode + 63, length(k.A))
+ for (column, m) in enumerate(modes)
+ tpa .*= ta
+ tpb .*= tb
+ spa .*= sa
+ spb .*= sb
+ cross = k.D[m]*(g.a/g.b)^m
+ @inbounds for i in eachindex(targets)
+ U[i, 4column - 3]=real(tpa[i])
+ U[i, 4column - 2]=imag(tpa[i])
+ U[i, 4column - 1]=real(tpb[i])
+ U[i, 4column]=imag(tpb[i])
+ end
+ @inbounds for j in eachindex(sources)
+ a=k.A[m]*spa[j]+cross*spb[j]
+ b=k.B[m]*spb[j]+cross*spa[j]
+ V[j, 4column - 3]=real(a)
+ V[j, 4column - 2]=imag(a)
+ V[j, 4column - 1]=real(b)
+ V[j, 4column]=imag(b)
+ end
+ end
+ @views mul!(
+ matrix, U[:, 1:4length(modes)], transpose(V[:, 1:4length(modes)]), 1.0, 1.0)
+ end
+ matrix
+end
+
+function _shunt_kernel_matrix(selected::Formula{:boundary}, targets, sources, g, k;
+ kwargs...)
+ buffers = initialize_buffers(selected, Val(:kernel), Float64, nothing,
+ (rows = length(targets), columns = length(sources), modes = length(k.A),
+ quadrature = 0), (;))
+ return _shunt_kernel_matrix!(Matrix{Float64}(undef, length(targets), length(sources)),
+ targets, sources, g, k; buffers, kwargs...)
+end
+
+_shunt_core_voltage(z, g) = 1-(g.Rleft+log(abs(z)/g.a)/g.epsilon)/g.Rtotal
+
+# Round wires retain their existing auxiliary-source treatment. The tape has
+# no source contour or inset: its physical faces are integrated below.
+function _shunt_points(g, nw, fraction; shift = 0.0)
+ targets, sources = ComplexF64[], ComplexF64[]
+ for wire in g.wires
+ center = complex(wire.x, wire.y)
+ for j in 0:(nw - 1)
+ push!(targets, center + wire.r*cis(2pi*(j+shift)/nw))
+ push!(sources, center + fraction*wire.r*cis(2pi*j/nw))
+ end
+ end
+ (; targets, sources)
+end
+
+function _shunt_jacobi!(values, t, alpha, beta)
+ p = length(values)-1
+ values[1] = 1
+ p == 0 && return values
+ values[2] = ((alpha+beta+2)*t+alpha-beta)/2
+ s = alpha+beta
+ for n in 1:(p - 1)
+ A = (2n+s+1)*(2n+s+2)/(2(n+1)*(n+s+1))
+ B = (alpha^2-beta^2)*(2n+s+1)/(2(n+1)*(n+s+1)*(2n+s))
+ C = (n+alpha)*(n+beta)*(2n+s+2)/((n+1)*(n+s+1)*(2n+s))
+ values[n + 2] = (A*t+B)*values[n + 1]-C*values[n]
+ end
+ values
+end
+
+function _shunt_jacobi(t, alpha, beta, p)
+ _shunt_jacobi!(Vector{Float64}(undef, p+1), t, alpha, beta)
+end
+
+function _shunt_gauss_jacobi(n, alpha, beta)
+ alpha > -1 && beta > -1 && n > 1 || error("Invalid Jacobi rule.")
+ s = alpha+beta
+ diagonal = [(beta-alpha)/(s+2);
+ [(beta^2-alpha^2)/((2k+s)*(2k+s+2)) for k in 1:(n - 1)]]
+ off = [2/(s+2)*sqrt((1+alpha)*(1+beta)/(s+3));
+ [2/(2k+s)*sqrt(k*(k+alpha)*(k+beta)*(k+s) /
+ ((2k+s-1)*(2k+s+1))) for k in 2:(n - 1)]]
+ decomposition = eigen(SymTridiagonal(diagonal, off))
+ # Normalized measure w(t)dt / integral(w). No arclength factor belongs here.
+ decomposition.values, vec(decomposition.vectors[1, :] .^ 2)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Find the leading electrostatic exponent at a right-angle metal corner on a
+dielectric interface. The host occupies angle ``\\pi/2`` and the adjacent
+dielectric angle ``\\pi``. Continuity of potential and normal displacement,
+with constant potential on the two metal faces, gives this specialization:
+
+```math
+\\varepsilon_h\\cos(\\nu\\pi/2)\\sin(\\nu\\pi)
++\\varepsilon_o\\sin(\\nu\\pi/2)\\cos(\\nu\\pi)=0.
+```
+
+The smallest root ``0<\\nu<1`` gives potential relative to the metal proportional
+to ``\\rho^\\nu`` and surface charge density proportional to ``\\rho^{\\nu-1}``,
+where ``\\rho`` is distance from the corner \\[m\\]. The admissible branch
+obeys Meixner's finite-energy edge condition [Meixner1972](@cite).
+Material-dependent metal-dielectric wedge singularities, rather than a
+universal homogeneous exponent, are treated by [VanBladel1985](@cite).
+For equal permittivities this equation recovers ``\\nu=2/3``.
+
+# Arguments
+
+- `epsilon_host`: host relative permittivity \\[dimensionless\\].
+- `epsilon_outer`: adjacent dielectric relative permittivity \\[dimensionless\\].
+
+# Returns
+
+- Leading exponent ``\\nu`` \\[dimensionless\\], found by bisection excluding
+ the trivial zero root. This is the specified two-dielectric corner, not a
+ general wedge solver.
+
+# Errors
+
+Nonpositive permittivities or an unbracketed root raise `ErrorException`.
+"""
+function _shunt_junction_exponent(epsilon_host, epsilon_outer)
+ epsilon_host > 0 && epsilon_outer > 0 ||
+ error("Positive dielectric permittivities required.")
+ # Host sector pi/2, outer dielectric sector pi. Determinant has no cotangent
+ # poles. Exclude the trivial nu=0 root and bracket the first positive root.
+ determinant(nu) = epsilon_host*cospi(nu/2)*sinpi(nu) +
+ epsilon_outer*sinpi(nu/2)*cospi(nu)
+ left, right = 1e-10, 1.0
+ determinant(left) > 0 && determinant(right) < 0 ||
+ error("internal shunt: dielectric corner root is not bracketed")
+ for _ in 1:60
+ mid = (left+right)/2
+ if determinant(mid) > 0
+ left = mid
+ else
+ right = mid
+ end
+ end
+ (left+right)/2
+end
+
+function _shunt_face_point(face, t)
+ face.kind == :arc ?
+ face.radius*cis(face.phi+face.span*t/2) :
+ (face.mid+face.half*t)*cis(face.phi)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build whole-face charge expansions on the tape's two circular arcs and two
+radial ends, retaining its physical thickness. The finite-face precedent is
+[Bernal1997](@cite), Section IV, Eq. (10), which uses Maxwell-weighted
+Chebyshev functions. This implementation instead uses material-dependent
+Jacobi weights, with exponents from `_shunt_junction_exponent` at dielectric
+interfaces and ``\\nu=2/3`` at homogeneous right-angle corners:
+
+```math
+d\\widehat q(t)=\\sum_{n=0}^{p} a_n P_n^{(\\alpha,\\beta)}(t)\\,d\\mu(t),
+\\qquad
+d\\mu(t)=\\frac{(1-t)^\\alpha(1+t)^\\beta\\,dt}
+{2^{\\alpha+\\beta+1}\\mathrm{B}(\\alpha+1,\\beta+1)},
+\\qquad \\alpha=\\nu_+-1,\\quad\\beta=\\nu_--1.
+```
+
+Here ``t\\in[-1,1]`` is the face parameter, ``\\nu_\\pm`` are its endpoint
+exponents, and ``P_n`` are Jacobi polynomials. The normalized charge measure
+``d\\widehat q=dq/(2\\pi\\varepsilon_0)`` and coefficients ``a_n`` have units
+\\[V\\]. ``dq`` is charge per cable length \\[C/m\\]. Each face's total charge
+is ``2\\pi\\varepsilon_0 a_0``. Do not multiply this measure by a second
+arclength Jacobian. Encoding known singularities in the approximation space
+is also supported by [Classen2011](@cite), Sections 2 and 4. Their FIT/DG
+discretizations are not the boundary-integral element implemented here.
+
+# Arguments
+
+- `g`: host and layer radii \\[m\\] and relative permittivities \\[dimensionless\\].
+- `s`: tape geometry and terminal index. The inner and outer radii are in
+ \\[m\\], and the center angle and angular span are in \\[rad\\].
+- `p`: highest Jacobi degree on each face.
+- `quadrature`: number of normalized Gauss–Jacobi nodes per face.
+
+# Returns
+
+- 4 face records with physical coordinates \\[m\\], endpoint exponents,
+ quadrature weights and weighted polynomial values.
+
+# Errors
+
+Insufficient quadrature order or tape contact with a bounding conductor
+raises `ErrorException`.
+"""
+function _shunt_tape_faces(g, s, p, quadrature)
+ p >= 0 && quadrature >= max(2, p+1) || error("Quadrature needs at least order+1 nodes.")
+ at_interface = isapprox(s.ro, g.b; atol = 64eps(Float64)*g.b, rtol = 0)
+ at_inner = isapprox(s.ri, g.a; atol = 64eps(Float64)*g.b, rtol = 0)
+ at_inner && isempty(g.left) &&
+ error("internal shunt: tape contacts the inner conductor")
+ nu_inner = at_inner ? _shunt_junction_exponent(g.epsilon, last(g.left).epsilon) : 2/3
+ # A metal face coincident with the reference shield is not an open tape.
+ at_interface && isempty(g.right) &&
+ error("internal shunt: tape contacts the reference shield")
+ nu_outer = at_interface ? _shunt_junction_exponent(g.epsilon, first(g.right).epsilon) :
+ 2/3
+ descriptions = [
+ (; kind = :arc, radius = s.ri, phi = s.phi, span = s.span, mid = 0.0, half = 0.0,
+ alpha = nu_inner-1, beta = nu_inner-1),
+ (; kind = :arc, radius = s.ro, phi = s.phi, span = s.span, mid = 0.0, half = 0.0,
+ alpha = nu_outer-1, beta = nu_outer-1),
+ [(; kind = :end, radius = 0.0, phi = s.phi+sign*s.span/2, span = 0.0,
+ mid = (s.ri+s.ro)/2, half = (s.ro-s.ri)/2,
+ alpha = nu_outer-1, beta = nu_inner-1) for sign in (-1, 1)]...]
+ map(descriptions) do face
+ nodes, weights = _shunt_gauss_jacobi(quadrature, face.alpha, face.beta)
+ polys = transpose(reduce(hcat, [_shunt_jacobi(t, face.alpha, face.beta, p)
+ for t in nodes]))
+ weighted = weights .* polys
+ loggamma = SpecialFunctions.loggamma
+ beta_norm = exp(loggamma(face.alpha+1)+loggamma(face.beta+1) -
+ loggamma(face.alpha+face.beta+2))
+ merge(face,
+ (; p, nodes, weights, weighted, beta_norm, terminal = s.terminal,
+ points = _shunt_face_point.(Ref(face), nodes)))
+ end
+end
+
+function _shunt_face_projection(z, face)
+ if face.kind == :arc
+ radius = abs(z)
+ angle_difference = atan(sin(angle(z)-face.phi), cos(angle(z)-face.phi))
+ u = 2angle_difference/face.span
+ delta = abs(radius-face.radius)/(face.radius*face.span/2)
+ else
+ rotated = z*cis(-face.phi)
+ u = (real(rotated)-face.mid)/face.half
+ delta = abs(imag(rotated))/face.half
+ end
+ u, delta
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Integrate the logarithmic kernel against the normalized face charge modes:
+
+```math
+M_n(z)=\\int_{-1}^{1} P_n^{(\\alpha,\\beta)}(t)
+\\log\\!\\left(\\frac{|z-\\zeta(t)|}{1\\,\\mathrm{m}}\\right)d\\mu(t).
+```
+
+The face position is ``\\zeta(t)`` \\[m\\]. ``d\\mu`` is the normalized measure
+in `_shunt_tape_faces`. Ordinary quadrature can lose accuracy close to a
+source boundary [HelsingOjala2008](@cite), Section 1. Here direct and image
+logarithms are integrated separately from the smooth Green remainder.
+Nearby targets use ``t=\\cos\\theta`` and adaptive Gauss–Kronrod integration,
+split at the projected singularity or near peak. Distant targets use the
+face's Gauss–Jacobi rule. This is not Helsing-Ojala's rational-quadrature
+algorithm, and that reference does not prescribe the corner basis.
+
+# Arguments
+
+- `z`: target coordinate in the complex cross-sectional plane \\[m\\].
+- `face`: physical face geometry, normalized weights and Jacobi degree.
+
+# Keywords
+
+- `rtol=1e-10`: relative adaptive quadrature tolerance. The absolute tolerance
+ is `0.01rtol` for these dimensionless moments.
+- `atol=0.01rtol`: absolute tolerance for the dimensionless moment vector.
+- `maxevals=100_000`: adaptive quadrature evaluation budget.
+- `result=zeros(face.p+1)`: in-place moment workspace, also returned.
+- `segments=nothing`: optional reusable QuadGK segment buffer.
+
+# Returns
+
+- Logarithmic moments \\[dimensionless\\].
+- Adaptive error estimate. The far-field fixed rule returns zero because it
+ supplies no estimate, not because its error is known to vanish.
+
+# Errors
+
+An unresolved zero-distance evaluation or failed adaptive quadrature raises
+`BoundarySolveError`. The logarithm is not replaced by an arbitrary finite floor.
+"""
+function _shunt_log_moments(z, face; rtol = 1e-10, atol = 0.01rtol,
+ maxevals = 100_000, result = zeros(face.p+1), segments = nothing)
+ u, delta = _shunt_face_projection(z, face)
+ if hypot(max(abs(u)-1, 0), delta) > 0.2
+ fill!(result, 0)
+ @inbounds for j in eachindex(face.points)
+ value = log(abs(face.points[j]-z))
+ for n in eachindex(result)
+ result[n] += face.weighted[j, n]*value
+ end
+ end
+ return result, 0.0
+ end
+ # Weighted logarithmic product integration: compute the log moments of the
+ # Jacobi modes, independently of the smooth Green remainder. t=cos(theta)
+ # absorbs endpoint weights. Split at the logarithmic singularity or near peak.
+ theta0 = acos(clamp(u, -1, 1))
+ splits = [0.0, theta0, pi]
+ if delta > 0
+ width = min(sqrt(delta), delta/max(sin(theta0), eps(Float64)))
+ for sign in (-1, 1), factor in (1, 4)
+
+ push!(splits, clamp(theta0+sign*factor*width, 0, pi))
+ end
+ end
+ sort!(unique!(splits))
+ evaluations = Ref(0)
+ function integrand!(output, theta)
+ evaluations[] += 1
+ t = cos(theta)
+ difference = abs(u) <= 1 ?
+ -2sin((theta+theta0)/2)*sin((theta-theta0)/2) : t-u
+ distance = if face.kind == :arc
+ # Exact opposed-arc distance, also valid in the self limit.
+ hypot(abs(z)-face.radius,
+ 2sqrt(abs(z)*face.radius)*sin(face.span*difference/4))
+ else
+ hypot(face.half*difference, imag(z*cis(-face.phi)))
+ end
+ # Roundoff may identify the endpoint only after its contribution is
+ # below integration accuracy. Never manufacture a finite log(0) floor.
+ distance > 0 || throw(BoundarySolveError(:quadrature,
+ (; point = z, theta, face = face.kind), "unresolved logarithmic integration point"))
+ weight = sin(theta/2)^(2face.alpha+1)*cos(theta/2)^(2face.beta+1)/face.beta_norm
+ _shunt_jacobi!(output, t, face.alpha, face.beta)
+ output .*= log(distance)*weight
+ end
+ # Reuse quadrature work vectors. Modal integration must not allocate a new
+ # polynomial vector at each of its thousands of function evaluations.
+ integral,
+ estimate = quadgk!(integrand!, result, splits;
+ segbuf = segments, rtol, atol, order = max(7, cld(face.p+1, 2)), maxevals)
+ all(isfinite, integral) || throw(BoundarySolveError(:nonfinite,
+ (; point = z, face = face.kind), "nonfinite logarithmic integral"))
+ tolerance = max(rtol*norm(integral), atol)
+ if !(estimate <= tolerance)
+ @warn "Boundary logarithmic quadrature target was not met; returning the computed integral" context=(; point = z, face = face.kind, radius = face.radius,
+ phi = face.phi, degree = face.p,
+ span = face.span, mid = face.mid, half = face.half, alpha = face.alpha, beta = face.beta,
+ quadrature = length(face.points),
+ estimate, tolerance, evaluations = evaluations[], maxevals)
+ end
+ return integral, estimate
+end
+
+function _shunt_tape_columns!(columns, targets, faces, g, k; log_rtol = 1e-10,
+ integration = (rtol = log_rtol, atol = 0.01log_rtol, maxevals = 100_000), buffers)
+ estimated_error = 0.0
+ offset = 0
+ for face in faces
+ block = @view columns[:, (offset + 1):(offset + face.p + 1)]
+ kernel = @view buffers.tape[1:length(targets), 1:length(face.points)]
+ _shunt_kernel_matrix!(
+ kernel, targets, face.points, g, k; split_images = true, buffers)
+ mul!(block, kernel, face.weighted)
+ result = zeros(face.p+1)
+ segments = alloc_segbuf(Float64, Vector{Float64}, Float64; size = 32)
+ for (i, z) in pairs(targets)
+ inner, outer = g.a^2/conj(z), g.b^2/conj(z)
+ direct_coefficient = 1.0
+ inner_same = abs(inner-z) <= 8eps(Float64)*g.b
+ outer_same = abs(outer-z) <= 8eps(Float64)*g.b
+ direct_coefficient += (inner_same ? k.ra0 : 0.0) + (outer_same ? k.rb0 : 0.0)
+ for (point, coefficient) in ((inner, inner_same ? 0.0 : k.ra0),
+ (outer, outer_same ? 0.0 : k.rb0), (z, direct_coefficient))
+ coefficient == 0 && continue
+ moments,
+ err = _shunt_log_moments(point, face; integration..., result, segments)
+ @inbounds for n in eachindex(moments)
+ block[i, n] -= (coefficient/g.epsilon)*moments[n]
+ end
+ estimated_error = max(estimated_error, abs(coefficient/g.epsilon)*err)
+ end
+ end
+ offset += face.p+1
+ end
+ (; columns, log_moment_error = estimated_error)
+end
+
+function _shunt_tape_columns(selected::Formula{:boundary}, targets, faces, g, k; kwargs...)
+ nodes = maximum(face -> length(face.points), faces; init = 0)
+ buffers = initialize_buffers(selected, Val(:kernel), Float64, nothing,
+ (rows = length(targets), columns = nodes, modes = length(k.A), quadrature = nodes),
+ (;))
+ columns = Matrix{Float64}(undef, length(targets), sum(f.p+1 for f in faces; init = 0))
+ return _shunt_tape_columns!(columns, targets, faces, g, k; buffers, kwargs...)
+end
+
+function _shunt_face_targets(faces, n; validation = false)
+ parameters = -cos.(pi .* ((1:n) .- 0.5) ./ n)
+ if validation
+ # Independent uniform targets plus progressively closer corner targets.
+ parameters = sort!(unique!([parameters; collect(range(-1, 1; length = n+1));
+ [-1+10.0^-k for k in 2:10]; [1-10.0^-k for k in 2:10]]))
+ end
+ [_shunt_face_point(face, t) for face in faces for t in parameters]
+end
+
+"Maximum dense collocation storage per local solve [bytes]."
+const INTERNAL_SHUNT_MATRIX_BYTES = 384 * 1024^2
+
+const ShuntTapeFace = NamedTuple{
+ (:kind, :radius, :phi, :span, :mid, :half, :alpha, :beta, :p, :nodes, :weights,
+ :weighted, :beta_norm, :terminal, :points),
+ Tuple{Symbol, Float64, Float64, Float64, Float64, Float64, Float64, Float64,
+ Int, Vector{Float64}, Vector{Float64}, Matrix{Float64},
+ Float64, Int, Vector{ComplexF64}}}
+
+const InternalShuntDiagnostic = NamedTuple{
+ (:boundary_residual, :wire_residual, :tape_residual, :common_residual,
+ :penetration_indicator, :reciprocity, :log_moment_error, :unknowns,
+ :equations, :matrix_bytes, :level),
+ Tuple{Union{Nothing, Float64}, Union{Nothing, Float64}, Union{Nothing, Float64},
+ Union{Nothing, Float64}, Union{Nothing, Float64}, Float64, Float64,
+ Int, Int, Int, typeof(DEFAULT_RESOLUTION)}}
+
+function _shunt_values(domain::InternalShuntDomain{T}, methods) where {T}
+ # The admitted lossless laws depend only on relative permittivity. Resistivity
+ # temperature corrections do not belong to these blueprint coefficients.
+ reference_frequency = one(T)
+ epsilon(material) = begin
+ law = material.kind === :semicon ? methods.semicon_admittance :
+ methods.insulation_admittance
+ kappa = law(material, reference_frequency, material.T0)
+ imag(kappa)/(2pi*reference_frequency*vacuum_permittivity(T))
+ end
+ values = T[domain.a, domain.b, epsilon(domain.material)]
+ for layers in (domain.left, domain.right), layer in layers
+
+ append!(values, (layer.ri, layer.ro, epsilon(layer.material)))
+ end
+ for wire in domain.wires
+ append!(values, (wire.x, wire.y, wire.r))
+ end
+ for tape in domain.tapes
+ append!(values, (tape.ri, tape.ro, tape.phi, tape.span))
+ end
+ return values
+end
+
+# The flat values vector is also the differentiation input. It keeps
+# correlated scalar graphs out of dense factorizations without discarding them.
+function _shunt_data(values::AbstractVector{T}, domain) where {T}
+ a, b, epsilon = values[1:3]
+ Layer = NamedTuple{(:ri, :ro, :epsilon), Tuple{T, T, T}}
+ left, right = Layer[], Layer[]
+ cursor = 4
+ for (destination, layers) in ((left, domain.left), (right, domain.right))
+ for _ in layers
+ push!(destination, (
+ ri = values[cursor], ro = values[cursor + 1], epsilon = values[cursor + 2]))
+ cursor += 3
+ end
+ end
+ wires, tapes = ShuntWire{T}[], ShuntTape{T}[]
+ for wire in domain.wires
+ push!(wires,
+ (x = values[cursor], y = values[cursor + 1],
+ r = values[cursor + 2], terminal = wire.terminal))
+ cursor += 3
+ end
+ for tape in domain.tapes
+ push!(tapes,
+ (ri = values[cursor], ro = values[cursor + 1], phi = values[cursor + 2],
+ span = values[cursor + 3], terminal = tape.terminal))
+ cursor += 4
+ end
+ Rleft = sum(l->log(l.ro/l.ri)/l.epsilon, left; init = zero(T))
+ Rright = sum(l->log(l.ro/l.ri)/l.epsilon, right; init = zero(T))
+ return (; a, b, epsilon, left, right, wires, tapes, Rleft, Rright,
+ Rtotal = Rleft+log(b/a)/epsilon+Rright, ports = length(domain.terminals)-1)
+end
+
+function _shunt_discretization(g, level; validation = false)
+ faces = ShuntTapeFace[]
+ for tape in g.tapes
+ append!(faces, _shunt_tape_faces(g, tape, level.order, level.quadrature))
+ end
+ count = validation ? 3level.wire : 2level.wire
+ points = _shunt_points(g, count, 0.75; shift = validation ? 0.37 : 0.0).targets
+ terminals = Int[w.terminal for w in g.wires for _ in 1:count]
+ nface = validation ? max(41, 12(level.order+1)) : max(24, 4(level.order+1))
+ for face in faces
+ targets = _shunt_face_targets([face], nface; validation)
+ append!(points, targets)
+ append!(terminals, fill(face.terminal, length(targets)))
+ end
+ return (; faces, points, terminals, wire_targets = count*length(g.wires))
+end
+
+function _shunt_rhs!(rhs, points, terminals, g)
+ fill!(rhs, 0)
+ @inbounds for i in eachindex(points)
+ rhs[i, 1] = -_shunt_core_voltage(points[i], g)
+ rhs[i, terminals[i]] = 1
+ end
+ return rhs
+end
+
+function _shunt_matrix!(matrix, points, sources, faces, g, k;
+ integration = DEFAULT_INTEGRATION, buffers, stage = :assembly)
+ count = length(sources)
+ try
+ _shunt_kernel_matrix!(@view(matrix[:, 1:count]), points, sources, g, k; buffers)
+ tape = _shunt_tape_columns!(
+ @view(matrix[:, (count + 1):end]), points, faces, g, k; integration, buffers)
+ return tape.log_moment_error
+ catch exception
+ exception isa BoundarySolveError || rethrow()
+ throw(BoundarySolveError(exception.category, merge(exception.context, (; stage)), exception.message))
+ end
+end
+
+function _shunt_charge_map(g, sources, faces, level)
+ count = length(sources)+sum(f->f.p+1, faces; init = 0)
+ charge = zeros(g.ports, count)
+ @inbounds for (index, wire) in pairs(g.wires)
+ for j in ((index - 1) * level.wire + 1):(index * level.wire)
+ charge[1, j] = -_shunt_core_voltage(sources[j], g)
+ charge[wire.terminal, j] = 1
+ end
+ end
+ offset = length(sources)
+ for face in faces
+ charge[face.terminal, offset + 1] = 1
+ if face.kind === :arc
+ charge[1, offset + 1] = -_shunt_core_voltage(first(face.points), g)
+ else
+ @views mul!(charge[1:1, (offset + 1):(offset + face.p + 1)],
+ transpose(_shunt_core_voltage.(face.points, Ref(g))), face.weighted, -1.0, 0.0)
+ end
+ offset += face.p+1
+ end
+ return charge
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compute the lossless terminal capacitance of exposed wires and finite tapes
+inside a layered circular reference shield. Round-wire auxiliary sources and
+corner-weighted integrated tape charges share a column-equilibrated least-
+squares solve. Charge is extracted by exact total-charge and core-potential
+moments, not by symmetrizing the answer.
+
+# Arguments
+
+- `selected`: the boundary shunt formula, which builds the kernel buffers.
+- `g`: local radii and positions \\[m\\] and relative dielectric permittivities.
+
+# Keywords
+
+- `level`: fixed numerical resolution. The reference settings are not an
+ accuracy guarantee for arbitrary geometry or weak terminal couplings.
+- `retain=false`: retain the factorization only for local differentiation.
+- `integration`: dimensionless logarithmic-moment quadrature controls.
+- `audit=false`: evaluate independent boundary-grid residuals. When disabled,
+ those diagnostic fields are `nothing`. The terminal solve is unchanged.
+
+# Returns
+
+- Local capacitance `C` \\[F/m\\], sampled numerical diagnostics, and optional
+ differentiation state. Sampled residuals are not certified error bounds.
+
+# Notes
+
+The layered Green function and whole-face charge approach has precedent in
+[Bernal1997](@cite), but this solve uses oversampled boundary collocation and
+pivoted QR, not that paper's Galerkin system. The circular kernel, wire-source
+placement at `0.75` times each wire radius, Jacobi adaptation, resolution and
+acceptance checks are implementation choices. An accuracy bound for this combined discretization
+is not established by the cited methods.
+
+# Errors
+
+Memory-budget failures, degenerate charge columns and nonfinite capacitance are
+`BoundarySolveError`, not geometry `DomainError`. Monte Carlo must not condition
+its samples on them. Estimated rank, reciprocity, passivity and sampled geometric boundary
+quality are reported as warnings without substituting a different calculation.
+"""
+Base.@constprop :aggressive function _shunt_capacitance(selected::Formula{:boundary}, g;
+ level = DEFAULT_RESOLUTION, retain = false, integration = DEFAULT_INTEGRATION,
+ audit = false)
+ return _shunt_capacitance(selected, g, Val(retain); level, integration, audit)
+end
+
+function _shunt_capacitance(selected::Formula{:boundary}, g, ::Val{retain};
+ level = DEFAULT_RESOLUTION,
+ integration = DEFAULT_INTEGRATION, audit = false) where {retain}
+ estimated_unknowns = length(g.wires)*level.wire + 4length(g.tapes)*(level.order+1)
+ estimated_rows = 2length(g.wires)*level.wire +
+ 4length(g.tapes)*max(24, 4(level.order+1))
+ estimated_unknowns > 0 && estimated_rows >= estimated_unknowns ||
+ error("internal shunt: insufficient boundary equations")
+ estimated_unknowns <= div(INTERNAL_SHUNT_MATRIX_BYTES, 8estimated_rows) || throw(
+ BoundarySolveError(:budget, (; estimated_unknowns, estimated_rows),
+ "requested resolution exceeds the dense storage budget"))
+ discretization = _shunt_discretization(g, level)
+ (; faces, points, terminals) = discretization
+ sources = _shunt_points(g, level.wire, 0.75).sources
+ n = length(sources)+sum(f->f.p+1, faces; init = 0)
+ m = length(points)
+ n > 0 && m >= n || error("internal shunt: insufficient boundary equations")
+ n <= div(INTERNAL_SHUNT_MATRIX_BYTES, 8m) ||
+ throw(BoundarySolveError(:budget, (; rows = m, columns = n),
+ "boundary matrix exceeds the $(INTERNAL_SHUNT_MATRIX_BYTES÷1024^2) MiB storage budget"))
+ k = _shunt_kernel_coefficients(g, level.modes)
+ matrix = Matrix{Float64}(undef, m, n)
+ buffers = initialize_buffers(selected, Val(:kernel), Float64, nothing,
+ (rows = max(m, 192), columns = max(length(sources), level.quadrature),
+ modes = level.modes, quadrature = isempty(faces) ? 0 : level.quadrature), (;))
+ moment_error = _shunt_matrix!(
+ matrix, points, sources, faces, g, k; integration, buffers)
+ rhs = _shunt_rhs!(zeros(m, g.ports), points, terminals, g)
+ scales = Vector{Float64}(undef, n)
+ @inbounds for j in 1:n
+ scales[j] = norm(@view matrix[:, j])
+ isfinite(scales[j]) && scales[j] > 0 || throw(BoundarySolveError(
+ :rank, (; column = j), "degenerate charge column"))
+ @views matrix[:, j] ./= scales[j]
+ end
+ # In-place pivoted QR avoids retaining K, scaled K, U and V simultaneously.
+ factor = qr!(matrix, ColumnNorm())
+ diagonal = [abs(factor.factors[i, i]) for i in 1:n]
+ cutoff = max(m, n)*eps(Float64)*maximum(diagonal)
+ estimated_rank = count(>(cutoff), diagonal)
+ estimated_rank == n || @warn "Boundary charge basis has unresolved numerical rank; retaining the selected QR solve" estimated_rank columns=n cutoff
+ scaled_coefficients = factor\rhs
+ coefficients = scaled_coefficients ./ scales
+ charge = _shunt_charge_map(g, sources, faces, level)
+ C = (2pi*vacuum_permittivity(Float64)) .* (charge*coefficients)
+ C[1, 1] += 2pi*vacuum_permittivity(Float64)/g.Rtotal
+ all(isfinite, C) ||
+ throw(BoundarySolveError(:nonfinite, (;), "nonfinite terminal capacitance"))
+ audit_result = audit ?
+ _shunt_audit(
+ g, level, sources, k, coefficients, n, buffers, integration) : nothing
+ boundary = audit_result === nothing ? nothing : audit_result.boundary
+ common = audit_result === nothing ? nothing : audit_result.common
+ wire_residual = audit_result === nothing ? nothing : audit_result.wire_residual
+ tape_residual = audit_result === nothing ? nothing : audit_result.tape_residual
+ audit_result === nothing || (moment_error=max(moment_error, audit_result.moment_error))
+ reciprocity = norm(C-transpose(C))/norm(C)
+ reciprocity <= 0.01 || @warn "Boundary terminal reciprocity target was not met" reciprocity tolerance=0.01
+ minimum_eigenvalue = minimum(eigvals!(copy((C+transpose(C))/2)))
+ minimum_eigenvalue > 0 || @warn "Boundary capacitance has a nonpositive symmetric-part eigenvalue" minimum_eigenvalue
+ leakage = abs(sum(@view C[1, :]))
+ coupling = sum(abs, @view C[1, 2:end])
+ indicator = common === nothing ? nothing :
+ iszero(leakage) ? Inf : common*coupling/leakage
+ diagnostic = InternalShuntDiagnostic((boundary, wire_residual, tape_residual,
+ common, indicator, reciprocity, moment_error, n, m, 8m*n, level))
+ state = if retain
+ residual = factor.Q' * rhs
+ residual[1:n, :] .= 0
+ residual = factor.Q * residual
+ (; factor, scales, scaled_coefficients, coefficients, charge, residual,
+ points, terminals, sources, faces, k, level, integration)
+ else
+ nothing
+ end
+ return (; C, diagnostic, state)
+end
+
+function _shunt_audit(g, level, sources, k, coefficients, n, buffers, integration)
+ checks = _shunt_discretization(g, level; validation = true)
+ matrix = Matrix{Float64}(undef, min(192, length(checks.points)), n)
+ errors = zeros(size(matrix, 1), g.ports)
+ boundary = common = wire_residual = tape_residual = 0.0
+ moment_error = 0.0
+ for start in 1:192:length(checks.points)
+ stop = min(start+191, length(checks.points))
+ rows = 1:(stop - start + 1)
+ block = @view matrix[rows, :]
+ selected = @view checks.points[start:stop]
+ moment_error = max(moment_error,
+ _shunt_matrix!(block, selected, sources, checks.faces, g, k;
+ integration, buffers, stage = :audit))
+ residual = @view errors[rows, :]
+ _shunt_rhs!(residual, selected, @view(checks.terminals[start:stop]), g)
+ mul!(residual, block, coefficients, 1.0, -1.0)
+ for i in rows
+ value = maximum(abs, @view residual[i, :])
+ boundary = max(boundary, value)
+ common = max(common, abs(sum(@view residual[i, :])))
+ if start+i-1 <= checks.wire_targets
+ wire_residual = max(wire_residual, value)
+ else
+ tape_residual = max(tape_residual, value)
+ end
+ end
+ end
+ boundary <= 0.06 || @warn "Sampled boundary residual exceeds 0.06 V per unit excitation" boundary tolerance=0.06
+ return (; boundary, common, wire_residual, tape_residual, moment_error)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Differentiate the fixed-resolution local least-squares problem in one physical
+parameter direction. Centered differences evaluate only kernel and charge-map
+derivatives. The nominal pivoted QR is reused. Include the nonzero collocation
+residual in the differentiated stationarity equation:
+
+```math
+A^T A\\,dx=A^T(db-dA\\,x)+dA^T(b-Ax).
+```
+
+Triangular QR solves evaluate this equation without forming normal equations.
+Geometric boundary derivatives are streamed in bounded row blocks, not stored as a dense
+matrix of uncertain scalars. Input directions retain their original physical
+units. The direction parameter and `step` are dimensionless.
+
+# Returns
+
+- Directional derivative of terminal capacitance \\[F/m\\].
+"""
+function _shunt_tangent(selected::Formula{:boundary}, values, direction, domain, state, step)
+ plus = _shunt_data(values .+ step .* direction, domain)
+ minus = _shunt_data(values .- step .* direction, domain)
+ dp,
+ dm = _shunt_discretization(plus, state.level), _shunt_discretization(minus, state.level)
+ sp = _shunt_points(plus, state.level.wire, 0.75).sources
+ sm = _shunt_points(minus, state.level.wire, 0.75).sources
+ kp,
+ km = _shunt_kernel_coefficients(plus, state.level.modes),
+ _shunt_kernel_coefficients(minus, state.level.modes)
+ m, n = size(state.factor)
+ count = min(192, m)
+ matrix_p, matrix_m = Matrix{Float64}(undef, count, n), Matrix{Float64}(undef, count, n)
+ bp, bm = zeros(count, plus.ports), zeros(count, plus.ports)
+ drive, stationarity = zeros(m, plus.ports), zeros(n, plus.ports)
+ buffers = initialize_buffers(selected, Val(:kernel), Float64, nothing,
+ (rows = count, columns = max(length(sp), state.level.quadrature),
+ modes = state.level.modes,
+ quadrature = isempty(dp.faces) ? 0 : state.level.quadrature), (;))
+ for start in 1:192:m
+ stop = min(start+191, m)
+ rows = 1:(stop - start + 1)
+ ap, am = @view(matrix_p[rows, :]), @view(matrix_m[rows, :])
+ xp, xm = @view(dp.points[start:stop]), @view(dm.points[start:stop])
+ _shunt_matrix!(ap, xp, sp, dp.faces, plus, kp;
+ integration = state.integration, buffers, stage = :derivative)
+ _shunt_matrix!(am, xm, sm, dm.faces, minus, km;
+ integration = state.integration, buffers, stage = :derivative)
+ ap .-= am
+ ap ./= 2step
+ rp, rm = @view(bp[rows, :]), @view(bm[rows, :])
+ _shunt_rhs!(rp, xp, @view(dp.terminals[start:stop]), plus)
+ _shunt_rhs!(rm, xm, @view(dm.terminals[start:stop]), minus)
+ target = @view drive[start:stop, :]
+ target .= (rp .- rm) ./ (2step)
+ mul!(target, ap, state.coefficients, -1.0, 1.0)
+ mul!(stationarity, transpose(ap), @view(state.residual[start:stop, :]), 1.0, 1.0)
+ end
+ stationarity ./= state.scales
+ delta = state.factor\drive
+ R = UpperTriangular(@view state.factor.factors[1:n, 1:n])
+ correction = R\(transpose(R)\stationarity[state.factor.p, :])
+ @inbounds for i in 1:n, j in axes(delta, 2)
+
+ delta[state.factor.p[i], j] += correction[i, j]
+ end
+ delta ./= state.scales
+ charge_delta = (_shunt_charge_map(plus, sp, dp.faces, state.level) -
+ _shunt_charge_map(minus, sm, dm.faces, state.level)) ./ (2step)
+ result = (2pi*vacuum_permittivity(Float64)) .*
+ (charge_delta*state.coefficients + state.charge*delta)
+ result[1, 1] += 2pi*vacuum_permittivity(Float64)*(inv(plus.Rtotal)-inv(minus.Rtotal))/(2step)
+ return result
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate a local shunt domain from its physical scalar descriptors.
+This non-exported extension protocol lets Measurements preserve correlations
+without introducing uncertain scalars into dense numerical workspaces.
+
+# Arguments
+
+- `values`: host and layer radii, wire coordinates and radii and tape dimensions
+ \\[m\\], relative permittivities and tape angles \\[rad\\], in the order
+ supplied during blueprint construction.
+- `domain`: extracted local geometry and terminal ownership.
+
+# Keywords
+
+- `level`: wire, tape, and Fourier discretization controls.
+- `integration`: dimensionless logarithmic-moment quadrature controls.
+- `audit=false`: enable independent boundary-grid and derivative step checks.
+- `retain=false`: retain the nominal factorization for differentiation tests.
+- `directions=nothing`: optional columns of physical input perturbations per
+ unit dimensionless direction parameter.
+
+# Returns
+
+- Capacitance `C` \\[F/m\\], numerical diagnostics and optional retained state.
+ With `directions`, also return checked implicit capacitance derivatives.
+"""
+function internal_shunt_response(
+ selected::Formula{:boundary}, values::AbstractVector{<:Real}, domain;
+ level = DEFAULT_RESOLUTION, retain = false, directions = nothing,
+ integration = DEFAULT_INTEGRATION, audit = false)
+ numerical = Float64.(values)
+ result = try
+ _shunt_capacitance(selected, _shunt_data(numerical, domain); level, integration, audit,
+ retain = retain || directions !== nothing)
+ catch exception
+ exception isa BoundarySolveError || rethrow()
+ throw(BoundarySolveError(exception.category,
+ merge(exception.context,
+ (; design = domain.design, terminals = domain.terminals)),
+ exception.message))
+ end
+ directions === nothing && return result
+ size(directions, 1) == length(numerical) || throw(DimensionMismatch(
+ "internal shunt directions must align with physical descriptors"))
+ derivatives = Matrix{Float64}(undef, size(directions, 2), length(result.C))
+ for column in axes(directions, 2)
+ direction = @view directions[:, column]
+ relative = maximum(eachindex(direction)) do i
+ abs(direction[i])/(iszero(numerical[i]) ? abs(numerical[2]) : abs(numerical[i]))
+ end
+ iszero(relative) && (derivatives[column, :] .= 0; continue)
+ step = cbrt(eps(Float64))/relative
+ fine = _shunt_tangent(selected, numerical, direction, domain, result.state, step/2)
+ all(isfinite, fine) || throw(BoundarySolveError(:derivative,
+ (; design = domain.design, terminals = domain.terminals, column), "nonfinite sensitivity"))
+ if audit
+ coarse = _shunt_tangent(selected, numerical, direction, domain, result.state, step)
+ discrepancy = norm(fine-coarse)
+ tolerance = 0.02max(norm(fine), norm(coarse)) +
+ 256eps(Float64)*norm(result.C)/step
+ discrepancy <= tolerance || @warn "Boundary uncertainty sensitivity did not resolve on step refinement" design=domain.design terminals=domain.terminals column discrepancy tolerance
+ end
+ derivatives[column, :] .= vec(fine)
+ end
+ return (; C = result.C, diagnostic = result.diagnostic,
+ state = retain ? result.state : nothing, tangents = derivatives)
+end
diff --git a/src/engine/shuntmodel/geometry.jl b/src/engine/shuntmodel/geometry.jl
new file mode 100644
index 000000000..5921fce89
--- /dev/null
+++ b/src/engine/shuntmodel/geometry.jl
@@ -0,0 +1,268 @@
+# Numerical boundary-domain records owned by the local shunt formulations.
+const ShuntWire{T} = NamedTuple{(:x, :y, :r, :terminal), Tuple{T, T, T, Int}}
+const ShuntTape{T} = NamedTuple{(:ri, :ro, :phi, :span, :terminal), Tuple{T, T, T, T, Int}}
+const ShuntLayer{T} = NamedTuple{(:ri, :ro, :material), Tuple{T, T, Material{T}}}
+
+"""
+$(TYPEDEF)
+
+Describe an open-conductor domain inside a circular layered shield. The first
+terminal is the equivalent inner conductor. The last is the closed reference
+shield. Intermediate terminals retain their exposed circular wires and tapes.
+Coordinates and radii are in \\[m\\]. Materials are not homogenized.
+
+$(TYPEDFIELDS)
+"""
+struct InternalShuntDomain{T <: Real}
+ "Source design index."
+ design::Int
+ "Global conductor range of the containing concentric assembly."
+ assembly::UnitRange{Int}
+ "Global terminal range, including inner conductor and reference shield."
+ terminals::UnitRange{Int}
+ "Host dielectric inner radius \\[m\\]."
+ a::T
+ "Host dielectric outer radius \\[m\\]."
+ b::T
+ "Unmodified physical host dielectric."
+ material::Material{T}
+ "Ordered layers between inner conductor and host."
+ left::Vector{ShuntLayer{T}}
+ "Ordered layers between host and reference shield."
+ right::Vector{ShuntLayer{T}}
+ "Exposed circular wires in the host frame."
+ wires::Vector{ShuntWire{T}}
+ "Exposed finite-thickness tapes in the host frame."
+ tapes::Vector{ShuntTape{T}}
+end
+
+_shunt_scalar(x) = float(nominal(x))
+_shunt_tol(x) = 128eps(typeof(_shunt_scalar(x))) * abs(_shunt_scalar(x))
+_shunt_same(a, b, scale) = numerical_magnitude(a - b) <= _shunt_tol(scale)
+function _shunt_concentric(shape, x, y, scale)
+ _shunt_same(shape.at.x, x, scale) && _shunt_same(shape.at.y, y, scale)
+end
+
+# Preserve ordering and dependency information. Nominal values decide ordering.
+# Coincident interfaces and centers must also have matching uncertain dependencies.
+function _shunt_layers(regions, inner, outer, x, y, ::Type{T}) where {T}
+ layers = ShuntLayer{T}[]
+ _shunt_same(inner, outer, outer) && return layers
+ for placed in regions
+ shape = placed.primitive
+ placed.terminal === nothing && shape isa DataModel.Annulus || continue
+ _shunt_concentric(shape, x, y, outer) || continue
+ _shunt_scalar(shape.ri - inner) >= -_shunt_tol(outer) &&
+ _shunt_scalar(shape.ro - outer) <= _shunt_tol(outer) || continue
+ push!(layers,
+ (ri = T(shape.ri), ro = T(shape.ro),
+ material = convert(Material{T}, placed.source.material)))
+ end
+ sort!(layers; by = layer -> _shunt_scalar(layer.ri))
+ cursor = inner
+ for layer in layers
+ _shunt_same(cursor, layer.ri, outer) || return nothing
+ cursor = layer.ro
+ end
+ _shunt_same(cursor, outer, outer) || return nothing
+ return layers
+end
+
+function _shunt_closed(design, index, x, y, scale)
+ indices = findall(==(index), design.terminal_map)
+ length(indices) == 1 || return nothing
+ shape = design.geometry.regions[only(indices)].primitive
+ shape isa DataModel.Annulus && _shunt_concentric(shape, x, y, scale) ||
+ return nothing
+ return shape
+end
+
+function _shunt_host_domain(
+ design, blueprint, host_region, design_index, offset, ::Type{T}) where {T}
+ host = host_region.primitive.outer
+ x, y, phi = host.at.x, host.at.y, host.at.φ
+ scale = host.ro
+ members = Int[]
+ for (i, placed) in pairs(design.geometry.regions)
+ placed.terminal === nothing && continue
+ shape = placed.primitive
+ inside = if shape isa DataModel.Disk
+ radius = hypot(shape.at.x - x, shape.at.y - y)
+ _shunt_scalar(radius - shape.r - host.ri) >= -_shunt_tol(scale) &&
+ _shunt_scalar(radius + shape.r - host.ro) <= _shunt_tol(scale)
+ elseif shape isa DataModel.BentStrip
+ _shunt_concentric(shape, x, y, scale) &&
+ _shunt_scalar(shape.ri - host.ri) >= -_shunt_tol(scale) &&
+ _shunt_scalar(shape.ro - host.ro) <= _shunt_tol(scale)
+ else
+ false
+ end
+ if inside
+ inner, outer = if shape isa DataModel.Disk
+ radius = hypot(shape.at.x-x, shape.at.y-y)
+ radius-shape.r, radius+shape.r
+ else
+ shape.ri, shape.ro
+ end
+ # A nominal contact with an independent uncertain displacement
+ # crosses a dielectric interface, outside a fixed-topology tangent.
+ for (edge, boundary) in ((inner, host.ri), (outer, host.ro))
+ abs(_shunt_scalar(edge-boundary)) <= _shunt_tol(scale) &&
+ !_shunt_same(edge, boundary, scale) && return nothing
+ end
+ push!(members, i)
+ end
+ end
+ isempty(members) && return nothing
+ # A coating, void, or another dielectric island inside the host invalidates
+ # the homogeneous-host Green function even if the metal itself fits radially.
+ holes = host_region.primitive.holes
+ length(holes) == length(members) && all(
+ index -> any(hole -> isequal(design.geometry.regions[index].primitive, hole), holes), members) ||
+ return nothing
+ terminals = sort!(unique(design.terminal_map[members]))
+ first(terminals) > 1 && last(terminals) < length(blueprint.conductors) || return nothing
+ terminals == collect(first(terminals):last(terminals)) || return nothing
+ # Never resolve only some pieces of a retained terminal.
+ all(i -> design.terminal_map[i] ∉ terminals || i in members,
+ eachindex(design.terminal_map)) || return nothing
+ first_index, reference = first(terminals) - 1, last(terminals) + 1
+ assembly_index = blueprint.conductors[first_index].assembly
+ assembly = blueprint.assembly_ranges[assembly_index]
+ reference in assembly || return nothing
+ inner = blueprint.conductors[first_index]
+ _shunt_same(inner.position[1], x, scale) &&
+ _shunt_same(inner.position[2], y, scale) || return nothing
+ # Inner cores keep the existing equivalent-core assumption. Elsewhere an
+ # actual closed conductor is needed to separate independently solved gaps.
+ first_index == first(assembly) ||
+ _shunt_closed(design, first_index, x, y, scale) !== nothing || return nothing
+ shield = _shunt_closed(design, reference, x, y, scale)
+ shield === nothing && return nothing
+ left = _shunt_layers(design.geometry.regions, inner.r_ex, host.ri, x, y, T)
+ right = _shunt_layers(design.geometry.regions, host.ro, shield.ri, x, y, T)
+ (left === nothing || right === nothing) && return nothing
+ wires, tapes = ShuntWire{T}[], ShuntTape{T}[]
+ for index in members
+ shape = design.geometry.regions[index].primitive
+ terminal = design.terminal_map[index] - first_index + 1
+ if shape isa DataModel.Disk
+ dx, dy = shape.at.x - x, shape.at.y - y
+ push!(wires,
+ (x = T(cos(phi)*dx + sin(phi)*dy),
+ y = T(-sin(phi)*dx + cos(phi)*dy), r = T(shape.r), terminal))
+ else
+ zero(shape.span) < shape.span < 2pi || return nothing
+ push!(tapes,
+ (ri = T(shape.ri), ro = T(shape.ro),
+ phi = T(shape.at.φ - phi), span = T(shape.span), terminal))
+ end
+ end
+ # The whole-face element admits exposed faces only. Overlapping/touching
+ # faces require an exposed-union adapter. Never impose buried geometric boundaries.
+ for i in eachindex(wires), j in 1:(i - 1)
+
+ l, r = wires[i], wires[j]
+ gap = hypot(l.x-r.x, l.y-r.y)-l.r-r.r
+ if _shunt_scalar(gap) <= _shunt_tol(scale)
+ l.terminal == r.terminal || throw(ArgumentError(
+ "internal shunt: touching wire terminals in $(design.cable_id)"))
+ return nothing
+ end
+ end
+ for tape in tapes, wire in wires
+
+ gap = tape.ri - hypot(wire.x, wire.y) - wire.r
+ _shunt_scalar(gap) >= -_shunt_tol(scale) || return nothing
+ abs(_shunt_scalar(gap)) <= _shunt_tol(scale) &&
+ !_shunt_same(gap, zero(gap), scale) && return nothing
+ if abs(_shunt_scalar(gap)) <= _shunt_tol(scale) && wire.terminal != tape.terminal
+ theta = atan(sin(atan(wire.y, wire.x)-tape.phi),
+ cos(atan(wire.y, wire.x)-tape.phi))
+ abs(_shunt_scalar(theta)) <= _shunt_scalar(tape.span)/2 &&
+ throw(ArgumentError("internal shunt: touching wire and tape terminals in $(design.cable_id)"))
+ end
+ end
+ for i in eachindex(tapes), j in 1:(i - 1)
+
+ l, r = tapes[i], tapes[j]
+ separation = abs(atan(sin(l.phi-r.phi), cos(l.phi-r.phi)))
+ radial = _shunt_scalar(max(l.ri, r.ri)-min(l.ro, r.ro))
+ angular = _shunt_scalar(separation - (l.span+r.span)/2)
+ radial > _shunt_tol(scale) || angular > _shunt_tol(scale)/_shunt_scalar(scale) ||
+ return nothing
+ end
+ return InternalShuntDomain{T}(design_index,
+ (first(assembly) + offset):(last(assembly) + offset),
+ (first_index + offset):(reference + offset), T(host.ri), T(host.ro),
+ convert(Material{T}, host_region.source.material), left, right, wires, tapes)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Extract eligible local shunt domains from completed physical designs. Concentric
+dielectric continuity, closed reference shields and exposed faces are checked
+before any numerical allocation. Unsupported geometries keep their existing
+equivalent-coaxial treatment. No shapes, materials or terminals are changed.
+
+# Returns
+
+- Concrete vector of internal domains, with global terminal indices.
+"""
+function internal_shunt_domains(designs, blueprints::AbstractVector{<:CableBlueprint{T}}) where {T}
+ domains = InternalShuntDomain{T}[]
+ offset = 0
+ for (design_index, (design, blueprint)) in enumerate(zip(designs, blueprints))
+ append!(domains, internal_shunt_domains(design, blueprint, T; design_index, offset))
+ offset += length(blueprint)
+ end
+ return domains
+end
+
+function internal_shunt_domains(
+ design::CableDesign, geometry, ::Type{T}; design_index = 1, offset = 0) where {T}
+ domains = InternalShuntDomain{T}[]
+ for placed in design.geometry.regions
+ shape = placed.primitive
+ placed.terminal === nothing && shape isa DataModel.DifferenceShape &&
+ shape.outer isa DataModel.Annulus || continue
+ domain = _shunt_host_domain(design, geometry, placed, design_index, offset, T)
+ domain === nothing && continue
+ any(
+ old -> max(first(old.terminals), first(domain.terminals)) <
+ min(last(old.terminals), last(domain.terminals)),
+ domains) && continue
+ push!(domains, domain)
+ end
+ return domains
+end
+
+function _shunt_domain_equal(a, b)
+ length(a.terminals) == length(b.terminals) || return false
+ for name in (:a, :b)
+ same_physical_state(getproperty(a, name), getproperty(b, name)) || return false
+ end
+ for name in (:left, :right, :wires, :tapes)
+ l, r = getproperty(a, name), getproperty(b, name)
+ length(l) == length(r) || return false
+ if name in (:left, :right)
+ all(same_physical_state((x.ri, x.ro, x.material.eps_r), (
+ y.ri, y.ro, y.material.eps_r))
+ for (x, y) in zip(l, r)) || return false
+ else
+ all(same_physical_state(x, y) for (x, y) in zip(l, r)) || return false
+ end
+ end
+ return same_physical_state(a.material.eps_r, b.material.eps_r)
+end
+
+_shunt_lossless(::Any) = false
+function _shunt_lossless(::Union{InsulationAdmittance.Formula{:lossless},
+ SemiconAdmittance.Formula{:lossless}})
+ true
+end
+function _shunt_lossless(methods::NamedTuple)
+ _shunt_lossless(methods.insulation_admittance) &&
+ _shunt_lossless(methods.semicon_admittance)
+end
diff --git a/src/engine/shuntmodel/interface.jl b/src/engine/shuntmodel/interface.jl
new file mode 100644
index 000000000..24c929852
--- /dev/null
+++ b/src/engine/shuntmodel/interface.jl
@@ -0,0 +1,130 @@
+"""
+$(TYPEDEF)
+
+Select a local shunt geometry approximation. Material admittivity remains owned
+by the insulation and semiconductor constitutive selections.
+
+$(TYPEDFIELDS)
+"""
+struct Formula{ID, P <: NamedTuple, O <: FormulationOptions} <: ShuntModelFormulation
+ "Requested fallback model."
+ parameters::P
+ "Boundary discretization, quadrature, and optional audit controls."
+ options::O
+end
+
+"Reference boundary discretization. Accuracy depends on geometry and the requested terminal quantity."
+const DEFAULT_RESOLUTION = (wire = 64, order = 32, quadrature = 256, modes = 1024)
+"Default controls for dimensionless logarithmic-moment integration."
+const DEFAULT_INTEGRATION = (rtol = 1e-8, atol = 1e-10, maxevals = 100_000)
+
+"Return the available local shunt model identifiers."
+formulas(::Type{<:Formula}) = (:default, :equivalent, :boundary)
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct a cable-local shunt model. `:default` and `:equivalent` use the equivalent annular
+layer of each dielectric interval. `:boundary` resolves eligible lossless open-screen
+domains before frequency evaluation.
+
+# Keywords
+
+- `parameters=(;)`: geometric boundary `fallback=:error` (default) or explicitly
+ `:equivalent` after an unsupported geometric boundary assumption or numerical failure.
+ A finite result's quality warning never triggers this fallback.
+- `options=(;)`: geometric boundary `resolution=(wire=64, order=32, quadrature=256,
+ modes=1024)`, `integration=(rtol=1e-8, atol=1e-10, maxevals=100_000)`, and
+ `audit=false`, where the audit recomputes an independent boundary grid to check
+ derivative step refinement. The equivalent model does not accept numerical controls.
+
+# Returns
+
+- A concrete shunt model selection.
+"""
+function Formula{ID}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions()) where {ID}
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ ID === :equivalent || throw(ArgumentError("unknown shunt model :$ID"))
+ isempty(parameters) && isempty(options.data) || throw(ArgumentError(
+ "equivalent shunt models accept no parameters or numerical controls"))
+ return Formula{ID, typeof(parameters), typeof(options)}(parameters, options)
+end
+
+Formula{:default}(; kwargs...) = Formula{:equivalent}(; kwargs...)
+
+function Formula{:boundary}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions())
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ isempty(setdiff(keys(parameters), (:fallback,))) || throw(ArgumentError(
+ "boundary shunt parameters accept only fallback"))
+ fallback = get(parameters, :fallback, :error)
+ fallback in (:error, :equivalent) ||
+ throw(ArgumentError("boundary fallback must be :error or :equivalent"))
+ isempty(setdiff(keys(options.data), (:resolution, :integration, :audit))) ||
+ throw(ArgumentError(
+ "boundary shunt options accept resolution, integration, and audit"))
+ resolution = get(options.data, :resolution, (;))
+ resolution isa NamedTuple &&
+ isempty(setdiff(keys(resolution), keys(DEFAULT_RESOLUTION))) ||
+ throw(ArgumentError("unknown boundary resolution controls"))
+ resolution = merge(DEFAULT_RESOLUTION, resolution)
+ all(x -> x isa Integer && !(x isa Bool) && x > 0, values(resolution)) ||
+ throw(ArgumentError("boundary resolution controls must be positive integers"))
+ resolution.quadrature >= resolution.order+1 || throw(ArgumentError(
+ "boundary quadrature must contain at least order+1 nodes"))
+ integration = get(options.data, :integration, (;))
+ integration isa NamedTuple &&
+ isempty(setdiff(keys(integration), keys(DEFAULT_INTEGRATION))) ||
+ throw(ArgumentError("unknown boundary integration controls"))
+ integration = merge(DEFAULT_INTEGRATION, integration)
+ all(x -> x isa Real && isfinite(x) && x >= 0, (integration.rtol, integration.atol)) &&
+ max(integration.rtol, integration.atol) > 0 || throw(ArgumentError(
+ "boundary integration requires nonnegative finite tolerances, at least one positive"))
+ integration.maxevals isa Integer && !(integration.maxevals isa Bool) &&
+ integration.maxevals > 0 ||
+ throw(ArgumentError("boundary maxevals must be a positive integer"))
+ audit = get(options.data, :audit, false)
+ audit isa Bool || throw(ArgumentError("boundary audit must be Bool"))
+ normalized = FormulationOptions(; resolution,
+ integration = (rtol = Float64(integration.rtol),
+ atol = Float64(integration.atol), maxevals = Int(integration.maxevals)),
+ audit)
+ parameters = (; fallback)
+ return Formula{:boundary, typeof(parameters), typeof(normalized)}(parameters, normalized)
+end
+
+"""Describe the equivalent annular layer local shunt approximation."""
+function description(::Type{<:Formula{:equivalent}}; compact::Bool = false)
+ compact ? "equivalent annular layer" :
+ "Equivalent annular layer shunt geometry"
+end
+"""Describe the default equivalent annular layer local shunt approximation."""
+function description(::Type{<:Formula{:default}}; compact::Bool = false)
+ description(Formula{:equivalent}; compact)
+end
+"""Describe the lossless wire and tape geometric boundary approximation."""
+function description(::Type{<:Formula{:boundary}}; compact::Bool = false)
+ compact ? "boundary" :
+ "Lossless wire/tape boundary shunt geometry"
+end
+
+Formula(identifier::Symbol; kwargs...) = Formula(Val(identifier); kwargs...)
+Formula(::Val{ID}; kwargs...) where {ID} = Formula{ID}(; kwargs...)
+Formula(value::ShuntModelFormulation) = value
+
+function Formula(value::FormulaDefinition{ID, Order}) where {ID, Order}
+ Order === :default && value.equivalent_earth === nothing || throw(ArgumentError(
+ "shunt_model does not accept equivalent-earth reductions or ordering"))
+ return Formula{ID}(; parameters = value.parameters, options = value.options)
+end
+
+formula_id(::Formula{ID}) where {ID} = ID
+formula_id(::Type{<:Formula{ID}}) where {ID} = ID
+description(value::Formula; compact::Bool = false) = description(typeof(value); compact)
+formulation_options(value::Formula) = value.options
+function Base.NamedTuple(value::Formula)
+ (identifier = formula_id(value),
+ parameters = value.parameters, options = value.options.data)
+end
+Base.pairs(::Type{<:Formula}; quantity = nothing) = pairs((;))
diff --git a/src/engine/solver.jl b/src/engine/solver.jl
deleted file mode 100644
index 6a029bcec..000000000
--- a/src/engine/solver.jl
+++ /dev/null
@@ -1,370 +0,0 @@
-function compute!(
- problem::LineParametersProblem{T},
- formulation::EMTFormulation,
-) where {T <: REALSCALAR}
-
- lvl = levelfrom(formulation.options.common.verbosity)
- sink =
- isnothing(formulation.options.logfile) ?
- ConsoleLogger(stderr, lvl) :
- TeeLogger(ConsoleLogger(stderr, lvl),
- FileLogger(formulation.options.logfile, lvl))
- with_logger(TimestampLogger(sink)) do
-
- @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 identities for potential-coefficient matrix inversion.
- # P is complex symmetric, not Hermitian, whenever dielectric or earth
- # losses are present. LU is therefore required; wrapping P in Hermitian
- # changes the matrix that is being solved.
- 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
-
- F = lu!(Pbuf)
- ldiv!(inv_Pbuf, F, I_nph) # inv_Pbuf := P^{-1}
- # 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)
- F = lu!(Mred)
- ldiv!(inv_Mred, F, I_nkeep)
- # 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
-
- if !isnothing(formulation.modal_transform)
- # apply modal transformation
- _, lp = formulation.modal_transform(
- LineParameters(PhaseDomain, Zout, Yout, ws.freq),
- )
- else
- lp = LineParameters(PhaseDomain, Zout, Yout, ws.freq)
- end
-
- @info "Line parameters computation completed successfully"
- return ws, lp
- end
-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]
- p[g] = InsulationAdmittance.potential_coefficient(
- pinsfunctor,
- ws,
- i,
- 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/specialfunctions.jl b/src/engine/specialfunctions.jl
new file mode 100644
index 000000000..2dd4e625b
--- /dev/null
+++ b/src/engine/specialfunctions.jl
@@ -0,0 +1,87 @@
+@inline special_besselix(order::Integer, value) = SpecialFunctions.besselix(order, value)
+@inline special_besselkx(order::Integer, value) = SpecialFunctions.besselkx(order, value)
+@inline special_besselk(order::Integer, value) = SpecialFunctions.besselk(order, value)
+@inline special_besseljx(order::Integer, value) = SpecialFunctions.besseljx(order, value)
+@inline special_besselix(order::Integer,
+ value::Complex{Float32}) = ComplexF32(SpecialFunctions.besselix(Float32(order), value))
+@inline special_besselkx(order::Integer,
+ value::Complex{Float32}) = ComplexF32(SpecialFunctions.besselkx(Float32(order), value))
+@inline special_besselk(order::Integer,
+ value::Complex{Float32}) = ComplexF32(SpecialFunctions.besselk(Float32(order), value))
+@inline special_besseljx(order::Integer,
+ value::Complex{Float32}) = ComplexF32(SpecialFunctions.besseljx(Float32(order), value))
+function special_besseljx(order::Integer, value::Complex{BigFloat})
+ order>=0 || throw(DomainError(order, "Bessel order must be nonnegative"))
+ abs(value)>max(128, precision(BigFloat), order^2) ||
+ return (-im)^order*special_besselix(order, im*value)
+ real(value)<0 && return (-1)^order*special_besseljx(order, -value)
+ # DLMF 10.17.1, 10.17.5–6: combine the two scaled Hankel expansions.
+ # This avoids an angular grid proportional to a large complex argument.
+ plus=one(value)
+ minus=one(value)
+ tp=one(value)
+ tm=one(value)
+ for n in 1:100_000
+ factor=(4BigFloat(order)^2-BigFloat(2n-1)^2)/(8n*value)
+ tp*=im*factor
+ tm*=-im*factor
+ plus+=tp
+ minus+=tm
+ max(abs(tp), abs(tm))<=eps(BigFloat)*max(abs(plus), abs(minus)) && break
+ n==100_000 &&
+ throw(ErrorException("scaled BigFloat Bessel asymptotic did not converge"))
+ end
+ phase=cis(BigFloat(π)*(BigFloat(order)/2+BigFloat(1)/4))
+ growth=abs(imag(value))
+ return (exp(im*value-growth)*conj(phase)*plus +
+ exp(-im*value-growth)*phase*minus)/sqrt(2BigFloat(π)*value)
+end
+
+# SpecialFunctions omits complex BigFloat Bessel functions. The local methods
+# retain the caller's working precision for unsupported argument types.
+function special_besselix(order::Integer, value::Complex{BigFloat})
+ order >= 0 || throw(DomainError(order, "Bessel order must be nonnegative"))
+ abs(value)>max(128, precision(BigFloat), order^2) &&
+ return (-im)^order*special_besseljx(order, im*value)
+ if abs(value)>8
+ # DLMF 10.32.3, with the scaling inside the exponential. Seed the
+ # angular phase scale rather than relying on two aliased rules.
+ count=max(8, ceil(Int, abs(imag(value))+order))
+ points=collect(range(zero(BigFloat), BigFloat(π); length = count+1))
+ f=θ->exp(value*cos(θ)-abs(real(value)))*cos(order*θ)
+ result, _=quadgk(f, points; rtol = sqrt(eps(BigFloat)))
+ return result/BigFloat(π)
+ end
+ half = value / BigFloat(2)
+ term = half^order / BigFloat(factorial(big(order)))
+ result = term
+ for index in 1:100_000
+ term *= half^2 / (BigFloat(index) * BigFloat(index + order))
+ next = result + term
+ if next == result || abs(term) <= eps(BigFloat) * max(abs(next), one(BigFloat))
+ return exp(-abs(real(value))) * next
+ end
+ result = next
+ end
+ throw(ErrorException("complex BigFloat besseli series did not converge"))
+end
+
+function special_besselk(order::Integer, value::Complex{BigFloat})
+ return exp(-value)*special_besselkx(order, value)
+end
+
+function special_besselkx(order::Integer, value::Complex{BigFloat})
+ order>=0 || throw(DomainError(order, "Bessel order must be nonnegative"))
+ !iszero(value)&&real(value)>=0 || throw(DomainError(value,
+ "complex BigFloat K requires a nonzero argument on the outgoing right half-plane"))
+ # DLMF 10.32.8, w=z(t−1), rotate the w contour to the positive real
+ # axis, then w=u². This remains exponentially decaying in the lossless
+ # imaginary-argument limit. Exp(-z*cosh(t)) does not.
+ exponent=BigFloat(order)-BigFloat(1)/2
+ f=u->exp(-u*u)*u^(2order)*(1+u*u/(2value))^exponent
+ feature=min(sqrt(abs(value)), BigFloat(1)/2)
+ result,
+ _=quadgk(f, zero(BigFloat), feature, one(BigFloat), BigFloat(Inf);
+ rtol = sqrt(eps(BigFloat)))
+ return 2sqrt(BigFloat(π)/(2value))/SpecialFunctions.gamma(BigFloat(order)+BigFloat(1)/2)*result
+end
diff --git a/src/engine/textdisplay.jl b/src/engine/textdisplay.jl
new file mode 100644
index 000000000..c3e383441
--- /dev/null
+++ b/src/engine/textdisplay.jl
@@ -0,0 +1,333 @@
+_engine_type_name(value) = String(nameof(typeof(value)))
+_engine_unit(unit) = replace(Units.label(unit), "." => "·")
+
+_domain_name(::Type{PhaseDomain}) = "phase domain"
+_domain_name(::Type{ModalDomain}) = "modal domain"
+_domain_name(::Type{D}) where {D <: LineParamsDomain} = lowercase(String(nameof(D)))
+
+TextDisplay.@showfields EarthPair "EarthPair" pair -> (
+ row = pair.row,
+ column = pair.column,
+ heights = TextDisplay.engineering.(pair.heights, Ref(:meter)),
+ separation = TextDisplay.engineering(pair.separation, :meter),
+ layers = pair.layers
+)
+
+TextDisplay.@showfields SpectralIntegral "SpectralIntegral" integral -> (
+ callable = typeof(integral.f),
+)
+
+TextDisplay.@showfields AirVoltageSpectrum "AirVoltageSpectrum" kernel -> (
+ target_interface_distance = TextDisplay.engineering(kernel.geometry.hp, :meter),
+ source_interface_distance = TextDisplay.engineering(kernel.geometry.hq, :meter),
+ radius = TextDisplay.engineering(kernel.geometry.radius, :meter)
+)
+
+TextDisplay.@showfields BlueprintConductor "BlueprintConductor" row -> (
+ terminal = row.terminal,
+ assembly = row.assembly,
+ r_in = TextDisplay.engineering(row.r_in, :meter),
+ r_ex = TextDisplay.engineering(row.r_ex, :meter),
+ kind = row.material.kind
+)
+
+TextDisplay.@showfields BlueprintDielectric "BlueprintDielectric" row -> (
+ conductor = row.conductor,
+ r_in = TextDisplay.engineering(row.r_in, :meter),
+ r_ex = TextDisplay.engineering(row.r_ex, :meter),
+ kind = row.material.kind
+)
+
+TextDisplay.@showfields CableBlueprint "CableBlueprint" blueprint -> (
+ cable_id = blueprint.cable_id,
+ conductors = length(blueprint.conductors),
+ dielectrics = length(blueprint.dielectrics),
+ assemblies = length(blueprint.assembly_ranges),
+ shunt_model = blueprint.shunt_details.requested,
+ boundary_blocks = length(blueprint.shunt)
+)
+
+TextDisplay.@showfields LineCableModelsFEMError "LineCableModelsFEMError" error -> (
+ category = error.category,
+ object = error.object_id,
+ field = error.field,
+ message = error.message,
+ run_directory = error.run_directory
+)
+
+function _frequency_span(values)
+ isempty(values) && return "no points"
+ count = length(values)
+ first_value = TextDisplay.engineering(first(values), :hertz)
+ last_value = TextDisplay.engineering(last(values), :hertz)
+ return count == 1 ? "1 point · $first_value" :
+ "$count points · $first_value … $last_value"
+end
+
+TextDisplay.name(::Type{LineCableModelsCoaxial}) = "LineCableModelsCoaxial"
+Base.summary(io::IO, ::LineCableModelsCoaxial) =
+ print(io, "LineCableModels coaxial backend")
+Base.show(io::IO, ::LineCableModelsCoaxial) = print(io, "LineCableModelsCoaxial()")
+Base.show(io::IO, ::MIME"text/plain", backend::LineCableModelsCoaxial) =
+ show(io, backend)
+
+TextDisplay.name(::Type{<:LineCableModelsFEM}) = "LineCableModelsFEM"
+Base.summary(io::IO, ::LineCableModelsFEM) = print(io, "LineCableModels FEM backend")
+function Base.show(io::IO, backend::LineCableModelsFEM)
+ print(io, "LineCableModelsFEM(", length(backend.methods), " material laws)")
+end
+function Base.show(io::IO, ::MIME"text/plain", backend::LineCableModelsFEM)
+ get(io, :compact, false) && return show(io, backend)
+ selections = map(backend.methods) do selected
+ selected === nothing && return nothing
+ description(selected;compact=true)
+ end
+ return TextDisplay.fields(
+ io,
+ "LineCableModels FEM backend",
+ (; selections..., options = backend.options);
+ multiline = true
+ )
+end
+
+TextDisplay.name(::Type{PhaseDomain}) = "Phase domain"
+Base.summary(io::IO, ::PhaseDomain) = print(io, "Phase domain")
+Base.show(io::IO, ::PhaseDomain) = print(io, "PhaseDomain()")
+Base.show(io::IO, ::MIME"text/plain", value::PhaseDomain) = show(io, value)
+
+TextDisplay.name(::Type{<:ModalDomain}) = "Modal domain"
+Base.summary(io::IO, value::ModalDomain) =
+ print(io, "Modal domain, ", size(value.gamma,1), " modes")
+Base.show(io::IO, value::ModalDomain) =
+ print(io, "ModalDomain(", join(size(value.gamma),'×'), " roots)")
+function Base.show(io::IO, ::MIME"text/plain", value::ModalDomain)
+ get(io,:compact,false) && return show(io,value)
+ return TextDisplay.tree(io,"Modal domain",(
+ (label="Tv $(join(size(value.operators.Tv),'×'))",noun="fields"),
+ (label="Ti $(join(size(value.operators.Ti),'×'))",noun="fields"),
+ (label="roots $(join(size(value.gamma),'×'))",noun="fields"),
+ ))
+end
+
+function Base.summary(io::IO, formulation::AbstractFormulation)
+ print(io, _engine_type_name(formulation), " formulation")
+end
+function Base.show(io::IO, formulation::AbstractFormulation)
+ print(io, _engine_type_name(formulation), "()")
+end
+Base.show(io::IO, ::MIME"text/plain", formulation::AbstractFormulation) =
+ show(io, formulation)
+
+TextDisplay.@showfields Union{
+ InternalImpedance.Formula, InsulationImpedance.Formula,
+ EarthImpedance.Formula, InsulationAdmittance.Formula,
+ SemiconAdmittance.Formula, EarthAdmittance.Formula, PipeImpedance.Formula,
+ ShuntModel.Formula,
+} "Formula" method -> (id = formula_id(method),)
+
+TextDisplay.name(::Type{<:LineParametersFormulation}) = "LineParametersFormulation"
+Base.summary(io::IO, ::LineParametersFormulation) = print(io, "Line-parameters formulation")
+function Base.show(io::IO, formulation::LineParametersFormulation)
+ print(io, "LineParametersFormulation(", length(formulation.methods), " methods)")
+end
+
+TextDisplay.name(::Type{<:CableConstantsFormulation}) = "CableConstantsFormulation"
+Base.summary(io::IO, ::CableConstantsFormulation) =
+ print(io, "Cable-constants formulation")
+function Base.show(io::IO, formulation::CableConstantsFormulation)
+ print(io, "CableConstantsFormulation(", length(formulation.methods), " methods)")
+end
+function Base.show(io::IO, ::MIME"text/plain", formulation::CableConstantsFormulation)
+ get(io, :compact, false) && return show(io, formulation)
+ children = Tuple((
+ label = string(key, " ", sprint(show, method; context = :compact => true)),
+ noun = "methods",
+ ) for (key, method) in pairs(formulation.methods))
+ return TextDisplay.tree(io, "Cable-constants formulation", children; noun = "methods")
+end
+
+TextDisplay.name(::Type{<:CableConstantsProblem}) = "CableConstantsProblem"
+Base.summary(io::IO, ::CableConstantsProblem) = print(io, "Cable-constants problem")
+function Base.show(io::IO, problem::CableConstantsProblem)
+ print(io, "CableConstantsProblem(temperature=", problem.temperature,
+ ", frequency=", problem.frequency, ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", problem::CableConstantsProblem)
+ get(io, :compact, false) && return show(io, problem)
+ return TextDisplay.tree(io, "CableConstantsProblem", (
+ (label = "design $(problem.design.cable_id)", noun = "fields"),
+ (label = "temperature $(TextDisplay.engineering(problem.temperature, :celsius))", noun = "fields"),
+ (label = "frequency $(TextDisplay.engineering(problem.frequency, :hertz))", noun = "fields"),
+ ))
+end
+
+TextDisplay.name(::Type{<:CableConstants}) = "CableConstants"
+Base.summary(io::IO, constants::CableConstants) =
+ print(io, "CableConstants, ", length(constants), " assemblies")
+function Base.show(io::IO, constants::CableConstants)
+ print(io, "CableConstants(assemblies=", length(constants),
+ ", frequency=", constants.frequency, ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", constants::CableConstants)
+ get(io, :compact, false) && return show(io, constants)
+ rows = Tuple((
+ label = string(
+ constants.cores[index], " R=", constants.R[index],
+ " Ω/m L=", constants.L[index], " H/m C=", constants.C[index],
+ " F/m G=", constants.G[index], " S/m"
+ ),
+ noun = "assemblies",
+ ) for index in eachindex(constants.cores))
+ return TextDisplay.tree(io,
+ "Cable constants · $(constants.frequency) Hz", rows; noun = "assemblies")
+end
+function Base.show(io::IO, ::MIME"text/plain", formulation::LineParametersFormulation)
+ get(io, :compact, false) && return show(io, formulation)
+ children = Any[(
+ label = string(key, " ", sprint(show, method; context = :compact => true)),
+ noun = "methods",
+ ) for (key, method) in pairs(formulation.methods)]
+ isempty(formulation.options.data) || push!(children, (
+ label = "options $(length(formulation.options.data)) entries",
+ noun = "methods",
+ ))
+ return TextDisplay.tree(io, "Line-parameters formulation", Tuple(children); noun = "methods")
+end
+
+TextDisplay.name(::Type{<:LineParametersProblem}) = "LineParametersProblem"
+Base.summary(io::IO, problem::LineParametersProblem) =
+ print(io, "Line-parameters problem over $(length(problem.frequencies)) frequencies")
+function Base.show(io::IO, problem::LineParametersProblem)
+ print(io, "LineParametersProblem(cables=", ncables(problem.system),
+ ", frequencies=", length(problem.frequencies), ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", problem::LineParametersProblem)
+ get(io, :compact, false) && return show(io, problem)
+ children = (
+ (label = "system $(sprint(show, problem.system; context = :compact => true))", noun = "fields"),
+ (label = "temperature $(TextDisplay.engineering(problem.temperature, :celsius))", noun = "fields"),
+ (label = "earth $(sprint(show, problem.earth_props; context = :compact => true))", noun = "fields"),
+ (label = "f $(_frequency_span(problem.frequencies))", noun = "fields"),
+ )
+ return TextDisplay.tree(io, "LineParametersProblem", children)
+end
+
+function _array_summary(name, value, selector)
+ return string(
+ name, "(", join(size(value), '×'), "; unit=",
+ _engine_unit(_result_unit(value, selector)), ")"
+ )
+end
+
+TextDisplay.name(::Type{<:SeriesImpedance}) = "SeriesImpedance"
+Base.summary(io::IO, value::SeriesImpedance) =
+ print(io, "Series impedance, ", join(size(value), '×'))
+Base.show(io::IO, value::SeriesImpedance) =
+ print(io, _array_summary("SeriesImpedance", value, Z))
+function Base.show(io::IO, ::MIME"text/plain", value::SeriesImpedance)
+ get(io, :compact, false) && return show(io, value)
+ TextDisplay.tree(io, "Series impedance · $(join(size(value), '×'))", (
+ (label = "unit $(_engine_unit(_result_unit(value, Z)))", noun = "fields"),
+ (label = "values summarized; use observables for extraction", noun = "fields"),
+ ))
+end
+
+TextDisplay.name(::Type{<:ShuntAdmittance}) = "ShuntAdmittance"
+Base.summary(io::IO, value::ShuntAdmittance) =
+ print(io, "Shunt admittance, ", join(size(value), '×'))
+Base.show(io::IO, value::ShuntAdmittance) =
+ print(io, _array_summary("ShuntAdmittance", value, Y))
+function Base.show(io::IO, ::MIME"text/plain", value::ShuntAdmittance)
+ get(io, :compact, false) && return show(io, value)
+ TextDisplay.tree(io, "Shunt admittance · $(join(size(value), '×'))", (
+ (label = "unit $(_engine_unit(_result_unit(value, Y)))", noun = "fields"),
+ (label = "values summarized; use observables for extraction", noun = "fields"),
+ ))
+end
+
+TextDisplay.name(::Type{<:LineParameters}) = "LineParameters"
+function Base.summary(io::IO, parameters::LineParameters)
+ print(io, "LineParameters, ", nconductors(parameters), '×', nconductors(parameters),
+ " over ", nfrequencies(parameters), " frequencies")
+end
+function Base.show(io::IO, parameters::LineParameters)
+ print(io, "LineParameters(", _domain_name(domain(parameters)), "; ",
+ nconductors(parameters), '×', nconductors(parameters), '×',
+ nfrequencies(parameters), ", basis=:", basis(parameters), ")")
+ has_uncertainty_type(eltype(parameters)) && print(io, " ±")
+end
+function Base.show(io::IO, ::MIME"text/plain", parameters::LineParameters)
+ get(io, :compact, false) && return show(io, parameters)
+ zunit = _engine_unit(_result_unit(parameters, Z))
+ yunit = _engine_unit(_result_unit(parameters, Y))
+ shape = join(size(parameters.Z), '×')
+ children = (
+ (label = "f $(_frequency_span(parameters.f))", noun = "fields"),
+ (label = "Z $shape · $zunit", noun = "fields"),
+ (label = "Y $shape · $yunit", noun = "fields"),
+ )
+ if parameters.domain isa ModalDomain
+ state=parameters.domain
+ children=(children...,
+ (label="Tv/Ti $(join(size(state.operators.Tv),'×'))",noun="fields"),
+ (label="roots $(join(size(state.gamma),'×'))",noun="fields"))
+ if haskey(parameters.details.data,:modal) &&
+ haskey(parameters.details.data.modal,:diagnostics)
+ diagnostics=parameters.details.data.modal.diagnostics
+ children=(children...,
+ (label="numerical targets $(length(diagnostics.missed_frequencies)) missed, $(length(diagnostics.fallback_frequencies)) matched fallback",noun="fields"))
+ end
+ end
+ return TextDisplay.tree(io, "LineParameters · $(_domain_name(domain(parameters)))", children)
+end
+
+TextDisplay.name(::Type{<:RMSError}) = "RMSError"
+Base.summary(io::IO, error::RMSError) =
+ print(io, "RMS error, ", join(size(error.absolute), '×'))
+function Base.show(io::IO, error::RMSError)
+ print(io, "RMSError(", join(size(error.absolute), '×'), ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", error::RMSError)
+ get(io, :compact, false) && return show(io, error)
+ return TextDisplay.tree(io, "RMS error · $(join(size(error.absolute), '×'))", (
+ (label = "absolute matrix summarized", noun = "fields"),
+ (label = "relative matrix summarized", noun = "fields"),
+ ))
+end
+
+TextDisplay.name(::Type{<:LineParametersBenchmark}) = "LineParametersBenchmark"
+Base.summary(io::IO, benchmark::LineParametersBenchmark) =
+ print(io, "Line-parameters benchmark, ", join(size(benchmark.Z.absolute), '×'))
+function Base.show(io::IO, benchmark::LineParametersBenchmark)
+ print(io, "LineParametersBenchmark(", join(size(benchmark.Z.absolute), '×'),
+ "; basis=:", basis(benchmark), ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", benchmark::LineParametersBenchmark)
+ get(io, :compact, false) && return show(io, benchmark)
+ shape = join(size(benchmark.Z.absolute), '×')
+ return TextDisplay.tree(io, "Line-parameters benchmark", (
+ (label = "Z $shape RMS errors", noun = "fields"),
+ (label = "Y $shape RMS errors", noun = "fields"),
+ (label = "basis :$(basis(benchmark))", noun = "fields"),
+ ))
+end
+
+TextDisplay.name(::Type{<:LineParametersWorkspace}) = "LineParametersWorkspace"
+Base.summary(io::IO, workspace::LineParametersWorkspace) =
+ print(io, "Line-parameters workspace")
+function Base.show(io::IO, workspace::LineParametersWorkspace)
+ input = workspace.input
+ print(io, "LineParametersWorkspace(phases=", input.n_phases,
+ ", cables=", input.n_cables,
+ ", frequencies=", input.n_frequencies, ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", workspace::LineParametersWorkspace)
+ get(io, :compact, false) && return show(io, workspace)
+ input = workspace.input
+ return TextDisplay.tree(io, "Line-parameters workspace", (
+ (label = "phases $(input.n_phases)", noun = "fields"),
+ (label = "cables $(input.n_cables)", noun = "fields"),
+ (label = "frequencies $(input.n_frequencies)", noun = "fields"),
+ (label = "trace $(workspace.trace === nothing ? "disabled" : "enabled")", noun = "fields"),
+ ))
+end
diff --git a/src/engine/transforms/Transforms.jl b/src/engine/transforms/Transforms.jl
deleted file mode 100644
index f2540a63d..000000000
--- a/src/engine/transforms/Transforms.jl
+++ /dev/null
@@ -1,44 +0,0 @@
-"""
- LineCableModels.Engine.Transforms
-
-# Dependencies
-
-$(IMPORTS)
-
-# Exports
-
-$(EXPORTS)
-"""
-module Transforms
-
-# Export public API
-export Fortescue
-
-# Module-specific dependencies
-using ...Commons
-import ...Commons: get_description, PhaseDomain, ModalDomain
-import ...Utils: symtrans, 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")
-
-function (F::AbstractTransformFormulation)(
- lp::LineParameters{Tc, U, ModalDomain},
-) where {Tc <: COMPLEXSCALAR, U <: REALSCALAR}
- throw(
- ErrorException(
- "Not yet implemented: inverse $(nameof(typeof(F)))( ::LineParameters{<:COMPLEXSCALAR,<:REALSCALAR,ModalDomain} )",
- ),
- )
-end
-
-end # module Transforms
diff --git a/src/engine/transforms/eiglevenberg.jl b/src/engine/transforms/eiglevenberg.jl
deleted file mode 100644
index d20050b14..000000000
--- a/src/engine/transforms/eiglevenberg.jl
+++ /dev/null
@@ -1,367 +0,0 @@
-struct Levenberg <: AbstractTransformFormulation
- tol::BASE_FLOAT
-end
-
-# Convenient ctor
-Levenberg(; tol::BASE_FLOAT = BASE_FLOAT(1e-8)) = Levenberg(tol)
-
-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 per-unit-length** impedance/admittance (diagonal per frequency).
-
-# Arguments
-
-- `lp`: Phase-domain line parameters (series `Z`, shunt `Y`, and `f`).
-- `f::Levenberg`: Functor with solver tolerance.
-
-# Returns
-
-- `Ti`: Transformation matrices `T(•)` as a 3-tensor `n×n×nfreq` (columns are modes).
-- `LineParameters`: Modal-domain per-unit-length parameters:
- - Series impedance `Zm` (diagonal per frequency) \\[Ω/m\\].
- - Shunt admittance `Ym` (diagonal per frequency) \\[S/m\\].
-
-"""
-function (f::Levenberg)(
- lp::LineParameters{Tc, U, PhaseDomain},
-) where {Tc <: COMPLEXSCALAR, U <: REALSCALAR}
- 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)
-
- Zm = similar(lp.Z.values)
- Ym = similar(lp.Y.values)
-
- Tk = zeros(Tc, n, n)
- Zk = zeros(Tc, n, n)
- Yk = zeros(Tc, n, n)
- invT = zeros(Tc, n, n)
-
- @inbounds for k in 1:nfreq
- Tk .= @view Ti[:, :, k]
- invT .= inv(Tk)
- @views begin # enforce reciprocity
- copyto!(Zk, lp.Z.values[:, :, k]);
- symtrans!(Zk)
- copyto!(Yk, lp.Y.values[:, :, k]);
- symtrans!(Yk)
- end
- # Modal matrices (carry uncertainties)
- @views Zm[:, :, k] .= transpose(Tk) * Zk * Tk
- @views Ym[:, :, k] .= invT * Yk * transpose(invT)
-
- fname = String(nameof(typeof(f)))
- offdiagZ = offdiag_ratio(Zm[:, :, k])
- if offdiagZ > f.tol
- @warn "$fname: transformed Z not diagonal within tolerance, check your results" ratio =
- offdiagZ
- else
- @views Zm[:, :, k] .= Diagonal(diag(Zm[:, :, k])) # enforce exact diagonal
- end
- offdiagY = offdiag_ratio(Ym[:, :, k])
- if offdiagY > f.tol
- @warn "$fname: transformed Y not diagonal within tolerance, check your results" ratio =
- offdiagY
- else
- @views Ym[:, :, k] .= Diagonal(diag(Ym[:, :, k])) # enforce exact diagonal
- end
- end
- # 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)
-
- return Ti, LineParameters(ModalDomain, SeriesImpedance(Zm), ShuntAdmittance(Ym), lp.f)
- # Keep 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
deleted file mode 100644
index 2d8ecf9f1..000000000
--- a/src/engine/transforms/fortescue.jl
+++ /dev/null
@@ -1,54 +0,0 @@
-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, U, PhaseDomain},
-) where {Tc <: COMPLEXSCALAR, U <: REALSCALAR}
- _, 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)
-
- @inbounds for k in 1:nfreq
- Zs = symtrans(lp.Z.values[:, :, k]) # enforce reciprocity
- Ys = symtrans(lp.Y.values[:, :, k])
-
- Zseq = Tv * Zs * Tv'
- Yseq = Tv * Ys * Tv'
-
- fname = String(nameof(typeof(f)))
- offdiagZ = offdiag_ratio(Zseq)
- if offdiagZ > f.tol
- @warn "$fname: transformed Z not diagonal within tolerance, check your results" ratio =
- offdiagZ
- end
- offdiagY = offdiag_ratio(Yseq)
- if offdiagY > f.tol
- @warn "$fname: transformed Y not diagonal within tolerance, check your results" ratio =
- offdiagY
- end
-
- Z012[:, :, k] = Matrix(Diagonal(diag(Zseq)))
- Y012[:, :, k] = Matrix(Diagonal(diag(Yseq)))
- end
- return Tv, LineParameters(ModalDomain, 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
deleted file mode 100644
index ec6582dfb..000000000
--- a/src/engine/types.jl
+++ /dev/null
@@ -1,44 +0,0 @@
-"""
-$(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
-
-
diff --git a/src/engine/workspace.jl b/src/engine/workspace.jl
deleted file mode 100644
index 2e4ecb49c..000000000
--- a/src/engine/workspace.jl
+++ /dev/null
@@ -1,239 +0,0 @@
-"""
-$(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}
- "Physical insulation-layer indices for each cable component."
- insulator_layer_ranges::Vector{UnitRange{Int}}
- "Vector of physical insulation-layer inner radii \\[m\\]."
- r_ins_layer_in::Vector{T}
- "Vector of physical insulation-layer outer radii \\[m\\]."
- r_ins_layer_ext::Vector{T}
- "Vector of physical insulation-layer resistivities \\[Ω·m\\]."
- rho_ins_layer::Vector{T}
- "Vector of physical insulation-layer relative permittivities \\[dimensionless\\]."
- eps_ins_layer::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
- "Line length [m]."
- line_length::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_insulator_layers = sum(
- length(component.insulator_group.layers)
- for cable in system.cables
- for component in cable.design_data.components
- )
- 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
- insulator_layer_ranges = Vector{UnitRange{Int}}(undef, n_phases)
- r_ins_layer_in = Vector{T}(undef, n_insulator_layers)
- r_ins_layer_ext = Vector{T}(undef, n_insulator_layers)
- rho_ins_layer = Vector{T}(undef, n_insulator_layers)
- eps_ins_layer = Vector{T}(undef, n_insulator_layers)
- 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
- layer_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.r_in)
- r_ext[idx] = T(component.conductor_group.r_ex)
- r_ins_in[idx] = T(component.insulator_group.r_in)
- r_ins_ext[idx] = T(component.insulator_group.r_ex)
-
- # 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)
-
- # Preserve the physical dielectric stack for broadband lossy models.
- first_layer_idx = layer_idx + 1
- for layer in component.insulator_group.layers
- layer_idx += 1
- r_ins_layer_in[layer_idx] = T(layer.r_in)
- r_ins_layer_ext[layer_idx] = T(layer.r_ex)
- rho_ins_layer[layer_idx] = T(layer.material_props.rho)
- eps_ins_layer[layer_idx] = T(layer.material_props.eps_r)
- end
- insulator_layer_ranges[idx] = first_layer_idx:layer_idx
-
- # 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)
- line_length = T(problem.system.line_length)
-
- # 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, insulator_layer_ranges = insulator_layer_ranges,
- r_ins_layer_in = r_ins_layer_in, r_ins_layer_ext = r_ins_layer_ext,
- rho_ins_layer = rho_ins_layer, eps_ins_layer = eps_ins_layer,
- phase_map = phase_map, cable_map = cable_map, rho_g = rho_g,
- eps_g = eps_g, mu_g = mu_g,
- temp = temp, line_length = line_length, 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/formulas.jl b/src/formulas.jl
new file mode 100644
index 000000000..575151b64
--- /dev/null
+++ b/src/formulas.jl
@@ -0,0 +1,169 @@
+"""
+$(TYPEDSIGNATURES)
+
+Select a registered formula. The receiving formulation determines its family from the
+keyword slot in which the selection appears.
+
+# Arguments
+
+- `identifier`: stable formula identifier.
+ `:default` routes to the defining family's explicit default implementation for
+ the chosen backend. The resulting selection is checked against the problem
+ before computation.
+ Cable-insulation and semicon-admittance `:default` selections route to the
+ explicit `:lossless` dielectric relation. Unsupported contexts fail before
+ frequency evaluation.
+
+# Keywords
+
+- `order`: position of an equivalent homogeneous-earth reduction relative to
+ material frequency dependence. `:before` applies EquivalentHomogeneous before FrequencyDependent, `:after`
+ applies EquivalentHomogeneous after FrequencyDependent, and `:default` selects the receiving formulation's
+ default. Non-EquivalentHomogeneous formula slots accept only `:default`.
+- `parameters=(;)`: explicit model parameters accepted by the defining formula.
+- `options=(;)`: formulation-owned physical choices and numerical controls, such
+ as Unified's `Γ` \\[1/m\\] and `integration=(method=:quad, options=(;))`.
+- `equivalent_earth=nothing`: explicit reduction for a compatible external formula.
+
+# Returns
+
+- A concrete declarative selection resolved before computation.
+
+# Examples
+
+```julia
+earth = formula(:carson1926)
+soil = formula(:default)
+equivalent = formula(:default; order=:before)
+```
+"""
+function formula(identifier::Symbol; order::Symbol = :default,
+ parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions(), equivalent_earth = nothing)
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ order in (:default, :before, :after) || throw(ArgumentError(
+ "formula order must be :default, :before, or :after"
+ ))
+ return FormulaDefinition{identifier, order, typeof(parameters),
+ typeof(options), typeof(equivalent_earth)}(
+ parameters, options, equivalent_earth)
+end
+
+"Return the selected formulation's identifier."
+formula_id(expression::Expression) = formula_id(expression.selection)
+
+formula_id(::FormulaDefinition{ID}) where {ID} = ID
+
+"""Expose a requested formula identifier and its explicit model and numerical controls."""
+function Base.NamedTuple(value::FormulaDefinition{ID,Order}) where {ID,Order}
+ return (identifier=ID, order=Order, parameters=value.parameters,
+ options=value.options.data, equivalent_earth=value.equivalent_earth === nothing ? nothing : NamedTuple(value.equivalent_earth))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Describe a retained selection through its defining type.
+"""
+description(source::Pair{<:Type,<:NamedTuple}; compact::Bool=false) = description(first(source); compact)
+formula_id(source::Pair{<:Type,<:NamedTuple}) = formula_id(first(source))
+
+description(::Missing; compact::Bool=false) = "method unavailable"
+description(::Nothing; compact::Bool=false) = "none"
+
+"""Describe a scalar, explicitly selected control at its defining formula."""
+description(::Type, ::Val{key}, value::Union{Number,Symbol,AbstractString};
+ compact::Bool=false) where {key} = string(key,"=",value)
+formula_id(::Missing) = missing
+formula_id(::Nothing) = :none
+
+"""
+$(TYPEDSIGNATURES)
+
+Label ordered formulation selections using their owner-supplied descriptions.
+
+# Arguments
+
+- `sources`: native formulations or owner-bound retained declarations.
+
+# Keywords
+
+- `roles`: one `:reference`, `:result`, or `:none` role per source.
+- `quantity`: physical quantity selected through the observation grammar, or
+ `nothing` for all equation choices.
+- `compact=true`: use the short owned names for both root and child selections.
+
+# Returns
+
+- One text label per source, in input order, without result numbering.
+ Consumers select unique quantity-relevant formulations before presentation.
+
+# Notes
+
+Owners expose native children through `pairs(source; quantity)` and retained
+children through `pairs(owner, record; quantity)`, scoped
+identity through `formula_id`, explicit controls in those selection pairs, and
+text through `description`. This formatter only decides common-field omission
+and reference prefixes. Unsupported owner methods are not caught.
+"""
+function description(sources::AbstractVector;
+ roles=fill(:result,length(sources)),
+ quantity=nothing, compact::Bool=true)
+ length(roles)==length(sources) ||
+ throw(DimensionMismatch("one role is required per formulation"))
+ all(in((:reference,:result,:none)),roles) ||
+ throw(ArgumentError("description roles must be reference, result, or none"))
+ isempty(sources) && return String[]
+ retained(value) = value isa Pair ? last(value) : (;)
+ selections=[ismissing(source) ? Pair[] : collect(pairs((source isa Pair ? Tuple(source) : (source,))...; quantity)) for source in sources]
+ complete=[ismissing(source) ? Pair[] : collect(pairs((source isa Pair ? Tuple(source) : (source,))...)) for source in sources]
+ formulation_indices=findall(!=(:reference),roles)
+ # Scientific comparisons use owner-scoped identifiers, never display text.
+ scopes=unique([scope for index in formulation_indices for (scope,_) in complete[index]
+ if !isempty(last(scope))])
+ varying=filter(scopes) do scope
+ values=[[(formula_id(value),retained(value)) for (key,value) in complete[index]
+ if key==scope] for index in formulation_indices]
+ !all(value -> isequal(value,first(values)),values)
+ end
+ identities=[[scope for (scope,_) in entries if isempty(last(scope))] for entries in complete]
+ formulation_owners=unique(first(ids) for ids in identities[formulation_indices] if !isempty(ids))
+ inner_owners=unique(last(ids) for ids in identities if length(ids)>1)
+ return map(eachindex(sources)) do index
+ prefix=roles[index]===:reference ? "Reference" : ""
+ parts=String[]
+ for (scope,value) in selections[index]
+ if isempty(last(scope))
+ position=findfirst(==(scope),identities[index])
+ show_identity=position==1 ?
+ (roles[index]!==:result || length(identities[index])>1 || length(formulation_owners)>1) :
+ length(inner_owners)>1
+ show_identity && push!(parts,description(value;compact))
+ controls=retained(value)
+ peer=[other for entries in selections for (key,other) in entries if key==scope]
+ if !isempty(controls) && any(other -> !isequal(retained(other),controls),peer)
+ push!(parts,description(scope,value;compact,settings=true))
+ end
+ else
+ peer=[other for entries in selections for (key,other) in entries if key==scope]
+ changed=any(other -> !isequal((formula_id(other),retained(other)),
+ (formula_id(value),retained(value))),peer)
+ standalone=length(sources)==1 && !ismissing(formula_id(value)) &&
+ formula_id(value)!==:none
+ # Common scalar choices are omitted independently of their IDs.
+ # Branch structure and controls remain visible in comparisons.
+ # A standalone description shows its concrete selections.
+ (length(last(scope))>1 || !isempty(retained(value)) ||
+ scope in varying || changed || standalone) &&
+ push!(parts,description(scope,value;compact))
+ end
+ end
+ if isempty(parts)
+ unavailable=ismissing(sources[index]) ||
+ any(entry -> ismissing(formula_id(last(entry))),selections[index])
+ push!(parts,unavailable ? description(missing;compact) : description(sources[index];compact))
+ end
+ text=join(parts,"; ")
+ isempty(prefix) ? text : isempty(text) ? prefix : prefix*" · "*text
+ end
+end
diff --git a/src/grid.jl b/src/grid.jl
new file mode 100644
index 000000000..d06fbe059
--- /dev/null
+++ b/src/grid.jl
@@ -0,0 +1,310 @@
+import Base: eltype, extrema, getindex, iterate, length, rand, size
+import Random
+
+"""
+$(TYPEDEF)
+
+Supertype for the deterministic and uncertainty-bearing finite sources created
+by [`Grid`](@ref).
+"""
+abstract type AbstractGrid end
+
+"""
+$(TYPEDEF)
+
+Supertype for finite sources whose points retain an uncertainty descriptor.
+"""
+abstract type AbstractUncertainGrid <: AbstractGrid end
+
+"""
+$(TYPEDEF)
+
+Store a nominal value and its absolute standard uncertainty.
+
+$(TYPEDFIELDS)
+"""
+struct UncertainValue{T, E}
+ "Nominal parameter value."
+ nominal::T
+
+ "Absolute standard uncertainty in the same unit as `nominal`."
+ sigma::E
+
+ function UncertainValue(nominal::T, sigma::E) where {T, E}
+ if nominal isa Real && sigma isa Real
+ isfinite(nominal) || throw(ArgumentError(
+ "uncertain nominal values must be finite; got $nominal",
+ ))
+ isfinite(sigma) || throw(ArgumentError(
+ "uncertainty must be finite; got $sigma",
+ ))
+ sigma >= zero(sigma) || throw(ArgumentError(
+ "uncertainty must be nonnegative; got $sigma",
+ ))
+ end
+ return new{T, E}(nominal, sigma)
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the nominal value stored in an [`UncertainValue`](@ref).
+"""
+nominal(value::UncertainValue) = value.nominal
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the absolute standard uncertainty stored in an
+[`UncertainValue`](@ref), in the same unit as its nominal value.
+"""
+uncertainty(value::UncertainValue) = value.sigma
+
+"""
+$(TYPEDEF)
+
+Represent one finite source of deterministic values.
+
+$(TYPEDFIELDS)
+"""
+struct DeterministicGrid{V <: Tuple} <: AbstractGrid
+ "Values admitted by the source."
+ vals::V
+end
+
+"""
+$(TYPEDEF)
+
+Represent the Cartesian product of nominal values and relative standard
+uncertainties.
+
+$(TYPEDFIELDS)
+"""
+struct RelativeGrid{V <: Tuple, P <: Tuple} <: AbstractUncertainGrid
+ "Nominal values admitted by the source."
+ vals::V
+
+ "Relative standard uncertainties expressed as percentages."
+ rel_err::P
+
+ function RelativeGrid(vals::V, rel_err::P) where {V <: Tuple, P <: Tuple}
+ return validate(new{V, P}(vals, rel_err))
+ end
+end
+
+"""
+$(TYPEDEF)
+
+Represent the Cartesian product of nominal values and absolute standard
+uncertainties.
+
+$(TYPEDFIELDS)
+"""
+struct AbsoluteGrid{V <: Tuple, P <: Tuple} <: AbstractUncertainGrid
+ "Nominal values admitted by the source."
+ vals::V
+
+ "Absolute standard uncertainties in the same unit as `vals`."
+ abs_err::P
+
+ function AbsoluteGrid(vals::V, abs_err::P) where {V <: Tuple, P <: Tuple}
+ return validate(new{V, P}(vals, abs_err))
+ end
+end
+
+"""
+$(TYPEDEF)
+
+Mark values as absolute standard uncertainties for
+`Grid(values, AbsoluteError(...))`.
+
+$(TYPEDFIELDS)
+"""
+struct AbsoluteError{T <: Tuple}
+ "Absolute standard uncertainties."
+ vals::T
+
+ function AbsoluteError(vals::T) where {T <: Tuple}
+ validate(vals, AbsoluteError)
+ return new{T}(vals)
+ end
+end
+
+_grid_values(value::Tuple) = value
+_grid_values(value::AbstractArray) = Tuple(value)
+_grid_values(value) = (value,)
+
+# Standard uncertainties of the record being built. Relative uncertainties are percentages.
+function validate(errors::Tuple,
+ ::Type{R}) where {R <: Union{AbsoluteError, AbsoluteGrid, RelativeGrid}}
+ kind = R <: RelativeGrid ? "relative" : "absolute"
+ isempty(errors) && throw(ArgumentError("$kind uncertainty cannot be empty"))
+ for error in errors
+ error isa Real ||
+ throw(ArgumentError("$kind errors must be real; got $(typeof(error))"))
+ isfinite(error) || throw(ArgumentError("$kind errors must be finite; got $error"))
+ error >= zero(error) ||
+ throw(ArgumentError("$kind errors must be nonnegative; got $error"))
+ end
+ return errors
+end
+
+function validate(grid::Union{RelativeGrid, AbsoluteGrid})
+ kind, errors = grid isa RelativeGrid ? ("relative", grid.rel_err) :
+ ("absolute", grid.abs_err)
+ values = grid.vals
+ isempty(values) &&
+ throw(ArgumentError("$kind uncertainty cannot have an empty nominal source"))
+ validate(errors, typeof(grid))
+ for value in values
+ value isa Real || throw(ArgumentError(
+ "$kind uncertainty requires real nominal values; got $(typeof(value))",
+ ))
+ isfinite(value) ||
+ throw(ArgumentError("$kind nominal values must be finite; got $value"))
+ end
+ return grid
+end
+
+AbsoluteError(value) = AbsoluteError(_grid_values(value))
+
+"""
+$(TYPEDSIGNATURES)
+
+Create an explicit finite source. Collections vary only when passed to
+`Grid`. Ordinary builder arguments remain atomic domain values.
+"""
+Grid(grid::AbstractGrid) = grid
+Grid(value) = DeterministicGrid(_grid_values(value))
+Grid(value, error::AbsoluteError) = AbsoluteGrid(_grid_values(value), error.vals)
+function Grid(value, relative_error)
+ RelativeGrid(
+ _grid_values(value),
+ _grid_values(relative_error)
+ )
+end
+
+iterate(grid::DeterministicGrid, state...) = iterate(grid.vals, state...)
+length(grid::DeterministicGrid) = length(grid.vals)
+size(grid::DeterministicGrid) = (length(grid),)
+getindex(grid::DeterministicGrid, index::Integer) = grid.vals[index]
+eltype(::Type{<:DeterministicGrid{V}}) where {V} = eltype(V)
+#! explicit-imports: off
+# Base's iterator trait protocol exposes this value without a public binding.
+Base.IteratorSize(::Type{<:DeterministicGrid}) = Base.HasShape{1}()
+#! explicit-imports: on
+
+function iterate(grid::RelativeGrid, state...)
+ item = iterate(Iterators.product(grid.vals, grid.rel_err), state...)
+ item === nothing && return nothing
+ (value, percent), next_state = item
+ return UncertainValue(value, abs(value) * percent / 100), next_state
+end
+
+function iterate(grid::AbsoluteGrid, state...)
+ item = iterate(Iterators.product(grid.vals, grid.abs_err), state...)
+ item === nothing && return nothing
+ (value, error), next_state = item
+ return UncertainValue(value, error), next_state
+end
+
+length(grid::RelativeGrid) = length(grid.vals) * length(grid.rel_err)
+length(grid::AbsoluteGrid) = length(grid.vals) * length(grid.abs_err)
+size(grid::AbstractUncertainGrid) = (length(grid),)
+#! explicit-imports: off
+# Base's iterator trait protocol exposes this value without a public binding.
+Base.IteratorSize(::Type{<:AbstractUncertainGrid}) = Base.HasShape{1}()
+#! explicit-imports: on
+
+function getindex(grid::RelativeGrid, index::Integer)
+ 1 <= index <= length(grid) || throw(BoundsError(grid, index))
+ value_index = mod1(index, length(grid.vals))
+ error_index = cld(index, length(grid.vals))
+ value = grid.vals[value_index]
+ return UncertainValue(value, abs(value) * grid.rel_err[error_index] / 100)
+end
+
+function getindex(grid::AbsoluteGrid, index::Integer)
+ 1 <= index <= length(grid) || throw(BoundsError(grid, index))
+ value_index = mod1(index, length(grid.vals))
+ error_index = cld(index, length(grid.vals))
+ return UncertainValue(grid.vals[value_index], grid.abs_err[error_index])
+end
+
+extrema(grid::DeterministicGrid) = extrema(grid.vals)
+
+function extrema(grid::RelativeGrid)
+ bounds = map(Iterators.product(grid.vals, grid.rel_err)) do (value, percent)
+ delta = abs(value) * percent / 100
+ (value - delta, value + delta)
+ end
+ return minimum(first, bounds), maximum(last, bounds)
+end
+
+function extrema(grid::AbsoluteGrid)
+ bounds = map(Iterators.product(grid.vals, grid.abs_err)) do (value, error)
+ (value - error, value + error)
+ end
+ return minimum(first, bounds), maximum(last, bounds)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Draw one scalar realization from an uncertainty descriptor using a caller-owned
+random number generator and the selected sampling family.
+"""
+function sample_uncertainty(
+ rng::Random.AbstractRNG,
+ value::UncertainValue{<:Real, <:Real},
+ distribution::Symbol
+)
+ distribution === :normal && return value.nominal + value.sigma * randn(rng)
+ distribution === :uniform &&
+ return value.nominal + sqrt(3) * value.sigma * (2 * rand(rng) - 1)
+ throw(ArgumentError(
+ "unsupported distribution :$distribution; expected :normal or :uniform",
+ ))
+end
+
+function sample_uncertainty(
+ rng::Random.AbstractRNG,
+ value::UncertainValue{<:Real},
+ sampler::Function
+)
+ sampler(rng, value.nominal, value.sigma)
+end
+
+function sample_uncertainty(::Random.AbstractRNG, ::UncertainValue, distribution)
+ throw(ArgumentError(
+ "unsupported distribution $(typeof(distribution)); load its package extension or pass a sampler function",
+ ))
+end
+
+function rand(
+ rng::Random.AbstractRNG,
+ value::UncertainValue{<:Real};
+ distribution = :normal
+)
+ iszero(value.sigma) && return float(value.nominal)
+ return sample_uncertainty(rng, value, distribution)
+end
+
+function rand(value::UncertainValue{<:Real}; distribution = :normal)
+ return rand(Random.default_rng(), value; distribution)
+end
+
+function rand(
+ rng::Random.AbstractRNG,
+ grid::AbstractGrid;
+ distribution = :normal
+)
+ isempty(grid) && throw(ArgumentError("cannot sample an empty Grid"))
+ value = grid[rand(rng, 1:length(grid))]
+ return value isa UncertainValue ? rand(rng, value; distribution) : value
+end
+
+function rand(grid::AbstractGrid; distribution = :normal)
+ return rand(Random.default_rng(), grid; distribution)
+end
diff --git a/src/gridspace.jl b/src/gridspace.jl
new file mode 100644
index 000000000..ed02975c5
--- /dev/null
+++ b/src/gridspace.jl
@@ -0,0 +1,397 @@
+"""
+Concrete callable representation of a type constructor used by a Gridspace.
+"""
+struct _TypeConstructor{Target} end
+
+(::_TypeConstructor{Target})(arguments...) where {Target} = Target(arguments...)
+
+_gridspace_callable(build) = build
+_gridspace_callable(::Type{Target}) where {Target} = _TypeConstructor{Target}()
+
+"""
+$(TYPEDEF)
+
+Represent a typed finite space assembled from explicit [`Grid`](@ref), nested
+`Gridspace` or admitted completed result-space sources. `combine` is encoded
+in the type and may be `:product` or `:zip`. `Target` identifies the semantic
+result family. A nonempty deterministic space records the concrete type
+returned by its callable when Julia can prove it without evaluating a point.
+Otherwise the space declares its iterator element type unknown until values
+are materialized.
+
+$(TYPEDFIELDS)
+"""
+struct Gridspace{Target, F, G <: Tuple, C, R}
+ "Callable that constructs `Target` from one selected argument tuple."
+ build::F
+
+ "Explicit Grid or nested Gridspace sources."
+ grids::G
+end
+
+"""
+Sentinel result type for a Gridspace whose materialized element type is unknown.
+"""
+struct _UnknownGridspaceEltype end
+
+const _GridspaceSource = Union{AbstractGrid, Gridspace, AbstractResultSpace}
+
+function _gridspace_eltype(::Type{Tuple}, ::typeof(tuple), grids::Tuple)
+ (any(has_uncertainty, grids) || any(grid -> iszero(length(grid)), grids)) &&
+ return _UnknownGridspaceEltype
+ argument_types = map(grid -> eltype(typeof(grid)), grids)
+ all(isconcretetype, argument_types) || return _UnknownGridspaceEltype
+ return Tuple{argument_types...}
+end
+
+function _gridspace_eltype(::Type{Target}, build, grids::Tuple) where {Target}
+ (any(has_uncertainty, grids) || any(grid -> iszero(length(grid)), grids)) &&
+ return _UnknownGridspaceEltype
+
+ argument_types = map(grid -> eltype(typeof(grid)), grids)
+ inferred = Base.code_typed(
+ build,
+ Tuple{argument_types...};
+ optimize = false
+ )
+ result_type = isempty(inferred) ? Any :
+ foldl(typejoin, (last(entry) for entry in inferred))
+ isconcretetype(result_type) || return _UnknownGridspaceEltype
+ result_type <: Target || throw(ArgumentError(
+ "Gridspace callable result $result_type is not a subtype of target $Target",
+ ))
+ return result_type
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct a lazy space from explicit finite sources. Product composition uses
+Julia's product order, with the first source varying fastest. Zip composition
+pairs equal-length sources and broadcasts singleton sources. A completed
+result space is admitted only through a result-owner transport method of the
+form `Gridspace{Target}(source)`.
+
+# Errors
+
+- Throws `ArgumentError` when `combine` is unsupported or an input is not a
+ `Grid` or nested `Gridspace`.
+- Throws `ArgumentError` when an inferred concrete callable result does not
+ belong to `Target`.
+- Throws `DimensionMismatch` when zip source lengths are incompatible.
+"""
+function Gridspace{Target}(
+ build,
+ grids::G;
+ combine::Symbol = :product
+) where {Target, G <: Tuple}
+ combine in (:product, :zip) ||
+ throw(ArgumentError("combine must be :product or :zip; got :$combine"))
+ all(grid -> grid isa _GridspaceSource, grids) || throw(
+ ArgumentError(
+ "Gridspace sources must be Grid, Gridspace, or completed result-space values",
+ ),
+ )
+ callable = _gridspace_callable(build)
+ result_type = _gridspace_eltype(Target, callable, grids)
+ space = Gridspace{
+ Target, typeof(callable), G, Val{combine}, result_type
+ }(callable, grids)
+ combine === :zip && _zip_length(space)
+ return space
+end
+
+function Gridspace{Target}(grids::Tuple; combine::Symbol = :product) where {Target}
+ Gridspace{Target}(Target, grids; combine)
+end
+
+Grid(space::Gridspace) = space
+
+const _FiniteSource = Union{AbstractGrid, Gridspace}
+const _FiniteCollection = Union{Tuple, AbstractVector}
+
+_contains_grid(::_FiniteSource) = true
+_contains_grid(values::_FiniteCollection) = any(_contains_grid, values)
+_contains_grid(::Any) = false
+
+_collect_arguments(values...) = collect(values)
+
+_grid_source(source::_FiniteSource; combine::Symbol) = source
+function _grid_source(values::Tuple; combine::Symbol)
+ sources = map(values) do value
+ _contains_grid(value) ? _grid_source(value; combine) : Grid((value,))
+ end
+ return Gridspace{Tuple}(tuple, sources; combine)
+end
+function _grid_source(values::AbstractVector; combine::Symbol)
+ sources = map(values) do value
+ _contains_grid(value) ? _grid_source(value; combine) : Grid((value,))
+ end
+ return Gridspace{Vector}(_collect_arguments, Tuple(sources); combine)
+end
+
+"""
+ parameterize(Target, caller, values; combine=:product)
+
+Invoke `caller` directly when `values` are scalar. If an explicit `Grid` or
+`Gridspace` occurs at any admitted argument position, return a
+`Gridspace{Target}` that reconstructs the same call point by point.
+"""
+function parameterize(
+ ::Type{Target}, caller, values::Tuple; combine::Symbol = :product
+) where {Target}
+ combine in (:product, :zip) || throw(ArgumentError(
+ "combine must be :product or :zip"
+ ))
+ any(_contains_grid, values) || return caller(values...)
+ sources = map(values) do value
+ _contains_grid(value) ? _grid_source(value; combine) : Grid((value,))
+ end
+ return Gridspace{Target}(caller, sources; combine)
+end
+
+points(grid::AbstractGrid) = grid
+points(result::AbstractResultSpace) = result
+
+_zip_length(::Gridspace{<:Any, <:Any, Tuple{}, Val{:zip}, <:Any}) = 1
+function _zip_length(space::Gridspace{<:Any, <:Any, <:Tuple, Val{:zip}, <:Any})
+ counts = map(length, space.grids)
+ target_count = maximum(counts)
+ all(count -> count == 1 || count == target_count, counts) || throw(
+ DimensionMismatch(
+ "zip sources must have equal cardinality or be singletons; got $(Tuple(counts))",
+ ),
+ )
+ return target_count
+end
+
+function _zip_source(source, target_count::Int)
+ length(source) == 1 &&
+ return Iterators.repeated(only(points(source)), target_count)
+ return points(source)
+end
+
+_combinations(::Gridspace{<:Any, <:Any, Tuple{}, Val{:zip}, <:Any}) = ((),)
+function _combinations(
+ space::Gridspace{<:Any, <:Any, <:Tuple, Val{:zip}, <:Any}
+)
+ target_count = _zip_length(space)
+ iterators = map(source -> _zip_source(source, target_count), space.grids)
+ return Iterators.zip(iterators...)
+end
+
+_combinations(::Gridspace{<:Any, <:Any, Tuple{}, Val{:product}, <:Any}) = ((),)
+function _combinations(
+ space::Gridspace{<:Any, <:Any, <:Any, Val{:product}, <:Any}
+)
+ Iterators.product(map(points, space.grids)...)
+end
+
+_indexed_source(::AbstractGrid) = Val(false)
+_indexed_source(::AbstractResultSpace) = Val(true)
+_indexed_source(::DeterministicGrid) = Val(true)
+_indexed_source(::RelativeGrid) = Val(true)
+_indexed_source(::AbsoluteGrid) = Val(true)
+_indexed_source(space::Gridspace) = _indexed_sources(space.grids)
+
+_indexed_sources(::Tuple{}) = Val(true)
+function _indexed_sources(sources::Tuple)
+ return _indexed_sources(_indexed_source(first(sources)), Base.tail(sources))
+end
+_indexed_sources(::Val{false}, ::Tuple) = Val(false)
+_indexed_sources(::Val{true}, sources::Tuple) = _indexed_sources(sources)
+
+_source_point(source::AbstractGrid, index::Int) = source[index]
+_source_point(source::AbstractResultSpace, index::Int) = source[index]
+function _source_point(space::Gridspace{Target}, index::Int) where {Target}
+ return Gridpoint{Target}(space.build, _arguments_at(space, index))
+end
+
+function _arguments_at(
+ space::Gridspace{<:Any, <:Any, <:Any, Val{:product}, <:Any},
+ index::Int
+)
+ 1 <= index <= length(space) || throw(BoundsError(space, index))
+ arguments = Vector{Any}(undef, length(space.grids))
+ offset = index - 1
+ for (source_index, source) in pairs(space.grids)
+ count = length(source)
+ arguments[source_index] = _source_point(source, mod(offset, count) + 1)
+ offset = div(offset, count)
+ end
+ return Tuple(arguments)
+end
+
+function _arguments_at(
+ space::Gridspace{<:Any, <:Any, <:Any, Val{:zip}, <:Any},
+ index::Int
+)
+ 1 <= index <= length(space) || throw(BoundsError(space, index))
+ arguments = Vector{Any}(undef, length(space.grids))
+ for (source_index, source) in pairs(space.grids)
+ arguments[source_index] = _source_point(
+ source, length(source) == 1 ? 1 : index
+ )
+ end
+ return Tuple(arguments)
+end
+
+function _indexed_points(space::Gridspace{Target}) where {Target}
+ return (Gridpoint{Target}(space.build, _arguments_at(space, index))
+ for index in 1:length(space))
+end
+function _iterated_points(space::Gridspace{Target}) where {Target}
+ return (Gridpoint{Target}(space.build, args) for args in _combinations(space))
+end
+_zip_points(space::Gridspace, ::Val{true}) = _indexed_points(space)
+_zip_points(space::Gridspace, ::Val{false}) = _iterated_points(space)
+
+"""
+Return the lazy unresolved points of a Gridspace.
+"""
+function points(
+ space::Gridspace{<:Any, <:Any, <:Any, Val{:product}, <:Any}
+)
+ return _iterated_points(space)
+end
+function points(space::Gridspace{<:Any, <:Any, <:Any, Val{:zip}, <:Any})
+ return _zip_points(space, _indexed_sources(space.grids))
+end
+
+"""
+Return a value unchanged during deterministic point materialization.
+"""
+materialize(value) = value
+
+function materialize(value::UncertainValue)
+ throw(ArgumentError(
+ "direct materialization of an uncertainty-bearing Gridspace requires " *
+ "Measurements.jl; load it with `using Measurements` before " *
+ "iteration or MonteCarlo computation",
+ ))
+end
+
+"""
+Recursively materialize a selected Gridspace point.
+"""
+function materialize(point::Gridpoint)
+ point.build(map(materialize, point.args)...)
+end
+
+function materialize(
+ point::Gridpoint{Target}
+) where {Target <: AbstractProblemDefinition}
+ return validate(point.build(map(materialize, point.args)...))::Target
+end
+
+"""
+Return a deterministic value unchanged during stochastic realization.
+"""
+realize(::Random.AbstractRNG, value, _) = value
+
+function realize(rng::Random.AbstractRNG, value::UncertainValue, distribution)
+ rand(rng, value; distribution)
+end
+
+"""
+Draw the arguments of a selected Gridspace point without invoking its builder.
+"""
+function realize_arguments(rng::Random.AbstractRNG, point::Gridpoint, distribution)
+ return map(value -> realize(rng, value, distribution), point.args)
+end
+
+"""
+Build a selected Gridspace point from an already realized argument tuple.
+"""
+realize(point::Gridpoint, arguments::Tuple) = point.build(arguments...)
+
+function realize(
+ point::Gridpoint{Target},
+ arguments::Tuple
+) where {Target <: AbstractProblemDefinition}
+ return validate(point.build(arguments...))::Target
+end
+
+"""
+Recursively realize a selected Gridspace point using the caller's RNG.
+"""
+function realize(rng::Random.AbstractRNG, point::Gridpoint, distribution)
+ return realize(point, realize_arguments(rng, point, distribution))
+end
+
+function _materialized_result(
+ ::Type{_UnknownGridspaceEltype}, ::Type{Target}, point
+) where {Target}
+ return materialize(point)::Target
+end
+function _materialized_result(::Type{Result}, ::Type, point) where {Result}
+ return materialize(point)::Result
+end
+
+function Base.iterate(
+ space::Gridspace{Target, <:Any, <:Any, <:Any, Result}, state...
+) where {Target, Result}
+ item = iterate(points(space), state...)
+ item === nothing && return nothing
+ point, next_state = item
+ return _materialized_result(Result, Target, point), next_state
+end
+
+_gridspace_iterator_eltype(::Type{_UnknownGridspaceEltype}) = Base.IteratorEltype(Any)
+_gridspace_iterator_eltype(::Type) = Base.IteratorEltype(Vector{Int})
+
+_gridspace_eltype_trait(::Type{_UnknownGridspaceEltype}) = Any
+_gridspace_eltype_trait(::Type{Result}) where {Result} = Result
+
+#! explicit-imports: off
+# Base's iterator trait protocol exposes these values without public bindings.
+Base.IteratorSize(::Type{<:Gridspace}) = Base.HasShape{1}()
+function Base.IteratorEltype(
+ ::Type{<:Gridspace{<:Any, <:Any, <:Any, <:Any, Result}}
+) where {Result}
+ return _gridspace_iterator_eltype(Result)
+end
+#! explicit-imports: on
+function Base.eltype(
+ ::Type{<:Gridspace{<:Any, <:Any, <:Any, <:Any, Result}}
+) where {Result}
+ _gridspace_eltype_trait(Result)
+end
+function Base.length(
+ space::Gridspace{<:Any, <:Any, <:Any, Val{:product}, <:Any}
+)
+ prod(length, space.grids; init = 1)
+end
+Base.length(space::Gridspace{<:Any, <:Any, <:Any, Val{:zip}, <:Any}) = _zip_length(space)
+Base.size(space::Gridspace) = (length(space),)
+
+"""
+Draw one unresolved point, then realize its arguments through the owned construction path.
+"""
+function Base.rand(
+ rng::Random.AbstractRNG,
+ space::Gridspace{<:Any, <:Any, <:Any, <:Any, Result};
+ distribution = :normal
+) where {Result}
+ iszero(length(space)) && throw(ArgumentError("cannot sample an empty Gridspace"))
+ offset = rand(rng, 0:(length(space) - 1))
+ point = _indexed_sources(space.grids) === Val(true) ?
+ _source_point(space, offset + 1) : first(Iterators.drop(points(space), offset))
+ value = realize(rng, point, distribution)
+ Result === _UnknownGridspaceEltype && return value
+ return value::Result
+end
+
+function Base.rand(space::Gridspace; distribution = :normal)
+ return rand(Random.default_rng(), space; distribution)
+end
+
+"""
+Return whether a value structurally contains an uncertainty descriptor.
+"""
+has_uncertainty(::UncertainValue) = true
+has_uncertainty(grid::AbstractUncertainGrid) = true
+has_uncertainty(grid::DeterministicGrid) = any(has_uncertainty, grid.vals)
+has_uncertainty(point::Gridpoint) = any(has_uncertainty, point.args)
+has_uncertainty(space::Gridspace) = any(has_uncertainty, space.grids)
+has_uncertainty(::Any) = false
diff --git a/src/importexport/ImportExport.jl b/src/importexport/ImportExport.jl
index 75bcaa6ad..0a7bb8ffe 100644
--- a/src/importexport/ImportExport.jl
+++ b/src/importexport/ImportExport.jl
@@ -1,75 +1,80 @@
"""
- LineCableModels.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.
+Read and write LineCableModels data.
# 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.
+- Save and load material and cable libraries.
+- Encode package-owned model types in the versioned JSON schema.
+- Export cable systems and calculated matrices to PSCAD, ATPDraw, and TRALIN
+ formats.
+- Import supported PSCAD and TRALIN results.
# Dependencies
$(IMPORTS)
-# Exports
-
-$(EXPORTS)
"""
module ImportExport
-# Export public API
export export_data
-export read_data
+export import_data
export save
export load!
-# Module-specific dependencies
-using ..Commons
-using ..Utils: display_path, to_nominal, resolve_T, coerce_to_T, isdiag_approx
-using ..Materials: Material, MaterialsLibrary
-using ..EarthProps: EarthModel
-using ..DataModel: CablesLibrary, CableDesign, CableComponent, ConductorGroup,
- InsulatorGroup, CircStrands, RectStrands, 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
-using XLSX
-using Tables
-using DataFrames
-
-"""
-$(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...)
-
+#! explicit-imports: off
+# IMPORTS is expanded in the module docstring rather than called as Julia code.
+using DocStringExtensions: IMPORTS
+#! explicit-imports: on
+using DocStringExtensions: TYPEDSIGNATURES, METHODLIST
+import ..LineCableModels: build, validate, nominal, parameterize
+import ..LineCableModels
+import ..Commons
+import ..Units
+import UUIDs
+import ..Commons: observe, FormulationOptions, ComputationOptions, ComputationDetails
+import ..ReportBuilder
+using ..Materials: AbstractMaterial, Material, RadialDielectric, MaterialsLibrary
+using ..Earth: EarthLayer, EarthModel
+import ..DataModel
+using ..DataModel: CablesLibrary, DatasheetInfo, CableDesign, LineCableSystem,
+ AbstractCablePart, Region, Stack, Group, Assembly, Enclosure,
+ Disk, Rectangle, Ellipse,
+ Sector, Annulus, Shell,
+ Polygon, Pose2,
+ Ring, Polar, Fill, Lattice, capacity,
+ FillFactor,
+ LayRatio, Pitch, LayAngle, Helix
+using ..ParametricBuilder: AbstractGrid, DeterministicGrid, RelativeGrid,
+ AbsoluteGrid, Grid, Gridspace, AbsoluteError
+import ..Engine
+import ..Engine: ModalAnalysis
+import ..Engine: LineParameters, SeriesImpedance, ShuntAdmittance,
+ frequencies, Z, Y, C
+import EzXML
+using EzXML: ElementNode, XMLDocument, addelement!, prettyprint, setroot!
+using Printf: @printf, @sprintf
+import JSON3
+import Serialization
+import Statistics
+import ..UQ
+import LinearAlgebra
+using LinearAlgebra: tril
+
+include("interfaces.jl")
+include("paths.jl")
include("serialize.jl")
include("deserialize.jl")
+include("uncertainty.jl")
+include("observed.jl")
+include("problem.jl")
include("cableslibrary.jl")
include("materialslibrary.jl")
-include("pscad.jl")
include("atp.jl")
-include("xlsx.jl")
include("tralin.jl")
+public encode_observation, decode_observation_measurement, observation_sources
+public serialize_value, deserialize_value, deserialize_extension
+
end # module ImportExport
diff --git a/src/importexport/atp.jl b/src/importexport/atp.jl
index fa21e80ab..814ead739 100644
--- a/src/importexport/atp.jl
+++ b/src/importexport/atp.jl
@@ -1,45 +1,27 @@
-"""$(TYPEDSIGNATURES)
+"""
+$(TYPEDSIGNATURES)
-Export a [`LineCableSystem`](@ref) to an **ATPDraw‑compatible** XML file (LCC component with input data).
+Write an ATPDraw LCC project for a materialized line-cable system.
-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.
+The XML contains cable positions, conductor and insulation properties, line
+length, system frequency, and the resistivity of the last earth layer.
# 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.
+- `::Val{:atp}`: ATP format selector.
+- `cable_system`: positioned cable designs and line length.
+- `earth_props`: earth model. The last layer supplies `Grnd resis`.
-# Behavior
+# Keywords
-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`:
+- `base_freq`: ATP system frequency \\[Hz\\]. Default: `50.0`.
+- `file_name`: output path. Relative paths are resolved beside this exporter.
+ a supplied basename is prefixed with `cable_system.system_id`. Default:
+ `nothing`, which writes `_export.xml`.
- * Write a `` element with:
+# Returns
- * `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`.
+- The absolute output path.
# Units
@@ -56,499 +38,334 @@ Units are printed in the XML file according to the ATPDraw specifications:
# 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
+- The exporter writes explicitly adapted equivalent properties at the common material
+ reference state. Operating-temperature correction is excluded from the export.
+- [`nominal`](@ref) removes uncertainty before numeric values are written.
+- LineCableSystem construction, rather than export, checks cable overlap.
-* [`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.r_ex)
-
- _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.r_in),
- "Rout" => to_nominal(cond_group.r_ex),
- "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
+ cable_system::LineCableSystem,
+ earth_props::EarthModel;
+ base_freq = 50.0,
+ file_name::Union{String, Nothing} = nothing
+)::String
+ # ATP defines this explicit homogenization choice. The physical design remains
+ # authoritative. DataModel reduces only the local concentric parts required
+ # by ATPDraw's LCC record.
+ atp_components(design) = DataModel.flatten(design, base_freq)
+ function _set_attributes!(element, attrs::Dict)
+ for (k, v) in attrs
+ element[k] = string(v)
+ end
+ end
+ # 1. Setup Constants and Variables
+ if isnothing(file_name)
+ # Derive the name from cable_system when the caller omits it.
+ file_name = joinpath(@__DIR__, "$(cable_system.system_id)_export.xml")
+ else
+ # caller supplied a path and 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.designs)
+
+ # 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 = nominal(cable_system.line_length)
+ soil_rho = nominal(earth_props.layers[end].rho)
+ 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, (design, position)) in enumerate(zip(
+ cable_system.designs,
+ cable_system.positions
+ ))
+ cable_node = addelement!(cable_header, "cable")
+
+ components = atp_components(design)
+ num_components = length(components)
+ outermost = last(components)
+ outermost_radius = nominal(max(
+ outermost.conductor.r_ex,
+ outermost.dielectric.r_ex
+ ))
+
+ _set_attributes!(
+ cable_node,
+ Dict(
+ "NumCond" => num_components,
+ "Rout" => outermost_radius,
+ "PosX" => nominal(position.x),
+ "PosY" => nominal(position.y)
+ )
+ )
+
+ for component in components
+ conductor_node = addelement!(cable_node, "conductor")
+
+ conductor = component.conductor
+ dielectric = component.dielectric
+
+ rho_eq = conductor.material.rho
+ mu_r_cond = conductor.material.mu_r
+ mu_r_ins = dielectric.material.mu_r
+ eps_eq = dielectric.material.eps_r
+
+ _set_attributes!(
+ conductor_node,
+ Dict(
+ "Rin" => nominal(conductor.r_in),
+ "Rout" => nominal(conductor.r_ex),
+ "rho" => nominal(rho_eq),
+ "muC" => nominal(mu_r_cond),
+ "muI" => nominal(mu_r_ins),
+ "epsI" => nominal(eps_eq),
+ "Cext" => nominal(dielectric.shunt_capacitance),
+ "Gext" => nominal(dielectric.shunt_conductance)
+ )
+ )
+ end
+ end
+
+ # Write the completed XML document.
+ _set_attributes!(variables, Dict("NumSim" => 1, "IOPCVP" => 0, "UseParser" => "false"))
+
+ open(file_name, "w") do fid
+ prettyprint(fid, doc)
+ end
+ @info "XML file saved to: $(_display_path(file_name))"
+ return file_name
end
+"""
+$(TYPEDSIGNATURES)
+Write frequency-indexed series-impedance and shunt-admittance matrices to an
+ATPDraw `ZY` XML file.
-# 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.
+Each frequency produces one `` and one `` block. Matrix rows use
+comma-separated `R+Xi` and `G+Bi` entries.
# 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"`.
+- `::Val{:atp}`: ATP format selector.
+- `line_params`: frequency-dependent matrices and frequency vector.
-# Behavior
+# Keywords
-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]`:
+- `file_name`: output path. Default: `ZY_export.xml` beside this exporter.
+- `cable_system`: optional system supplying the line length and output-name
+ prefix. Default: `nothing`.
- * 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`.
+# Returns
+
+- The absolute output path.
# 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\\]
+- `Z` and `Y` entries retain the numerical basis stored by `line_params`.
+- XML `Length`: cable-system length \\[m\\], or `1.0` when `cable_system` is
+ absent.
# 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
+- [`nominal`](@ref) removes uncertainty before numeric values are written.
-* [`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
+ line_params::LineParameters;
+ file_name::Union{String, Nothing} = nothing,
+ cable_system::Union{LineCableSystem, Nothing} = nothing
+)::String
+
+ # Resolve final file_name while preserving any user-supplied path.
+ if isnothing(file_name)
+ # Derive the name from cable_system when the caller omits it.
+ 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 and 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 = observe(line_params, frequencies)
+
+ @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 : 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(observe(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", nominal(freq_val))
+ for i in 1:num_phases
+ row_str = join(
+ [@sprintf("%.16E%+.16Ei",
+ nominal(real(observe(line_params, Z, i, j, k))),
+ nominal(imag(observe(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 = nominal(freq[1])
+ @printf(fid, " \n", freq1)
+ for i in 1:num_phases
+ row_str = join(
+ [@sprintf("%.16E",
+ nominal(observe(line_params, C, i, j, 1)))
+ 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", nominal(freq_val))
+ for i in 1:num_phases
+ row_str = join(
+ [@sprintf("%.16E%+.16Ei",
+ nominal(real(observe(line_params, Y, i, j, k))),
+ nominal(imag(observe(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
+ @info "XML file saved to: $(_display_path(file_name))"
+ return file_name
end
diff --git a/src/importexport/cableslibrary.jl b/src/importexport/cableslibrary.jl
index 421cbc528..1cf0d7849 100644
--- a/src/importexport/cableslibrary.jl
+++ b/src/importexport/cableslibrary.jl
@@ -1,680 +1,132 @@
"""
$(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.
+Save a cable library as versioned JSON or trusted Julia serialization (`.jls`).
+JLS input must come from a trusted source and use matching package types.
"""
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)
+ library::CablesLibrary;
+ file_name::String = "cables_library.json"
+)
+ extension = lowercase(splitext(file_name)[2])
+ if extension == ".jls"
+ Serialization.serialize(file_name, (
+ designs = library.data,
+ datasheets = library.datasheets
+ ))
+ return abspath(file_name)
+ end
+ path = _json_path(file_name)
+ open(path, "w") do io
+ #! explicit-imports: off
+ # JSON3 exposes this established writer without a public marker.
+ JSON3.pretty(io, _json_document(library); allow_inf = true)
+ #! explicit-imports: on
+ end
+ return abspath(path)
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.
+Atomically replace a cable library from supported JSON or trusted JLS data.
"""
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
+ library::CablesLibrary;
+ file_name::String = "cables_library.json"
+)
+ isfile(file_name) || throw(ArgumentError(
+ "cables library file not found: '$(_display_path(file_name))'",
+ ))
+ extension = lowercase(splitext(file_name)[2])
+ decoded_library = if extension == ".jls"
+ _trusted_cable_data(Serialization.deserialize(file_name))
+ elseif extension == ".json"
+ document = _read_document(file_name, CABLES_SCHEMA)
+ materials = _document_materials(document)
+ root = _required(document, "root", CABLES_SCHEMA)
+ get(root, "kind", nothing) == "cable_library" || throw(ArgumentError(
+ "cable document root must have kind 'cable_library'"
+ ))
+ raw_cables = _required(root, "cables", "cable_library")
+ raw_cables isa AbstractDict || throw(ArgumentError(
+ "cable_library cables must be an object"
+ ))
+ _decoded_cable_library(raw_cables, materials)
+ else
+ throw(ArgumentError("CablesLibrary loading requires a .json or .jls file"))
+ end
+ library.data = decoded_library.designs
+ library.datasheets = decoded_library.datasheets
+ return 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
+function _decode_datasheet(value)
+ value isa AbstractDict || throw(ArgumentError(
+ "a cable datasheet record must be an object"
+ ))
+ names = sort!(Symbol.(collect(keys(value))); by = String)
+ entries = Tuple(deserialize_value(value[String(name)]) for name in names)
+ return DatasheetInfo(NamedTuple{Tuple(names)}(entries))
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
+function _decoded_cable_library(raw_cables, materials)
+ designs = Dict{String, CableDesign}()
+ datasheets = Dict{String, DatasheetInfo}()
+ for (name, value) in raw_cables
+ cable_id = String(name)
+ design = _decode_design(value, materials)
+ design isa CableDesign || throw(ArgumentError(
+ "cable '$cable_id' decoded as $(typeof(design)), not CableDesign"
+ ))
+ cable_id == design.cable_id || throw(ArgumentError(
+ "cable key '$cable_id' differs from cable_id '$(design.cable_id)'"
+ ))
+ designs[cable_id] = validate(design)
+ datasheets[cable_id] = _decode_datasheet(
+ _required(value, "datasheet", "cable_design")
+ )
+ end
+ return (; designs, datasheets)
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
- r_in = get_as(deserialized_layer_dict, :r_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(r_in) &&
- Base.error(
- "Missing 'r_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 == CircStrands
- 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 CircStrands 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 CircStrands(
- r_in,
- radius_wire,
- num_wires_int,
- lay_ratio,
- material_props;
- temperature = temperature,
- lay_direction = lay_direction_int,
- )
- elseif LayerType == Tubular
- r_ex = get_as(deserialized_layer_dict, :r_ex, missing, BASE_FLOAT)
- ismissing(r_ex) &&
- Base.error("Missing 'r_ex' for Tubular first layer.")
- return Tubular(
- r_in, r_ex, material_props; temperature = temperature)
- elseif LayerType == Strip
- r_ex = get_as(deserialized_layer_dict, :r_ex, 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, (r_ex, width, lay_ratio)) && error(
- "Missing required field(s) (r_ex, width, lay_ratio) for Strip first layer.",
- )
- lay_direction_int = isa(lay_direction, Int) ? lay_direction : Int(lay_direction)
-
- return Strip(
- r_in,
- r_ex,
- width,
- lay_ratio,
- material_props;
- temperature = temperature,
- lay_direction = lay_direction_int,
- )
- elseif LayerType == Insulator
- r_ex = get_as(deserialized_layer_dict, :r_ex, missing, BASE_FLOAT)
- ismissing(r_ex) &&
- Base.error("Missing 'r_ex' for Insulator first layer.")
- return Insulator(
- r_in,
- r_ex,
- material_props;
- temperature = temperature,
- )
- elseif LayerType == Semicon
- r_ex = get_as(deserialized_layer_dict, :r_ex, missing, BASE_FLOAT)
- ismissing(r_ex) &&
- Base.error("Missing 'r_ex' for Semicon first layer.")
- return Semicon(r_in, r_ex, material_props; temperature = temperature)
- elseif LayerType == Sector
- params = get_as(deserialized_layer_dict, :params, missing, BASE_FLOAT)
- rotation_angle_deg = get_as(deserialized_layer_dict, :rotation_angle_deg, missing, BASE_FLOAT)
-
- ismissing(params) && Base.error("Missing 'params' for Sector in data: $layer_data")
- !(params isa SectorParams) && error("'params' did not deserialize to a SectorParams object. Got: $(typeof(params))")
- ismissing(rotation_angle_deg) && Base.error("Missing 'rotation_angle_deg' for Sector in data: $layer_data")
-
- return Sector(params, rotation_angle_deg, material_props; temperature=temperature)
- elseif LayerType == SectorInsulator
- inner_sector = get_as(deserialized_layer_dict, :inner_sector, missing, BASE_FLOAT)
- thickness = get_as(deserialized_layer_dict, :thickness, missing, BASE_FLOAT)
-
- ismissing(inner_sector) && Base.error("Missing 'inner_sector' for SectorInsulator in data: $layer_data")
- !(inner_sector isa Sector) && error("'inner_sector' did not deserialize to a Sector object. Got: $(typeof(inner_sector))")
- ismissing(thickness) && Base.error("Missing 'thickness' for SectorInsulator in data: $layer_data")
-
- return SectorInsulator(inner_sector, thickness, 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
+function _decoded_cable_data(decoded)
+ decoded isa AbstractDict || throw(ArgumentError(
+ "the cables field must be a JSON object",
+ ))
+ designs = Dict{String, CableDesign}()
+ for (name, design) in decoded
+ design isa CableDesign || throw(ArgumentError(
+ "cable '$name' decoded as $(typeof(design)), not CableDesign",
+ ))
+ String(name) == design.cable_id || throw(ArgumentError(
+ "cable key '$name' differs from cable_id '$(design.cable_id)'",
+ ))
+ designs[String(name)] = validate(design)
+ end
+ return designs
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 == CircStrands
- 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 CircStrands layer $i in $comp_id",
- )
- args = [radius_wire, num_wires, lay_ratio, material_props]
- elseif LayerType == Tubular
- r_ex = get_as(layer_data, "r_ex", missing, BASE_FLOAT)
- ismissing(r_ex) &&
- Base.error("Missing 'r_ex' for Tubular layer $i in $comp_id")
- args = [r_ex, material_props]
- elseif LayerType == Strip
- r_ex = get_as(layer_data, "r_ex", 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, (r_ex, width, lay_ratio)) &&
- Base.error(
- "Missing required field(s) for Strip layer $i in $comp_id",
- )
- args = [r_ex, 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 r_ex, material_props
- # for the add! method.
- if LayerType in [Semicon, Insulator]
- r_ex = get_as(layer_data, "r_ex", missing, BASE_FLOAT)
- ismissing(r_ex) &&
- Base.error(
- "Missing 'r_ex' for $LayerType layer $i in $comp_id",
- )
- args = [r_ex, 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
+function _trusted_cable_data(decoded)
+ decoded isa NamedTuple && keys(decoded) == (:designs, :datasheets) || throw(
+ ArgumentError(
+ "trusted JLS cable data must contain designs and datasheets"
+ )
+ )
+ designs = _decoded_cable_data(decoded.designs)
+ decoded.datasheets isa AbstractDict || throw(ArgumentError(
+ "trusted JLS datasheet data must be a dictionary",
+ ))
+ datasheets = Dict{String, DatasheetInfo}()
+ for cable_id in keys(designs)
+ record = get(decoded.datasheets, cable_id) do
+ throw(KeyError(cable_id))
+ end
+ record isa Union{DatasheetInfo, NamedTuple} || throw(ArgumentError(
+ "datasheet '$cable_id' must be DatasheetInfo or a named tuple"
+ ))
+ datasheets[cable_id] =
+ record isa DatasheetInfo ? record : DatasheetInfo(record)
+ end
+ return (; designs, datasheets)
end
diff --git a/src/importexport/deserialize.jl b/src/importexport/deserialize.jl
index 88551db22..a461a13ea 100644
--- a/src/importexport/deserialize.jl
+++ b/src/importexport/deserialize.jl
@@ -1,328 +1,510 @@
-@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.
+Decode an extension-owned tagged value from the v1 JSON format.
"""
-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
+function deserialize_extension end
+
+_float_type(::Val{:Float16}) = Float16
+_float_type(::Val{:Float32}) = Float32
+_float_type(::Val{:Float64}) = Float64
+_float_type(::Val{:BigFloat}) = BigFloat
+
+function _required(value, name::AbstractString, owner)
+ haskey(value, name) || throw(ArgumentError(
+ "$owner requires a '$name' field"
+ ))
+ return value[name]
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
+function _field(value, name::AbstractString)
+ deserialize_value(
+ _required(value, name, get(value, "kind", get(value, "type", "object")))
+ )
+end
+function _optional(value, name::AbstractString)
+ haskey(value, name) ? deserialize_value(value[name]) : nothing
+end
-- `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.
+function _decode_named_tuple(value)
+ value isa AbstractVector || throw(ArgumentError(
+ "named-tuple data must be an ordered array"
+ ))
+ names = Tuple(Symbol(_required(entry, "name", "named-tuple entry"))
+ for entry in value)
+ allunique(names) || throw(ArgumentError("named-tuple names must be unique"))
+ values = Tuple(deserialize_value(
+ _required(entry, "value", "named-tuple entry")
+ ) for entry in value)
+ return NamedTuple{names}(values)
+end
-# Returns
+function _decode_float(type_name::AbstractString, value::AbstractDict)
+ T = _float_type(Val(Symbol(type_name)))
+ if haskey(value, "special")
+ special = value["special"]
+ special == "Inf" && return T(Inf)
+ special == "-Inf" && return T(-Inf)
+ special == "NaN" && return T(NaN)
+ throw(ArgumentError("unknown special floating-point value '$special'"))
+ end
+ stored_value = _required(value, "value", type_name)
+ return T === BigFloat ? parse(BigFloat, String(stored_value);precision=get(value,"precision",precision(BigFloat))) : convert(T, stored_value)
+end
-- 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`.
+function _decode_material_record(value)
+ if get(value, "kind", nothing) == "radial_dielectric"
+ materials = [_decode_material_record(m)
+ for m in _required(value, "materials", "radial_dielectric")]
+ return RadialDielectric(materials, _field(value, "weights");
+ mu_r = _field(value, "mu_r"))
+ end
+ raw_kind = deserialize_value(_required(value, "kind", "material"))
+ kind = raw_kind isa AbstractString ? Symbol(raw_kind) : raw_kind
+ fields = (
+ kind,
+ _field(value, "rho"),
+ _field(value, "eps_r"),
+ _field(value, "mu_r"),
+ _field(value, "T0"),
+ _field(value, "alpha"),
+ _field(value, "rho_thermal"),
+ _field(value, "theta_max"),
+ _field(value, "tan_delta"),
+ _field(value, "sigma_solar")
+ )
+ build = (selected_kind,
+ properties...) -> Material(
+ selected_kind isa Symbol ? selected_kind : Symbol(selected_kind),
+ properties...
+ )
+ return _decoded_target(Material, build, fields)
+end
-# Examples
+_decoded_source(value) = value isa Union{AbstractGrid, Gridspace}
+function _decoded_target(::Type{Target}, build, values::Tuple) where {Target}
+ any(_decoded_source, values) || return build(values...)
+ sources = map(values) do value
+ _decoded_source(value) ? value : Grid((value,))
+ end
+ return Gridspace{Target}(build, sources)
+end
-```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
-
+Decode a supported scalar, collection, Grid, or v1 declaration.
"""
-$(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.
+function deserialize_value(value)
+ value isa AbstractVector && return [deserialize_value(item) for item in value]
+ value isa AbstractDict || return value
+ if haskey(value, "__type__")
+ marker = String(value["__type__"])
+ marker in ("Float16", "Float32", "Float64", "BigFloat") &&
+ return _decode_float(marker, value)
+ marker == "Symbol" && return Symbol(_required(value, "value", marker))
+ marker == "Val" && return Val(deserialize_value(_required(value, "value", marker)))
+ marker == "Complex" && return complex(
+ deserialize_value(_required(value, "re", marker)),
+ deserialize_value(_required(value, "im", marker))
+ )
+ marker == "Missing" && return missing
+ marker == "Tuple" && return Tuple(deserialize_value(item) for item in value["values"])
+ if marker == "Array"
+ decoded=map(deserialize_value,value["values"])
+ if !isempty(decoded)
+ T=typeof(first(decoded))
+ all(item -> item isa T,decoded) && (decoded=collect(T,decoded))
+ end
+ return reshape(decoded,Tuple(Int.(value["size"])))
+ end
+ marker == "NamedTuple" && return NamedTuple{Tuple(Symbol.(value["names"]))}(
+ Tuple(deserialize_value(item) for item in value["values"]))
+ marker in ("UInt64", "Observable", "Quantile", "ModalRepresentation", "LineParameters", "CableConstants", "MonteCarloResult",
+ "LinearErrorResult", "SampleSummary", "HistogramDensity", "Distribution",
+ "FormulationOptions", "ComputationOptions", "ComputationDetails", "ObservedArchive",
+ "UUID", "Colon", "Quantity", "Unit", "UnitExpr", "ScientificType") &&
+ return deserialize_extension(Val(Symbol(marker)),value)
+ if marker in ("Measurement", "MeasurementLinearErrorResult")
+ applicable(deserialize_extension, Val(Symbol(marker)), value) || throw(
+ ArgumentError("deserializing Measurement values requires Measurements.jl")
+ )
+ return deserialize_extension(Val(Symbol(marker)), value)
+ end
+ throw(ArgumentError("unsupported serialized scalar tag '$marker'"))
+ end
+ if haskey(value, "grid")
+ values = deserialize_value(value["grid"])
+ haskey(value, "rel") && return Grid(values, deserialize_value(value["rel"]))
+ haskey(value, "abs") && return Grid(
+ values,
+ AbsoluteError(deserialize_value(value["abs"]))
+ )
+ return Grid(values)
+ end
+ if get(value, "type", nothing) == "material"
+ return _decode_material_record(_required(value, "value", "material"))
+ end
+ haskey(value, "type") && throw(ArgumentError(
+ "unsupported serialized object type '$(value["type"])'"
+ ))
+ haskey(value, "kind") && return _decode_node(Val(Symbol(value["kind"])), value)
+ return Dict(String(key) => deserialize_value(item) for (key, item) in value)
+end
-# Returns
-- An instance of type `T`.
+_decode_node(::Val{:disk}, value) = _decoded_target(Disk, Disk, (_field(value, "r"),))
+function _decode_node(::Val{:rectangle}, value)
+ _decoded_target(
+ Rectangle,
+ Rectangle,
+ (_field(value, "w"), _field(value, "h"))
+ )
+end
+function _decode_node(::Val{:ellipse}, value)
+ _decoded_target(
+ Ellipse,
+ Ellipse,
+ (_field(value, "a"), _field(value, "b"))
+ )
+end
+function _decode_node(::Val{:sector}, value)
+ _decoded_target(
+ Sector,
+ Sector,
+ (
+ _field(value, "span"),
+ _field(value, "r_base"),
+ _field(value, "r_back"),
+ _field(value, "fillet")
+ )
+ )
+end
+function _decode_node(::Val{:annulus}, value)
+ _decoded_target(
+ Annulus,
+ Annulus,
+ (_field(value, "ri"), _field(value, "ro"))
+ )
+end
+_decode_node(::Val{:shell}, value) = _decoded_target(Shell, Shell, (_field(value, "t"),))
+function _decode_node(::Val{:polygon}, value)
+ _decoded_target(Polygon, Polygon, (_field(value, "points"),))
+end
+function _decode_node(::Val{:pose2}, value)
+ _decoded_target(
+ Pose2,
+ Pose2,
+ (_field(value, "x"), _field(value, "y"), _field(value, "φ"))
+ )
+end
+function _decode_node(::Val{:earth_layer}, value)
+ EarthLayer(
+ _field(value, "rho"),
+ _field(value, "eps_r"),
+ _field(value, "mu_r"),
+ _field(value, "thickness")
+ )
+end
+function _decode_node(::Val{:earth_model}, value)
+ raw_layers = _required(value, "layers", "earth_model")
+ raw_layers isa AbstractVector || throw(ArgumentError(
+ "earth_model layers must be an array"
+ ))
+ layers = EarthLayer[deserialize_value(layer) for layer in raw_layers]
+ isempty(layers) && throw(ArgumentError("earth_model layers cannot be empty"))
+ T = promote_type(map(eltype, layers)...)
+ converted = Tuple(convert(EarthLayer{T}, layer) for layer in layers)
+ length(converted) >= 2 || throw(ArgumentError(
+ "earth_model requires air and at least one earth layer"
+ ))
+ return build(
+ EarthModel,
+ Base.tail(converted);
+ vertical_layers = Bool(_required(value, "vertical_layers", "earth_model")),
+ air_layer = first(converted)
+ )
+end
-# 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)
+function _decode_node(::Val{:ring}, value)
+ values = (
+ _field(value, "n"), _field(value, "r"),
+ _field(value, "φ0"), _field(value, "span"),
+ haskey(value, "gap_frac") ? _field(value, "gap_frac") : 0
+ )
+ build = (n, r, φ0, span, gap_frac) -> Ring(n; r, φ0, span, gap_frac)
+ return _decoded_target(Ring, build, values)
+end
+function _decode_node(::Val{:polar}, value)
+ values = (
+ _field(value, "nr"), _field(value, "nφ"),
+ _field(value, "r0"), _field(value, "dr"),
+ _field(value, "φ0"), _field(value, "span")
+ )
+ build = (nr, nφ, r0, dr, φ0, span) -> Polar(; nr, nφ, r0, dr, φ0, span)
+ return _decoded_target(Polar, build, values)
+end
+function _decode_node(::Val{:fill}, value)
+ values = (
+ _field(value, "r"), _field(value, "φ"),
+ _field(value, "φ0"), _field(value, "span")
+ )
+ build = (r, φ, φ0, span) -> Fill(; r, φ, φ0, span)
+ return _decoded_target(Fill, build, values)
+end
+function _decode_node(::Val{:lattice}, value)
+ values = (
+ _field(value, "nx"), _field(value, "ny"),
+ _field(value, "dx"), _field(value, "dy")
+ )
+ build = (nx, ny, dx, dy) -> Lattice(; nx, ny, dx, dy)
+ return _decoded_target(Lattice, build, values)
+end
+function _decode_node(::Val{:fill_factor}, value)
+ _decoded_target(
+ FillFactor, FillFactor, (_field(value, "η"),)
+ )
+end
+_decode_node(::Val{:capacity}, value) = capacity()
+function _decode_node(::Val{:lay_ratio}, value)
+ _decoded_target(LayRatio, LayRatio, (_field(value, "q"),))
+end
+_decode_node(::Val{:pitch}, value) = _decoded_target(Pitch, Pitch, (_field(value, "p"),))
+function _decode_node(::Val{:lay_angle}, value)
+ _decoded_target(LayAngle, LayAngle, (_field(value, "α"),))
+end
+function _decode_node(::Val{:helix}, value)
+ values = (
+ _field(value, "lay"), _field(value, "dir"), _field(value, "φ0")
+ )
+ build = (lay, dir, φ0) -> Helix(lay; dir, φ0)
+ return _decoded_target(Helix, build, values)
+end
- end
+function _material_reference(value, materials)
+ value isa AbstractString && return get(materials, String(value)) do
+ throw(KeyError(String(value)))
+ end
+ decoded = deserialize_value(value)
+ decoded isa Union{AbstractMaterial, Gridspace{<:AbstractMaterial}} ||
+ throw(ArgumentError(
+ "region material must decode as AbstractMaterial"
+ ))
+ return decoded
+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))
+function _decode_part(value, materials)
+ value isa AbstractDict || throw(ArgumentError("physical nodes must be objects"))
+ kind = Symbol(_required(value, "kind", "physical node"))
+ return _decode_part(Val(kind), value, materials)
+end
- # @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
+function _decode_part(::Val{:region}, value, materials)
+ values = (
+ Symbol(_required(value, "tag", "region")),
+ deserialize_value(_required(value, "primitive", "region")),
+ _material_reference(_required(value, "material", "region"), materials)
+ )
+ return _decoded_target(Region, Region, values)
+end
+function _decode_part(::Val{:stack}, value, materials)
+ items = _required(value, "items", "stack")
+ items isa AbstractVector && !isempty(items) || throw(ArgumentError(
+ "stack items must be a nonempty array"
+ ))
+ decoded = Tuple(_decode_part(item, materials) for item in items)
+ return _decoded_target(Stack, Stack, decoded)
+end
+function _decode_part(::Val{:group}, value, materials)
+ path = deserialize_value(_required(value, "path", "group"))
+ path isa AbstractVector && (path = Tuple(path))
+ values = (
+ Symbol(_required(value, "name", "group")),
+ deserialize_value(_required(value, "at", "group")),
+ _decode_part(_required(value, "item", "group"), materials),
+ deserialize_value(_required(value, "pattern", "group")),
+ path,
+ deserialize_value(_required(value, "compact", "group")),
+ deserialize_value(_required(value, "boundary", "group"))
+ )
+ return _decoded_target(Group, Group, values)
+end
+function _decode_part(::Val{:assembly}, value, materials)
+ if haskey(value, "members")
+ raw_members = _required(value, "members", "assembly")
+ raw_members isa AbstractVector && !isempty(raw_members) || throw(
+ ArgumentError("explicit assembly members must be a nonempty array")
+ )
+ members = Tuple(map(raw_members) do member
+ member isa AbstractDict || throw(ArgumentError(
+ "explicit assembly members must be objects"
+ ))
+ DataModel.AssemblyMember(
+ _decode_part(_required(member, "item", "assembly member"), materials),
+ deserialize_value(_required(member, "at", "assembly member"))
+ )
+ end)
+ return Assembly(
+ deserialize_value(_required(value, "at", "assembly")),
+ members,
+ nothing,
+ nothing,
+ nothing,
+ nothing
+ )
+ end
+ raw_names = _required(value, "names", "assembly")
+ names = raw_names === nothing ? nothing : Symbol.(raw_names)
+ values = (
+ deserialize_value(_required(value, "at", "assembly")),
+ _decode_part(_required(value, "item", "assembly"), materials),
+ deserialize_value(_required(value, "pattern", "assembly")),
+ deserialize_value(_required(value, "path", "assembly")),
+ deserialize_value(_required(value, "compact", "assembly")),
+ names
+ )
+ return _decoded_target(Assembly, Assembly, values)
+end
+function _decode_part(::Val{:enclosure}, value, materials)
+ raw_fill = _required(value, "fill", "enclosure")
+ fill = raw_fill isa AbstractString ? _material_reference(raw_fill, materials) :
+ get(raw_fill, "kind", nothing) == "region" ?
+ _decode_part(raw_fill, materials) : deserialize_value(raw_fill)
+ raw_wall = _required(value, "wall", "enclosure")
+ wall = raw_wall === nothing ? nothing : _decode_part(raw_wall, materials)
+ values = (
+ Symbol(_required(value, "tag", "enclosure")),
+ deserialize_value(_required(value, "at", "enclosure")),
+ deserialize_value(_required(value, "primitive", "enclosure")),
+ _decode_part(_required(value, "item", "enclosure"), materials),
+ fill,
+ wall
+ )
+ return _decoded_target(Enclosure, Enclosure, values)
+end
+function _decode_part(::Val{kind}, value, materials) where {kind}
+ throw(ArgumentError("unsupported physical node kind '$kind'"))
+end
- 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
+_decode_node(::Val{:region}, value) = _decode_part(value, Dict{String, Material}())
+_decode_node(::Val{:stack}, value) = _decode_part(value, Dict{String, Material}())
+_decode_node(::Val{:group}, value) = _decode_part(value, Dict{String, Material}())
+_decode_node(::Val{:assembly}, value) = _decode_part(value, Dict{String, Material}())
+_decode_node(::Val{:enclosure}, value) = _decode_part(value, Dict{String, Material}())
+
+function _decode_design_resolved(value, materials)
+ get(value, "kind", nothing) == "cable_design" || throw(ArgumentError(
+ "cable declaration must have kind 'cable_design'"
+ ))
+ # Historical records used "root". Only this decoder accepts that key.
+ # Never silently choose between two competing physical declarations.
+ haskey(value, "origin") && haskey(value, "root") && throw(ArgumentError(
+ "cable_design must not contain both 'origin' and legacy 'root'"
+ ))
+ origin = haskey(value, "root") ? value["root"] :
+ _required(value, "origin", "cable_design")
+ values = (
+ String(_required(value, "cable_id", "cable_design")),
+ _decode_part(origin, materials),
+ haskey(value, "nominal_data") ?
+ _decode_named_tuple(_required(value, "nominal_data", "cable_design")) : (;)
+ )
+ caller = (
+ cable_id, origin, nominal_data) -> build(
+ CableDesign, cable_id, origin; nominal_data
+ )
+ return _decoded_target(CableDesign, caller, values)
+end
- # --- Attempt 2: Positional Constructor (Fallback) ---
- # @info "Attempting positional construction for $T" # Debug logging
- fields_in_order = fieldnames(T)
- positional_args = []
+function _decode_design(value, materials = Dict{String, Material}())
+ varying = Tuple(
+ (name, material)
+ for (name, material) in sort!(collect(materials); by = first)
+ if material isa Gridspace{Material}
+ )
+ isempty(varying) && return _decode_design_resolved(value, materials)
+ names = first.(varying)
+ sources = last.(varying)
+ build = function (selected...)
+ resolved = Dict{String, Any}(materials)
+ for (name, material) in zip(names, selected)
+ resolved[name] = material
+ end
+ design = _decode_design_resolved(value, resolved)
+ design isa CableDesign || throw(ArgumentError(
+ "a named-material parameter space cannot be combined with another " *
+ "unresolved design field in the same JSON declaration"
+ ))
+ return design
+ end
+ return Gridspace{CableDesign}(build, sources)
+end
+_decode_node(::Val{:cable_design}, value) = _decode_design(value)
+
+function _decode_node(::Val{:line_cable_system}, value)
+ designs = CableDesign[deserialize_value(item)
+ for item in _required(value, "designs", "line_cable_system")]
+ clearance_rows = _optional(value, "clearances")
+ clearances = clearance_rows === nothing ? nothing :
+ reduce(vcat, permutedims.(clearance_rows))
+ inputs = (
+ designs,
+ deserialize_value(_required(value, "positions", "line_cable_system")),
+ deserialize_value(_required(value, "connections", "line_cable_system")),
+ _optional(value, "environment"),
+ String(_required(value, "system_id", "line_cable_system")),
+ _field(value, "line_length")
+ )
+ input_positions = _optional(value, "input_positions")
+ caller = (selected...) -> build(LineCableSystem, selected...;
+ _input_positions = input_positions, _clearances = clearances)
+ return parameterize(LineCableSystem, caller, inputs)
+end
- 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
+function _decode_node(::Val{:line_parameters_problem}, value)
+ haskey(value, "Gamma") && throw(ArgumentError(
+ "obsolete problem-level Gamma input; prescribe Γ in unified formula options"))
+ system = _field(value, "system")
+ system isa LineCableSystem || throw(ArgumentError(
+ "line_parameters_problem system must decode as LineCableSystem"
+ ))
+ earth = _field(value, "earth_props")
+ earth isa EarthModel || throw(ArgumentError(
+ "line_parameters_problem earth_props must decode as EarthModel"
+ ))
+ return Engine.LineParametersProblem(
+ system;
+ temperature = _field(value, "temperature"),
+ earth_props = earth,
+ frequencies = _field(value, "frequencies")
+ )
+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
+function _decode_node(::Val{kind}, value) where {kind}
+ throw(ArgumentError("unsupported declaration kind '$kind'"))
+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
+function _read_document(file_name::AbstractString, expected_format::AbstractString)
+ document = open(file_name, "r") do io
+ #! explicit-imports: off
+ JSON3.read(io, Dict{String, Any})
+ #! explicit-imports: on
+ end
+ _required(document, "\$schema", "LineCableModels document") ==
+ JSON_SCHEMA_DIALECT || throw(ArgumentError(
+ "unsupported JSON Schema dialect"
+ ))
+ format = _required(document, "format", "LineCableModels document")
+ format == expected_format || throw(ArgumentError(
+ "expected format '$expected_format', found '$format'"
+ ))
+ version = _required(document, "version", expected_format)
+ version == JSON_SCHEMA_VERSION || throw(ArgumentError(
+ "unsupported $expected_format version $version; supported version is " *
+ JSON_SCHEMA_VERSION
+ ))
+ return document
+end
- # This line should ideally not be reached
- error(
- "Failed to construct object of type $T using both keyword and positional methods.",
- )
+function _document_materials(document)
+ raw = _required(document, "materials", "LineCableModels document")
+ raw isa AbstractDict || throw(ArgumentError("materials must be an object"))
+ return Dict{String, Any}(
+ String(name) => _decode_material_record(value) for (name, value) in raw
+ )
end
diff --git a/src/importexport/interfaces.jl b/src/importexport/interfaces.jl
new file mode 100644
index 000000000..0f450f85e
--- /dev/null
+++ b/src/importexport/interfaces.jl
@@ -0,0 +1,65 @@
+"""
+$(TYPEDSIGNATURES)
+
+Export LineCableModels data in the format selected by `format`.
+
+# Arguments
+
+- `format`: format selector.
+- `args`: inputs required by the selected format.
+
+# Keywords
+
+- Format-specific output options.
+
+# Returns
+
+- The output path or value defined by the selected format.
+
+# Methods
+
+$(METHODLIST)
+"""
+function export_data(format::Symbol, args...; kwargs...)
+ return export_data(Val(format), args...; kwargs...)
+end
+
+function export_data(
+ ::Val{:xlsx},
+ line_parameters::Union{LineParameters,Commons.ObservedResult,AbstractVector{<:Commons.ObservedResult}};
+ file_name::Union{String, Nothing} = nothing,
+ cable_system::Union{LineCableSystem, Nothing} = nothing,
+ overwrite::Bool = false
+)
+ artifact = ReportBuilder.report(
+ ReportBuilder.XLSXReportDefinition(; file_name, cable_system, overwrite),
+ line_parameters
+ )
+ return artifact.output
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Import data in the format selected by `format`.
+
+# Arguments
+
+- `format`: format selector.
+- `args`: inputs required by the selected format.
+
+# Keywords
+
+- Format-specific input options.
+
+# Returns
+
+- Materialized objects defined by the selected format.
+
+# Methods
+
+$(METHODLIST)
+"""
+function import_data(format::Symbol, args...; kwargs...)
+ return import_data(Val(format), args...; kwargs...)
+end
diff --git a/src/importexport/materialslibrary.jl b/src/importexport/materialslibrary.jl
index 64241049b..a7f09b442 100644
--- a/src/importexport/materialslibrary.jl
+++ b/src/importexport/materialslibrary.jl
@@ -1,193 +1,68 @@
"""
$(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.
+Save a material library as versioned JSON or trusted Julia serialization (`.jls`).
+JLS input must come from a trusted source and use matching package types.
"""
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
+ library::MaterialsLibrary;
+ file_name::String = "materials_library.json"
+)
+ extension = lowercase(splitext(file_name)[2])
+ if extension == ".jls"
+ Serialization.serialize(file_name, library.data)
+ return abspath(file_name)
+ end
+ path = _json_path(file_name)
+ open(path, "w") do io
+ #! explicit-imports: off
+ # JSON3 exposes this established writer without a public marker.
+ JSON3.pretty(io, _json_document(library); allow_inf = true)
+ #! explicit-imports: on
+ end
+ return abspath(path)
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)
+Atomically replace a material library from supported JSON or trusted JLS data.
+The original library remains unchanged if parsing or validation fails.
"""
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
+ library::MaterialsLibrary;
+ file_name::String = "materials_library.json"
+)
+ isfile(file_name) || throw(ArgumentError(
+ "materials library file not found: '$(_display_path(file_name))'",
+ ))
+ extension = lowercase(splitext(file_name)[2])
+ materials = if extension == ".jls"
+ _trusted_material_data(Serialization.deserialize(file_name))
+ elseif extension == ".json"
+ document = _read_document(file_name, MATERIALS_SCHEMA)
+ Dict(
+ name => validate(material) for (name, material) in _document_materials(document)
+ )
+ else
+ throw(ArgumentError(
+ "MaterialsLibrary loading requires a .json or .jls file",
+ ))
+ end
+ library.data = materials
+ 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
+function _trusted_material_data(decoded)
+ decoded isa AbstractDict || throw(ArgumentError(
+ "trusted JLS material data must be a dictionary",
+ ))
+ materials = Dict{String, Material}()
+ for (name, material) in decoded
+ material isa Material || throw(ArgumentError(
+ "material '$name' must be Material, not $(typeof(material))",
+ ))
+ materials[String(name)] = validate(material)
+ end
+ return materials
end
diff --git a/src/importexport/observed.jl b/src/importexport/observed.jl
new file mode 100644
index 000000000..36e856ffe
--- /dev/null
+++ b/src/importexport/observed.jl
@@ -0,0 +1,150 @@
+# Each archive defines one uncertainty-source table spanning all points and the
+# reference. The archive is independent of process-global source registries and behavior-generation tags.
+_observed_encoding() = (indices=Dict{Any,Int}(),sources=Any[])
+"""
+$(TYPEDSIGNATURES)
+
+Encode detached values using one archive-wide uncertainty-source table.
+Extensions register independent sources in `source_table.sources` and reuse their
+indices in `source_table.indices`, preserving dependencies across all observations.
+"""
+encode_observation(value,source_table) = serialize_value(value,Val(:scientific))
+function encode_observation(value::NamedTuple,source_table)
+ Dict("__type__"=>"NamedTuple","names"=>string.(collect(keys(value))),
+ "values"=>[encode_observation(item,source_table) for item in values(value)])
+end
+encode_observation(value::Tuple,source_table) = Dict("__type__"=>"Tuple",
+ "values"=>[encode_observation(item,source_table) for item in value])
+encode_observation(value::AbstractArray,source_table) = Dict("__type__"=>"Array","size"=>collect(size(value)),
+ "values"=>[encode_observation(item,source_table) for item in vec(value)])
+encode_observation(value::AbstractDict,source_table) = Dict("__type__"=>"Dictionary",
+ "entries"=>[encode_observation((key,item),source_table) for (key,item) in value])
+encode_observation(value::Complex,source_table) = Dict("__type__"=>"Complex",
+ "re"=>encode_observation(real(value),source_table),"im"=>encode_observation(imag(value),source_table))
+encode_observation(value::Commons.ObservedResult,source_table) = Dict("__type__"=>"ObservedResult",
+ "fields"=>encode_observation((value.gridpoint,value.quantities,value.errors,value.timings),source_table))
+
+function serialize_value(value::Union{Commons.ObservedResult,AbstractVector{<:Commons.ObservedResult}})
+ return serialize_value(value,Val(:observed))
+end
+function serialize_value(value,::Val{:observed})
+ source_table=_observed_encoding()
+ payload=encode_observation(value,source_table)
+ return Dict("__type__"=>"ObservedArchive","payload"=>payload,
+ "sources"=>serialize_value(source_table.sources,Val(:scientific)))
+end
+function serialize_value(artifact::ReportBuilder.ReportArtifact)
+ return serialize_value((observed=artifact.observed,reference=artifact.reference),Val(:observed))
+end
+
+_decode_observed(value,sources) = deserialize_value(value)
+function _decode_observed(value::AbstractDict,sources)
+ marker=get(value,"__type__",nothing)
+ if marker=="ObservedResult"
+ return Commons.ObservedResult(_decode_observed(value["fields"],sources)...)
+ elseif marker=="ObservedMeasurement"
+ return decode_observation_measurement(value,sources)
+ elseif marker=="NamedTuple"
+ return NamedTuple{Tuple(Symbol.(value["names"]))}(Tuple(_decode_observed(item,sources) for item in value["values"]))
+ elseif marker=="Tuple"
+ return Tuple(_decode_observed(item,sources) for item in value["values"])
+ elseif marker=="Array"
+ elements=map(item -> _decode_observed(item,sources),value["values"])
+ if !isempty(elements)
+ T=typeof(first(elements))
+ if all(item -> item isa T,elements)
+ elements=collect(T,elements)
+ elseif all(item -> item isa Number || ismissing(item),elements)
+ scalar_type=foldl((left,right) -> Union{left,right},typeof.(elements))
+ elements=collect(scalar_type,elements)
+ end
+ end
+ return reshape(elements,Tuple(Int.(value["size"])))
+ elseif marker=="Dictionary"
+ return Dict(_decode_observed(entry,sources) for entry in value["entries"])
+ elseif marker=="Complex"
+ return complex(_decode_observed(value["re"],sources),_decode_observed(value["im"],sources))
+ end
+ return deserialize_value(value)
+end
+
+"""Restore one uncertain scalar from its archived sensitivities and shared sources."""
+function decode_observation_measurement end
+function deserialize_extension(::Val{:ObservedArchive},record)
+ records=deserialize_value(record["sources"])
+ sources=isempty(records) ? () : observation_sources(records,Val(:measurements))
+ return _decode_observed(record["payload"],sources)
+end
+"""Restore the archive's independent uncertainty sources once, before its values."""
+function observation_sources(records,::Val{:measurements})
+ throw(ArgumentError("restoring uncertain observations requires using Measurements"))
+end
+
+serialize_value(value::UUIDs.UUID) = Dict("__type__"=>"UUID","value"=>string(value))
+deserialize_extension(::Val{:UUID},record) = UUIDs.UUID(record["value"])
+serialize_value(::Colon) = Dict("__type__"=>"Colon")
+deserialize_extension(::Val{:Colon},record) = Colon()
+serialize_value(value::Units.Quantity{Q}) where {Q} = Q isa Tuple ?
+ Dict("__type__"=>"Quantity","parts"=>string.(collect(Q))) :
+ Dict("__type__"=>"Quantity","name"=>string(Q))
+deserialize_extension(::Val{:Quantity},record) = haskey(record,"parts") ?
+ Units.Quantity{Tuple(Symbol.(record["parts"]))}() :
+ Units.Quantity{Symbol(record["name"])}()
+serialize_value(value::Units.Unit) = Dict("__type__"=>"Unit","name"=>string(value.name),"prefix"=>string(value.prefix))
+deserialize_extension(::Val{:Unit},record) = Units.Unit(Symbol(record["name"]),Symbol(record["prefix"]))
+serialize_value(value::Units.UnitExpr) = Dict("__type__"=>"UnitExpr",
+ "numerator"=>serialize_value(value.numerator,Val(:scientific)),"denominator"=>serialize_value(value.denominator,Val(:scientific)))
+deserialize_extension(::Val{:UnitExpr},record) = Units.UnitExpr(deserialize_value(record["numerator"]),deserialize_value(record["denominator"]))
+function serialize_value(value::Type)
+ owner=parentmodule(value)
+ path=Base.fullname(owner)
+ first(path) in (:LineCableModels,:Base,:Core) || throw(ArgumentError("no portable type identity for $value"))
+ getfield(owner,nameof(value))===value || throw(ArgumentError("parametric runtime types are not portable formulation identities"))
+ return Dict("__type__"=>"ScientificType","path"=>string.(path),"name"=>string(nameof(value)))
+end
+function deserialize_extension(::Val{:ScientificType},record)
+ path=Symbol.(record["path"])
+ root=first(path)
+ owner=root===:LineCableModels ? LineCableModels : root===:Base ? Base : root===:Core ? Core :
+ throw(ArgumentError("unknown scientific type owner"))
+ for name in path[2:end]
+ owner=getfield(owner,name)
+ owner isa Module || throw(ArgumentError("scientific type owner must be a module"))
+ end
+ value=getfield(owner,Symbol(record["name"]))
+ value isa Type || throw(ArgumentError("saved scientific identity does not name a type"))
+ return value
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Save current observed data as JSON or a native Julia archive. One shared source table
+preserves shared uncertainties across quantities, points, and a report reference.
+BigFloat values retain their precision. Report tables and figures are rebuilt
+from these observations after loading.
+"""
+function save(value::Union{Commons.ObservedResult,AbstractVector{<:Commons.ObservedResult},ReportBuilder.ReportArtifact},path::AbstractString)
+ encoded=serialize_value(value)
+ extension=lowercase(splitext(path)[2])
+ extension in (".json",".jls") || throw(ArgumentError("observed archives require .json or .jls"))
+ destination=abspath(path)
+ temporary=tempname(dirname(destination))
+ try
+ open(temporary,"w") do io
+ extension==".json" ? JSON3.write(io,encoded) : Serialization.serialize(io,encoded)
+ end
+ mv(temporary,destination;force=true)
+ finally
+ isfile(temporary) && rm(temporary)
+ end
+ return destination
+end
+
+function import_data(::Val{:observed},path::AbstractString)
+ extension=lowercase(splitext(path)[2])
+ encoded=extension==".json" ? JSON3.read(read(path,String)) : extension==".jls" ?
+ open(Serialization.deserialize,path) : throw(ArgumentError("observed archives require .json or .jls"))
+ get(encoded,"__type__",nothing)=="ObservedArchive" || throw(ArgumentError("file is not a current observed archive"))
+ return deserialize_value(encoded)
+end
diff --git a/src/importexport/paths.jl b/src/importexport/paths.jl
new file mode 100644
index 000000000..4ef111f0c
--- /dev/null
+++ b/src/importexport/paths.jl
@@ -0,0 +1,16 @@
+_display_path(path::AbstractString) =
+ try
+ relpath(abspath(path), pwd())
+ catch
+ basename(path)
+ end
+
+function _json_path(file_name::AbstractString)
+ path = isabspath(file_name) ? String(file_name) : abspath(file_name)
+ extension = lowercase(splitext(path)[2])
+ isempty(extension) && return path * ".json"
+ extension == ".json" || throw(ArgumentError(
+ "JSON output requires a .json extension; got '$extension'",
+ ))
+ return path
+end
diff --git a/src/importexport/problem.jl b/src/importexport/problem.jl
new file mode 100644
index 000000000..8cf8c7780
--- /dev/null
+++ b/src/importexport/problem.jl
@@ -0,0 +1,63 @@
+"""
+$(TYPEDSIGNATURES)
+
+Write one fully materialized line-parameter problem as versioned JSON.
+
+# Arguments
+
+- `problem`: completed scalar line-parameter problem.
+
+# Keywords
+
+- `file_name`: destination JSON file.
+
+# Returns
+
+- Absolute path of the written file.
+"""
+function export_data(
+ ::Val{:json},
+ problem::Engine.LineParametersProblem;
+ file_name::AbstractString
+)
+ path = _json_path(String(file_name))
+ open(path, "w") do io
+ #! explicit-imports: off
+ JSON3.pretty(io, _json_document(problem); allow_inf = true)
+ #! explicit-imports: on
+ end
+ return abspath(path)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Read one fully materialized line-parameter problem from versioned JSON.
+
+# Arguments
+
+- `LineParametersProblem`: requested result type.
+
+# Keywords
+
+- `file_name`: source JSON file.
+
+# Returns
+
+- A validated scalar [`Engine.LineParametersProblem`](@ref).
+"""
+function import_data(
+ ::Val{:json},
+ ::Type{Engine.LineParametersProblem};
+ file_name::AbstractString
+)
+ isfile(file_name) || throw(ArgumentError(
+ "line-parameter problem file not found: '$(_display_path(file_name))'",
+ ))
+ document = _read_document(file_name, PROBLEM_SCHEMA)
+ problem = deserialize_value(_required(document, "root", PROBLEM_SCHEMA))
+ problem isa Engine.LineParametersProblem || throw(ArgumentError(
+ "line-parameter problem document did not decode as LineParametersProblem",
+ ))
+ return problem
+end
diff --git a/src/importexport/pscad.jl b/src/importexport/pscad.jl
deleted file mode 100644
index c1ab4bc70..000000000
--- a/src/importexport/pscad.jl
+++ /dev/null
@@ -1,868 +0,0 @@
-#=
-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.r_in)),
- )
- push!(
- coax1_params_vector,
- ("R2", format_nominal(component.conductor_group.r_ex)),
- )
- 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.r_ex)),
- )
- 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.r_ex)),
- )
- 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.r_ex)),
- )
- 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.r_ex)),
- )
- 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.r_ex)),
- )
- 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.r_ex)),
- )
- 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.r_ex)),
- )
- 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
diff --git a/src/importexport/serialize.jl b/src/importexport/serialize.jl
index aae900d3b..41d4d68cc 100644
--- a/src/importexport/serialize.jl
+++ b/src/importexport/serialize.jl
@@ -1,227 +1,357 @@
-"""
-$(TYPEDSIGNATURES)
+const JSON_SCHEMA_VERSION = "1.0.0"
+const JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"
+const MATERIALS_SCHEMA = "linecablemodels.materials"
+const CABLES_SCHEMA = "linecablemodels.cable"
+const PROBLEM_SCHEMA = "linecablemodels.line_parameters_problem"
+
+_scalar_tag(::Type{Float16}) = "Float16"
+_scalar_tag(::Type{Float32}) = "Float32"
+_scalar_tag(::Type{Float64}) = "Float64"
+_scalar_tag(::Type{BigFloat}) = "BigFloat"
+
+"""Encode a supported scalar, collection, Grid, or v1 declaration."""
+function serialize_value(value::AbstractFloat)
+ tag = _scalar_tag(typeof(value))
+ encoded_value = value isa BigFloat ? string(value) : value
+ if isfinite(value)
+ return value isa BigFloat ? Dict("__type__"=>tag,"value"=>encoded_value,"precision"=>precision(value)) :
+ Dict("__type__" => tag, "value" => encoded_value)
+ end
+ special = isnan(value) ? "NaN" : signbit(value) ? "-Inf" : "Inf"
+ return Dict("__type__" => tag, "special" => special)
+end
-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.
+serialize_value(value::Integer) = value
+serialize_value(value::Union{Nothing, String, Bool}) = value
+serialize_value(value::Symbol) = Dict("__type__" => "Symbol", "value" => String(value))
+function serialize_value(value::Complex)
+ return Dict(
+ "__type__" => "Complex",
+ "re" => serialize_value(real(value)),
+ "im" => serialize_value(imag(value))
+ )
+end
+function serialize_value(value::AbstractDict)
+ Dict(string(key) => serialize_value(item) for (key, item) in value)
+end
+function serialize_value(value::NamedTuple)
+ Dict(string(key) => serialize_value(item) for (key, item) in pairs(value))
+end
+function serialize_value(value::Union{AbstractVector, Tuple})
+ [serialize_value(item) for item in value]
+end
-# Arguments
+function _node(kind::AbstractString; fields...)
+ Dict{String, Any}(
+ "kind" => String(kind),
+ (String(name) => serialize_value(value) for (name, value) in pairs(fields))...
+ )
+end
-- `obj`: The object whose serializable fields are to be determined.
+function _material_record(value::Material)
+ return Dict{String, Any}(
+ "kind" => String(value.kind),
+ "rho" => serialize_value(value.rho),
+ "eps_r" => serialize_value(value.eps_r),
+ "mu_r" => serialize_value(value.mu_r),
+ "T0" => serialize_value(value.T0),
+ "alpha" => serialize_value(value.alpha),
+ "rho_thermal" => serialize_value(value.rho_thermal),
+ "theta_max" => serialize_value(value.theta_max),
+ "tan_delta" => serialize_value(value.tan_delta),
+ "sigma_solar" => serialize_value(value.sigma_solar)
+ )
+end
-# Returns
+function _material_record(value::RadialDielectric)
+ return Dict{String, Any}(
+ "kind" => "radial_dielectric",
+ "materials" => [_material_record(m) for m in value.materials],
+ "weights" => serialize_value(value.weights),
+ "mu_r" => serialize_value(value.mu_r))
+end
-- A tuple of symbols representing the fields of `obj` that should be serialized.
+function serialize_value(value::AbstractMaterial)
+ Dict(
+ "type" => "material",
+ "value" => _material_record(value)
+ )
+end
-# Methods
+serialize_value(value::Disk) = _node("disk"; r = value.r)
+serialize_value(value::Rectangle) = _node("rectangle"; w = value.w, h = value.h)
+serialize_value(value::Ellipse) = _node("ellipse"; a = value.a, b = value.b)
+function serialize_value(value::Sector)
+ return _node(
+ "sector";
+ span = value.span,
+ r_base = value.r_base,
+ r_back = value.r_back,
+ fillet = value.fillet
+ )
+end
+serialize_value(value::Annulus) = _node("annulus"; ri = value.ri, ro = value.ro)
+serialize_value(value::Shell) = _node("shell"; t = value.t)
+serialize_value(value::Polygon) = _node("polygon"; points = value.points)
+serialize_value(value::Pose2) = _node("pose2"; x = value.x, y = value.y, φ = value.φ)
+
+function serialize_value(value::EarthLayer)
+ return _node(
+ "earth_layer";
+ rho = value.rho,
+ eps_r = value.eps_r,
+ mu_r = value.mu_r,
+ thickness = value.thickness
+ )
+end
+function serialize_value(value::EarthModel)
+ return _node(
+ "earth_model";
+ vertical_layers = value.vertical_layers,
+ layers = value.layers
+ )
+end
-$(METHODLIST)
-"""
-function _serializable_fields end
+function serialize_value(value::Ring)
+ return _node("ring"; n = value.n, r = value.r, φ0 = value.φ0,
+ span = value.span, gap_frac = value.gap_frac)
+end
+function serialize_value(value::Polar)
+ return _node("polar"; nr = value.nr, nφ = value.nφ, r0 = value.r0,
+ dr = value.dr, φ0 = value.φ0, span = value.span)
+end
+function serialize_value(value::Fill)
+ _node("fill"; r = value.r, φ = value.φ, φ0 = value.φ0, span = value.span)
+end
+function serialize_value(value::Lattice)
+ _node("lattice"; nx = value.nx, ny = value.ny, dx = value.dx, dy = value.dy)
+end
+serialize_value(value::FillFactor) = _node("fill_factor"; η = value.η)
+serialize_value(::typeof(capacity())) = _node("capacity")
+serialize_value(value::LayRatio) = _node("lay_ratio"; q = value.q)
+serialize_value(value::Pitch) = _node("pitch"; p = value.p)
+serialize_value(value::LayAngle) = _node("lay_angle"; α = value.α)
+function serialize_value(value::Helix)
+ _node("helix"; lay = value.lay, dir = value.dir, φ0 = value.φ0)
+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)
+function serialize_value(value::Region; material_name = nothing)
+ material = material_name === nothing ? serialize_value(value.material) :
+ String(material_name(value.material))
+ return Dict(
+ "kind" => "region",
+ "tag" => String(value.tag),
+ "primitive" => serialize_value(value.primitive),
+ "material" => material
+ )
+end
+function serialize_value(value::Stack; material_name = nothing)
+ return Dict(
+ "kind" => "stack",
+ "items" => [serialize_value(item; material_name) for item in value.items]
+ )
+end
+function serialize_value(value::Group; material_name = nothing)
+ return Dict(
+ "kind" => "group",
+ "name" => String(value.name),
+ "at" => serialize_value(value.at),
+ "item" => serialize_value(value.item; material_name),
+ "pattern" => serialize_value(value.pattern),
+ "path" => serialize_value(value.path),
+ "compact" => serialize_value(value.compact),
+ "boundary" => serialize_value(value.boundary)
+ )
+end
+function serialize_value(
+ value::Assembly{<:Any, <:AbstractCablePart};
+ material_name = nothing
+)
+ names = value.names === nothing ? nothing : String.(value.names)
+ return Dict(
+ "kind" => "assembly",
+ "at" => serialize_value(value.at),
+ "item" => serialize_value(value.item; material_name),
+ "pattern" => serialize_value(value.pattern),
+ "path" => serialize_value(value.path),
+ "compact" => serialize_value(value.compact),
+ "names" => names
+ )
+end
+function serialize_value(value::Assembly{<:Any, <:Tuple}; material_name = nothing)
+ return Dict(
+ "kind" => "assembly",
+ "at" => serialize_value(value.at),
+ "members" => [Dict(
+ "at" => serialize_value(member.at),
+ "item" => serialize_value(member.item; material_name)
+ ) for member in value.item]
+ )
+end
+function serialize_value(value::Enclosure; material_name = nothing)
+ fill = value.fill isa Material ?
+ (material_name === nothing ? serialize_value(value.fill) :
+ String(material_name(value.fill))) :
+ serialize_value(value.fill; material_name)
+ wall = value.wall === nothing ? nothing : serialize_value(value.wall; material_name)
+ return Dict(
+ "kind" => "enclosure",
+ "tag" => String(value.tag),
+ "at" => serialize_value(value.at),
+ "primitive" => serialize_value(value.primitive),
+ "item" => serialize_value(value.item; material_name),
+ "fill" => fill,
+ "wall" => wall
+ )
+end
-# 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).
+function serialize_value(value::CableDesign; material_name = nothing)
+ return Dict(
+ "kind" => "cable_design",
+ "cable_id" => value.cable_id,
+ "nominal_data" => [Dict(
+ "name" => String(name),
+ "value" => serialize_value(item)
+ ) for (name, item) in pairs(value.nominal_data)],
+ "origin" => serialize_value(value.origin; material_name)
+ )
+end
-# 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,
-)
+function serialize_value(value::LineCableSystem)
+ return Dict(
+ "kind" => "line_cable_system",
+ "system_id" => value.system_id,
+ "line_length" => serialize_value(value.line_length),
+ "designs" => [serialize_value(design) for design in value.designs],
+ "positions" => serialize_value(value.positions),
+ "input_positions" => serialize_value(value.input_positions),
+ "clearances" =>
+ [serialize_value(collect(row)) for row in eachrow(value.clearances)],
+ "connections" => serialize_value(value.connections),
+ "environment" => serialize_value(value.environment)
+ )
+end
-# Layer Types (Conductor Parts)
-_serializable_fields(::CircStrands) = (
- :r_in, # Needed for first layer reconstruction
- :radius_wire,
- :num_wires,
- :lay_ratio,
- :material_props,
- :temperature,
- :lay_direction,
-)
+function serialize_value(value::Engine.LineParametersProblem)
+ return Dict(
+ "kind" => "line_parameters_problem",
+ "system" => serialize_value(value.system),
+ "temperature" => serialize_value(value.temperature),
+ "earth_props" => serialize_value(value.earth_props),
+ "frequencies" => serialize_value(value.frequencies)
+ )
+end
-# Layer Types (Conductor Parts)
-_serializable_fields(::RectStrands) = (
- :r_in, # Needed for first layer reconstruction
- :thickness,
- :width,
- :num_wires,
- :lay_ratio,
- :material_props,
- :temperature,
- :lay_direction,
-)
+function serialize_value(grid::DeterministicGrid)
+ return Dict("grid" => serialize_value(collect(grid.vals)))
+end
+function serialize_value(grid::RelativeGrid)
+ return Dict(
+ "grid" => serialize_value(collect(grid.vals)),
+ "rel" => serialize_value(collect(grid.rel_err))
+ )
+end
+function serialize_value(grid::AbsoluteGrid)
+ return Dict(
+ "grid" => serialize_value(collect(grid.vals)),
+ "abs" => serialize_value(collect(grid.abs_err))
+ )
+end
-_serializable_fields(::Tubular) = (
- :r_in, # Needed for first layer reconstruction
- :r_ex,
- :material_props,
- :temperature,
+function _collect_materials!(materials::Vector{AbstractMaterial}, part::Region)
+ any(item -> isequal(item, part.material), materials) || push!(materials, part.material)
+ return materials
+end
+function _collect_materials!(materials::Vector{AbstractMaterial}, part::Stack)
+ foreach(item -> _collect_materials!(materials, item), part.items)
+ return materials
+end
+function _collect_materials!(materials::Vector{AbstractMaterial}, part::Group)
+ return _collect_materials!(materials, part.item)
+end
+function _collect_materials!(
+ materials::Vector{AbstractMaterial},
+ part::Assembly{<:Any, <:AbstractCablePart}
)
-_serializable_fields(::Strip) = (
- :r_in, # Needed for first layer reconstruction
- :r_ex,
- :width,
- :lay_ratio,
- :material_props,
- :temperature,
- :lay_direction,
+ return _collect_materials!(materials, part.item)
+end
+function _collect_materials!(
+ materials::Vector{AbstractMaterial},
+ part::Assembly{<:Any, <:Tuple}
)
+ foreach(member -> _collect_materials!(materials, member.item), part.item)
+ return materials
+end
+function _collect_materials!(materials::Vector{AbstractMaterial}, part::Enclosure)
+ _collect_materials!(materials, part.item)
+ part.fill isa Material ?
+ (any(item -> isequal(item, part.fill), materials) || push!(materials, part.fill)) :
+ _collect_materials!(materials, part.fill)
+ part.wall === nothing || _collect_materials!(materials, part.wall)
+ return materials
+end
-# Layer Types (Insulator Parts)
-_serializable_fields(::Insulator) = (
- :r_in, # Needed for first layer reconstruction
- :r_ex,
- :material_props,
- :temperature,
-)
-_serializable_fields(::Semicon) = (
- :r_in, # Needed for first layer reconstruction
- :r_ex,
- :material_props,
- :temperature,
-)
+function _json_document(library::MaterialsLibrary)
+ return Dict(
+ "\$schema" => JSON_SCHEMA_DIALECT,
+ "format" => MATERIALS_SCHEMA,
+ "version" => JSON_SCHEMA_VERSION,
+ "materials" => Dict(
+ name => _material_record(material)
+ for (name, material) in sort!(collect(library.data); by = first)
+ )
+ )
+end
+
+function _json_document(library::CablesLibrary)
+ materials = AbstractMaterial[]
+ cable_ids = sort!(collect(keys(library.data)))
+ for cable_id in cable_ids
+ _collect_materials!(materials, library.data[cable_id].origin)
+ end
+ names = Dict{Int, String}(
+ index => "$(material.kind)_$index" for (index, material) in enumerate(materials)
+ )
+ material_name(material) = names[only(findall(item -> isequal(item, material), materials))]
+ return Dict(
+ "\$schema" => JSON_SCHEMA_DIALECT,
+ "format" => CABLES_SCHEMA,
+ "version" => JSON_SCHEMA_VERSION,
+ "materials" => Dict(
+ names[index] => _material_record(material)
+ for (index, material) in enumerate(materials)
+ ),
+ "root" => Dict(
+ "kind" => "cable_library",
+ "cables" => Dict(
+ cable_id => merge(
+ serialize_value(library.data[cable_id]; material_name),
+ Dict("datasheet" => Dict(
+ String(name) => serialize_value(item)
+ for (name, item) in pairs(library.datasheets[cable_id])
+ ))
+ )
+ for cable_id in cable_ids
+ )
+ )
+ )
+end
+
+function _json_document(system::LineCableSystem)
+ return Dict(
+ "\$schema" => JSON_SCHEMA_DIALECT,
+ "format" => CABLES_SCHEMA,
+ "version" => JSON_SCHEMA_VERSION,
+ "materials" => Dict{String, Any}(),
+ "root" => serialize_value(system)
+ )
+end
-# 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 = getproperty(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
+function _json_document(problem::Engine.LineParametersProblem)
+ return Dict(
+ "\$schema" => JSON_SCHEMA_DIALECT,
+ "format" => PROBLEM_SCHEMA,
+ "version" => JSON_SCHEMA_VERSION,
+ "root" => serialize_value(problem)
+ )
end
diff --git a/src/importexport/tralin.jl b/src/importexport/tralin.jl
index 05ddf3ec0..ca8b1f27e 100644
--- a/src/importexport/tralin.jl
+++ b/src/importexport/tralin.jl
@@ -1,471 +1,486 @@
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(getproperty(soil, :base_rho_g))
- mu_r = hasfield(typeof(soil), :mu_r) ? _fmt(getproperty(soil, :mu_r)) : "1"
- eps_r = hasfield(typeof(soil), :eps_r) ? _fmt(getproperty(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(getproperty(layer, :base_rho_g))
- mu_r = hasfield(typeof(layer), :base_mur_g) ? _fmt(getproperty(layer, :base_mur_g)) : "1"
- eps_r = hasfield(typeof(layer), :base_epsr_g) ? _fmt(getproperty(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) &&
- getproperty(layer, :t) !== nothing
- ) ?
- _fmt(getproperty(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.r_ex)
- push!(lines, "CABLE,CA-$(pidx),$(_fmt(outer_R))")
-
- # Strict connection vector
- conn = getproperty(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(getproperty(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.r_in)
- rex = _fmt(cond_group.r_ex)
- 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
+ cable_system::LineCableSystem,
+ earth_props::EarthModel;
+ freq = 50.0,
+ file_name::Union{String, Nothing} = nothing
+)::String
+
+ # helpers
+ _freqs(x) = x isa AbstractVector ? collect(x) : [x]
+ _fmt(x) = string(round(Float64(nominal(x)); digits = 6))
+ _maybe(x) = (x === nothing) ? "" : _fmt(x)
+
+ # Prefix the output filename with "tr_", matching the XML exporter.
+ if isnothing(file_name)
+ file_name = joinpath(@__DIR__, "tr_$(cable_system.system_id).f05")
+ else
+ dir = dirname(file_name)
+ fname = basename(file_name)
+ # Add the prefix while preserving the supplied filename.
+ prefixed_fname = startswith(fname, "tr_") ? fname : "tr_" * fname
+ # Resolve relative paths beside this exporter.
+ file_name = isabspath(file_name) ? joinpath(dir, prefixed_fname) :
+ joinpath(@__DIR__, dir, prefixed_fname)
+ end
+
+ freqs = map(f -> nominal(f), _freqs(freq))
+ # TRALIN defines this explicit radial homogenization choice. The choice is not stored
+ # on CableDesign. Unsupported physical geometry fails at the local
+ # homogenization step.
+ tralin_components(design) = DataModel.flatten(
+ design,
+ first(freqs)
+ )
+
+ # 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(soil.rho)
+ mu_r = _fmt(soil.mu_r)
+ eps_r = _fmt(soil.eps_r)
+ 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(layer.rho)
+ mu_r = _fmt(layer.mu_r)
+ eps_r = _fmt(layer.eps_r)
+
+ 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 = (
+ isfinite(layer.thickness)
+ ) ?
+ _fmt(layer.thickness) : ""
+ push!(lines, " LAYER,$lname,$rho,$thk,$mu_r,$eps_r")
+ end
+ end
+ end
+
+ push!(lines, "SYSTEM")
+
+ for (pidx, (design, position, connections)) in enumerate(zip(
+ cable_system.designs,
+ cable_system.positions,
+ cable_system.connections
+ ))
+ # Phase group position
+ push!(lines, "GROUP,PH-$(pidx),$(_fmt(position.x)),$(_fmt(position.y))")
+
+ comps_vec = tralin_components(design)
+ 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 = nominal(max(
+ comps_vec[end].conductor.r_ex,
+ comps_vec[end].dielectric.r_ex
+ ))
+ push!(lines, "CABLE,CA-$(pidx),$(_fmt(outer_R))")
+
+ # Strict connection vector
+ conn = connections
+ if length(conn) < ncomp
+ throw(
+ ArgumentError(
+ "connection-vector 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(comp.name)
+
+ conn_val = Int(conn[i]) # 0 or 1..N phases
+
+ conductor = comp.conductor
+ dielectric = comp.dielectric
+
+ rin = _fmt(conductor.r_in)
+ rex = _fmt(conductor.r_ex)
+ rho = _fmt(conductor.material.rho / 1.724e-8)
+ muC = _fmt(conductor.material.mu_r)
+ epsI = _fmt(dielectric.material.eps_r)
+
+ # COMPONENT,,,,,,,0,
+ push!(lines, "$label,$comp_id,$conn_val,$rex,$rin,$rho,$muC,0,$epsI")
+ end
+ end
+
+ push!(lines, "ENDPROGRAM")
+
+ 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
end
-
-# --- internal utility: slice a block between an anchor and the next page header ---
+# 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"))
+ 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)
+ # Stop before the next page header.
+ 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)]
+ # 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)
+ 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
+ # Read the fifth integer (PHASE) and retain distinct nonzero values.
+ 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 ---
+# public: extract the frequency vector from the "FREQUENCY OF HARMONIC CURRENT" section
"""
- extract_tralin_frequencies(file_or_lines) -> Vector{Float64}
+$(TYPEDSIGNATURES)
+
+Read operating frequencies from a TRALIN output section \\[Hz\\].
+
+# Arguments
+
+- `file_or_lines`: TRALIN output path or preloaded file lines.
-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\\].
+# Returns
-Accepts either a filename (`AbstractString`) or a preloaded `Vector{String}` with file lines.
+- Operating frequencies through the next page header \\[Hz\\].
+
+# Errors
+
+- Throws `ArgumentError` when the frequency section is absent or empty.
"""
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
+ 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
+ # Read the second column as a floating-point value with optional 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)
+$(TYPEDSIGNATURES)
+
+Parse frequency-indexed impedance, admittance, and potential-coefficient
+matrices from a TRALIN output file.
+
+# Arguments
-Parse a TRALIN file and extract impedance, admittance, and potential coefficient matrices
-for multiple frequency samples.
+- `filename`: TRALIN output path.
+
+# Returns
+
+- A tuple containing frequencies \\[Hz\\], series impedance \\[Ω/m\\], shunt
+ admittance \\[S/m\\], and potential coefficients \\[Ω·m\\].
"""
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
+ 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 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)
+$(TYPEDSIGNATURES)
+
+Parse one upper-triangular complex matrix between two TRALIN section headers.
+
+# Arguments
+
+- `fileLines`: TRALIN output lines beginning before `str_init`.
+- `order`: matrix order.
+- `str_init`: header that begins the matrix section.
+- `str_final`: header that ends the matrix section.
-Extracts matrix data between specified headers in `fileLines`, handling complex formatting.
+# Returns
+
+- A symmetric `order × order` complex matrix. Missing headers produce a zero
+ matrix after writing a diagnostic to standard output.
"""
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
+ # 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 and fill the matrix, padding incomplete rows when 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)
+$(TYPEDSIGNATURES)
-Parses a string to identify real and complex numbers, with conditional scaling for scientific notation.
+Parse the leading row index and `a + j b` values from one TRALIN matrix row.
"""
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
+ 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)
+$(TYPEDSIGNATURES)
-Cleans and arranges extracted list into a proper matrix format.
+Remove row labels and pad parsed TRALIN rows to `order × order`.
"""
function clean_variable_list(data, order)
- # Remove entries that lack values, filter short lists
- filter!(lst -> length(lst) > 1, data)
+ # 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]
+ # Remove row labels.
+ 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]
+ # Pad each row to the requested 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
+ # Append rows of zeros to reach the requested order.
+ 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
+ return data_padded
end
-# -- Direct TRALIN constructor
+# Direct TRALIN constructor
function LineParameters(::Val{:tralin}, file_name::AbstractString)
- f, Z_tralin, Y_tralin, _ = parse_tralin_file(file_name)
+ 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)
+ # Convert parsed values to the requested numeric types.
+ Z = ComplexF64.(Z_tralin)
+ Y = ComplexF64.(Y_tralin)
+ fv = Float64.(f)
- return LineParameters(SeriesImpedance(Z), ShuntAdmittance(Y), fv)
+ return LineParameters(SeriesImpedance(Z), ShuntAdmittance(Y), fv)
end
-# -- Format-auto convenience (add branches as you implement other parsers)
+# Select the parser from the requested format.
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
+ 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)"))
+# Report unsupported format symbols as argument errors.
+function LineParameters(::Val{fmt}, args...; kwargs...) where {fmt}
+ throw(ArgumentError("Unsupported format: $(fmt)"))
+end
-@inline LineParameters(fmt::Symbol, args...; kwargs...) =
- LineParameters(Val(fmt), args...; kwargs...)
+@inline LineParameters(fmt::Symbol, args...; kwargs...) = LineParameters(Val(fmt), args...; kwargs...)
diff --git a/src/importexport/uncertainty.jl b/src/importexport/uncertainty.jl
new file mode 100644
index 000000000..192f88616
--- /dev/null
+++ b/src/importexport/uncertainty.jl
@@ -0,0 +1,246 @@
+# Scientific result records are distinct from executable computation checkpoints.
+serialize_value(value, ::Val{:scientific}) = serialize_value(value)
+# JSON number readers need not preserve UInt64 values above typemax(Int64).
+# Scientific seeds must retain all 64 bits, including within formulation records.
+serialize_value(value::UInt64, ::Val{:scientific}) =
+ Dict("__type__"=>"UInt64", "value"=>string(value))
+deserialize_extension(::Val{:UInt64},record) = parse(UInt64,record["value"])
+function serialize_value(value::FormulationOptions)
+ return Dict("__type__"=>"FormulationOptions", "value"=>serialize_value(value.data, Val(:scientific)))
+end
+function serialize_value(value::ComputationOptions)
+ return Dict("__type__"=>"ComputationOptions", "value"=>serialize_value(value.data, Val(:scientific)))
+end
+function serialize_value(value::ComputationDetails)
+ return Dict("__type__"=>"ComputationDetails", "value"=>serialize_value(value.data, Val(:scientific)))
+end
+deserialize_extension(::Val{:FormulationOptions}, record) =
+ FormulationOptions(deserialize_value(record["value"]))
+deserialize_extension(::Val{:ComputationOptions}, record) =
+ ComputationOptions(deserialize_value(record["value"]))
+deserialize_extension(::Val{:ComputationDetails}, record) =
+ ComputationDetails(deserialize_value(record["value"]))
+function serialize_value(::Val{Value}, ::Val{:scientific}) where {Value}
+ return Dict("__type__"=>"Val", "value"=>serialize_value(Value, Val(:scientific)))
+end
+function serialize_value(value::NamedTuple, ::Val{:scientific})
+ return Dict("__type__"=>"NamedTuple", "names"=>string.(collect(keys(value))),
+ "values"=>[serialize_value(item, Val(:scientific)) for item in values(value)])
+end
+function serialize_value(value::Tuple, ::Val{:scientific})
+ return Dict("__type__"=>"Tuple", "values"=>[serialize_value(item, Val(:scientific))
+ for item in value])
+end
+function serialize_value(value::AbstractArray, ::Val{:scientific})
+ return Dict("__type__"=>"Array", "size"=>collect(size(value)),
+ "values"=>[serialize_value(item, Val(:scientific)) for item in vec(value)])
+end
+serialize_value(::Missing) = Dict("__type__"=>"Missing")
+function serialize_value(value::AbstractArray{T, N}) where {T, N}
+ return Dict("__type__"=>"Array", "size"=>collect(size(value)),
+ "values"=>map(serialize_value, vec(value)))
+end
+
+function serialize_value(selector::Function)
+ selector in (
+ Engine.Z, Engine.Y, Engine.R, Engine.X, Engine.L, Engine.G, Engine.B, Engine.C,
+ ModalAnalysis.gamma, ModalAnalysis.alpha, ModalAnalysis.beta, ModalAnalysis.velocity,
+ ModalAnalysis.Zc, ModalAnalysis.Yc, ModalAnalysis.H,
+ ModalAnalysis.Tv, ModalAnalysis.Ti,
+ frequencies, UQ.statistics, UQ.samples, UQ.histograms, Statistics.mean, Statistics.std,
+ Statistics.median, minimum, maximum, abs, angle, real, imag,
+ LinearAlgebra.diag) || throw(ArgumentError(
+ "no portable scientific selector codec for $selector"))
+ return Dict("__type__"=>"Observable", "name"=>string(nameof(selector)))
+end
+function serialize_value(selector::Base.Fix2{typeof(Statistics.quantile)})
+ Dict("__type__"=>"Quantile", "probability"=>serialize_value(selector.x))
+end
+function serialize_value(selector::Base.Fix2{F}) where {F<:Union{typeof(ModalAnalysis.Zc),
+ typeof(ModalAnalysis.Yc),typeof(ModalAnalysis.H)}}
+ selector in Commons.observables(ModalAnalysis.PropagationParameters) ||
+ throw(ArgumentError("unsupported modal representation selector"))
+ return Dict("__type__"=>"ModalRepresentation",
+ "selector"=>string(nameof(selector.f)),
+ "field"=>selector.f===ModalAnalysis.H ? string(selector.x.field) : nothing)
+end
+
+function deserialize_extension(::Val{:Observable}, record)
+ selectors=(
+ Engine.Z, Engine.Y, Engine.R, Engine.X, Engine.L, Engine.G, Engine.B, Engine.C,
+ ModalAnalysis.gamma, ModalAnalysis.alpha, ModalAnalysis.beta, ModalAnalysis.velocity,
+ ModalAnalysis.Zc, ModalAnalysis.Yc, ModalAnalysis.H,
+ ModalAnalysis.Tv, ModalAnalysis.Ti,
+ frequencies, UQ.statistics, UQ.samples, UQ.histograms, Statistics.mean, Statistics.std,
+ Statistics.median, minimum, maximum, abs, angle, real, imag,
+ LinearAlgebra.diag)
+ selected=filter(selector -> string(nameof(selector)) == record["name"], selectors)
+ length(selected)==1 || throw(ArgumentError("unknown saved scientific selector"))
+ return only(selected)
+end
+function deserialize_extension(::Val{:Quantile}, record)
+ Base.Fix2(Statistics.quantile, deserialize_value(record["probability"]))
+end
+function deserialize_extension(::Val{:ModalRepresentation},record)
+ name=record["selector"]
+ field=get(record,"field",nothing)
+ if name=="H" && field in ("voltage","current")
+ return Base.Fix2(ModalAnalysis.H,(domain=Engine.PhaseDomain,field=Symbol(field)))
+ elseif name in ("Zc","Yc") && field===nothing
+ selector=name=="Zc" ? ModalAnalysis.Zc : ModalAnalysis.Yc
+ return Base.Fix2(selector,(domain=Engine.PhaseDomain,))
+ end
+ throw(ArgumentError("unknown saved modal representation"))
+end
+
+function serialize_value(value::UQ.SampleSummary)
+ return Dict("__type__"=>"SampleSummary", "values"=>serialize_value(Tuple(NamedTuple(value))))
+end
+function deserialize_extension(::Val{:SampleSummary}, record)
+ UQ.SampleSummary(deserialize_value(record["values"])...)
+end
+
+function serialize_value(value::UQ.HistogramDensity)
+ record=NamedTuple(value)
+ return Dict("__type__"=>"HistogramDensity", "edges"=>serialize_value(record.edges),
+ "density"=>serialize_value(record.density))
+end
+function deserialize_extension(::Val{:HistogramDensity}, record)
+ UQ.HistogramDensity(deserialize_value(record["edges"]), deserialize_value(record["density"]))
+end
+
+function serialize_value(value::LineParameters)
+ Engine.domain(value) === Engine.ModalDomain && throw(ArgumentError(
+ "modal LineParameters require Julia Serialization for numerical state"))
+ return Dict("__type__"=>"LineParameters", "Z"=>serialize_value(observe(value, Z)),
+ "Y"=>serialize_value(observe(value, Y)), "frequencies"=>serialize_value(frequencies(value)),
+ "basis"=>string(LineCableModels.basis(value)), "domain"=>string(nameof(Engine.domain(value))),
+ "details"=>serialize_value(LineCableModels.details(value).data,Val(:scientific)))
+end
+function deserialize_extension(::Val{:LineParameters}, record)
+ record["domain"] in ("PhaseDomain","ModalDomain") ||
+ throw(ArgumentError("unsupported saved result domain"))
+ retained=deserialize_value(record["details"])
+ return LineParameters(
+ getfield(Engine,Symbol(record["domain"])), deserialize_value(record["Z"]), deserialize_value(record["Y"]),
+ deserialize_value(record["frequencies"]); basis = Symbol(record["basis"]), details = Engine.completion_details(retained))
+end
+
+function serialize_value(value::Engine.CableConstants)
+ return Dict("__type__"=>"CableConstants", "cores"=>serialize_value(value.cores),
+ "R"=>serialize_value(value.R),"L"=>serialize_value(value.L),
+ "C"=>serialize_value(value.C),"G"=>serialize_value(value.G),
+ "frequency"=>serialize_value(value.frequency),
+ "details"=>serialize_value(value.details.data,Val(:scientific)))
+end
+function deserialize_extension(::Val{:CableConstants},record)
+ retained=deserialize_value(record["details"])
+ return Engine.CableConstants(Symbol.(deserialize_value(record["cores"])),
+ (deserialize_value(record[key]) for key in ("R","L","C","G","frequency"))...,
+ Engine.completion_details(retained))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Encode retained MC products or first-order results without solving a model.
+The versioned record retains scientific data, not executable formulations.
+Measurement-bearing MC and LEP results use the extension's shared-source codec.
+"""
+function serialize_value(value::Union{UQ.MonteCarloResult, UQ.LinearErrorResult})
+ return serialize_value(value, map(serialize_value, value.values), nothing)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Encode a UQ result envelope with already encoded core `points`. Optional
+`sources` are shared Measurement source records supplied by the Measurements
+extension. Their point records retain signed sensitivities. This codec keeps
+empirical products, source identities and details under the scientific result codec.
+"""
+function serialize_value(value::Union{UQ.MonteCarloResult,UQ.LinearErrorResult},
+ points::AbstractVector, sources)
+ record=NamedTuple(value)
+ formulation=record.formulation isa NamedTuple ? record.formulation :
+ NamedTuple(record.formulation)
+ retained = record.details.data
+ portable_details = if isempty(retained)
+ retained
+ elseif value isa UQ.LinearErrorResult
+ (points=map(point -> point.data, retained.points),)
+ elseif haskey(retained, :trials)
+ merge(retained, (trials=map(trials -> map(trial -> trial.data, trials), retained.trials),))
+ else
+ retained
+ end
+ return Dict(
+ "__type__"=>value isa UQ.MonteCarloResult ? "MonteCarloResult" :
+ "LinearErrorResult",
+ "version"=>2, "formulation"=>serialize_value(formulation, Val(:scientific)),
+ "points"=>points, "sources"=>serialize_value(sources,Val(:scientific)), "details"=>serialize_value(
+ portable_details, Val(:scientific)),
+ "statistics"=>serialize_value(get(record, :statistics, nothing), Val(:scientific)),
+ "samples"=>serialize_value(get(record, :samples, nothing), Val(:scientific)),
+ "histograms"=>serialize_value(get(record, :histograms, nothing), Val(:scientific)),
+ "root_seed"=>serialize_value(get(record, :root_seed, nothing),Val(:scientific)),
+ "point_seeds"=>serialize_value(get(record, :point_seeds, nothing),Val(:scientific)),
+ "trial_counts"=>get(record, :trial_counts, nothing))
+end
+
+function deserialize_extension(kind::Union{Val{:MonteCarloResult}, Val{:LinearErrorResult}}, record)
+ record["version"] in (1,2) ||
+ throw(ArgumentError("unsupported scientific UQ record version"))
+ decoded=deserialize_value(record["formulation"])
+ formulation=(; (Symbol(k)=>v for (k, v) in pairs(decoded))...)
+ options=(; (Symbol(k)=>v for (k, v) in pairs(formulation.options))...)
+ formulation=merge(formulation, (; options))
+ points=if get(record,"sources",nothing) === nothing
+ map(deserialize_value, record["points"])
+ else
+ Base.get_extension(LineCableModels,:LineCableModelsMeasurementsExt) === nothing &&
+ throw(ArgumentError("restoring uncertainty-bearing results requires `using Measurements`"))
+ deserialize_extension(Val(:MeasurementPoints),record)
+ end
+ details=deserialize_value(record["details"])
+ retained=(; (Symbol(k)=>v for (k, v) in pairs(details))...)
+ if !isempty(retained)
+ if kind isa Val{:LinearErrorResult}
+ records=map(retained.points,points) do detail,point
+ point isa Union{Engine.CableConstants,LineParameters} ?
+ Engine.completion_details(detail) : ComputationDetails(detail)
+ end
+ retained=(points=records,)
+ elseif haskey(retained, :trials)
+ records=map(retained.trials,points) do trials,point
+ map(trials) do detail
+ point isa Union{Engine.CableConstants,LineParameters} ?
+ Engine.completion_details(detail) : ComputationDetails(detail)
+ end
+ end
+ retained=merge(retained,(trials=records,))
+ end
+ end
+ details=ComputationDetails(retained)
+ kind isa Val{:LinearErrorResult} &&
+ return UQ.LinearErrorResult(formulation, points, details)
+ products=map(("statistics", "samples", "histograms")) do key
+ values=deserialize_value(record[key])
+ values === nothing && return nothing
+ [(; (Symbol(k)=>v for (k, v) in pairs(point))...) for point in values]
+ end
+ root_seed=deserialize_value(record["root_seed"])
+ point_seeds=deserialize_value(record["point_seeds"])
+ root_seed isa Integer && !(root_seed isa Bool) &&
+ all(seed -> seed isa Integer && !(seed isa Bool),point_seeds) ||
+ throw(ArgumentError("scientific Monte Carlo seeds require exact integers; floating-point JSON seeds cannot preserve seeded replay"))
+ if record["version"] == 1
+ # Supported portable MC v1 records retained full empirical summaries
+ # but only mean-valued cores. Restore their documented marginal result
+ # through the UQ-owned materialization, never through native checkpoints.
+ points=map(LineCableModels.materialize,points,first(products))
+ end
+ return UQ.MonteCarloResult(
+ formulation, points, products..., UInt64(root_seed),
+ UInt64.(point_seeds), Int.(record["trial_counts"]), details)
+end
diff --git a/src/importexport/xlsx.jl b/src/importexport/xlsx.jl
deleted file mode 100644
index 6e50767fa..000000000
--- a/src/importexport/xlsx.jl
+++ /dev/null
@@ -1,161 +0,0 @@
-# import DataFrames: DataFrame
-# using DataFrames
-
-# ---------------------------------------------------------------------------
-# Stringification helpers (keeps uncertainties visible in Excel)
-# ---------------------------------------------------------------------------
-stringify(x) = string(x) # fallback (rarely reached)
-stringify(::Missing) = ""
-stringify(x::Real) = @sprintf("%.12g", float(x))
-stringify(x::Measurements.Measurement) =
- @sprintf("%.12g ± %.6g", Measurements.value(x), Measurements.uncertainty(x))
-
-function df_to_strings(df::DataFrame)
- DataFrame((name => stringify.(df[!, name]) for name in names(df))...; copycols = false)
-end
-
-# helper to fetch the units dict from df.metadata
-_get_units(df::DataFrame) =
- try
- DataFrames.metadata(df, "units", style = :note)
- catch
- try
- DataFrames.metadata(df, "units")
- catch
- nothing
- end
- end
-
-# ---------------------------------------------------------------------------
-# XLSX sheet writer: reuses/renames Sheet1 for the first write to avoid blanks
-# ---------------------------------------------------------------------------
-function _write_sheet!(xf, sheetname::String, df::DataFrame; use_first_sheet::Bool)
- units = _get_units(df)
- df_str = df_to_strings(df)
-
- ws = nothing
- if use_first_sheet
- ws = try
- xf["Sheet1"] # reuse default first sheet
- catch
- nothing
- end
- ws = ws === nothing ? XLSX.addsheet!(xf, sheetname) : ws
- # If rename! exists, great; if not, we still write so Sheet1 isn't blank.
- try
- XLSX.rename!(ws, sheetname)
- catch
- end
- else
- ws = XLSX.addsheet!(xf, sheetname)
- end
-
- # Start row for writing
- start_row = 1
-
- # Optional UNITS block (Column | Unit) from DataFrame metadata
- if units isa AbstractDict
- for name in names(df)
- ws[start_row, 1] = String(name)
- u = get(units, name, get(units, Symbol(name), ""))
- ws[start_row, 2] = String(u)
- start_row += 1
- end
- start_row += 1 # spacer line
- end
-
- # IMPORTANT: anchor_cell must be a CellRef, not a String
- XLSX.writetable!(
- ws,
- Tables.columntable(df_str);
- anchor_cell = XLSX.CellRef(start_row, 1),
- )
- return nothing
-end
-
-
-# ---------------------------------------------------------------------------
-# Main export
-# ---------------------------------------------------------------------------
-function export_data(
- ::Val{:xlsx},
- line_params::LineParameters;
- file_name::Union{String, Nothing} = nothing,
- cable_system::Union{LineCableSystem, Nothing} = nothing,
-)::Union{String, Nothing}
-
- # ---- Resolve final file_name (exactly as requested) --------------------
- if isnothing(file_name)
- if isnothing(cable_system)
- file_name = joinpath(@__DIR__, "ZY_export.xlsx")
- else
- file_name = joinpath(@__DIR__, "$(cable_system.system_id)_ZY_export.xlsx")
- end
- else
- 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
-
- # ---- Build the DataFrames once (uses LP.f internally) ------------------
- df_z, df_y = DataFrame(line_params) # each is Matrix{DataFrame}
-
- # Shapes
- nzx, nzy = size(df_z)
- nyx, nyy = size(df_y)
-
- # Diagonal-only logic (modal parameters)
- Z_isdiag = isdiag_approx(line_params.Z[:, :, 1])
- Y_isdiag = isdiag_approx(line_params.Y[:, :, 1])
-
- if Z_isdiag
- @warn "Z appears modal/diagonal (isdiag_approx=true). Exporting ONLY diagonal elements Z[i,i]; off-diagonals are intentionally omitted."
- end
- if Y_isdiag
- @warn "Y appears modal/diagonal (isdiag_approx=true). Exporting ONLY diagonal elements Y[i,i]; off-diagonals are intentionally omitted."
- end
-
- # ---- Write XLSX --------------------------------------------------------
- try
- first_sheet = true
- XLSX.openxlsx(file_name, mode = "w") do xf
- # Z sheets
- if Z_isdiag
- for i in 1:min(nzx, nzy)
- _write_sheet!(xf, "Z($i,$i)", df_z[i, i]; use_first_sheet = first_sheet)
- first_sheet = false
- end
- else
- for i in 1:nzx, j in 1:nzy
- _write_sheet!(xf, "Z($i,$j)", df_z[i, j]; use_first_sheet = first_sheet)
- first_sheet = false
- end
- end
-
- # Y sheets
- if Y_isdiag
- for i in 1:min(nyx, nyy)
- _write_sheet!(xf, "Y($i,$i)", df_y[i, i]; use_first_sheet = first_sheet)
- first_sheet = false
- end
- else
- for i in 1:nyx, j in 1:nyy
- _write_sheet!(xf, "Y($i,$j)", df_y[i, j]; use_first_sheet = first_sheet)
- first_sheet = false
- end
- end
- end
-
- return file_name
- catch err
- # If anything explodes (e.g., filesystem perms), return nothing.
- # Let the caller decide whether to rethrow.
- @error "Failed to export XLSX: $(err)"
- return nothing
- end
-end
diff --git a/src/interfaces.jl b/src/interfaces.jl
new file mode 100644
index 000000000..f26dee5d1
--- /dev/null
+++ b/src/interfaces.jl
@@ -0,0 +1,105 @@
+"""
+Add one owned value to a mutable collection.
+"""
+function add! end
+
+"""
+ build(Target, declarations...; kwargs...)
+
+Construct one completed domain object from complete physical declarations.
+
+`build(CableDesign, ...)` resolves a physical cable root and terminal state.
+`build(LineCableSystem, ...)` places completed designs and resolves global
+connections. When an argument is a `Grid` or `Gridspace`, or an admitted tuple
+or vector contains one, the same call returns a `Gridspace{Target}` whose
+callable invokes scalar `build` after selecting and reconstructing one complete
+point.
+
+# Arguments
+
+- `Target`: completed domain type to construct.
+- `declarations`: complete physical declarations owned by `Target`.
+
+# Returns
+
+One completed `Target`, or `Gridspace{Target}` for explicit finite inputs.
+"""
+function build end
+
+"""
+ Gridpoint{Target}(build, args)
+
+Store one selected but unresolved argument tuple from a finite parameter space.
+`Target` preserves the semantic object or problem family that the point will
+materialize, allowing computation dispatch to consume a scalar point without
+first discarding its target identity.
+"""
+struct Gridpoint{Target, F, A <: Tuple}
+ "Scalar constructor or lowering function selected by the finite space."
+ build::F
+ "Selected argument tuple, with any nested target-bearing points retained."
+ args::A
+end
+
+function Gridpoint{Target}(build, args::A) where {Target, A <: Tuple}
+ return Gridpoint{Target, typeof(build), A}(build, args)
+end
+
+"""
+homogenize(design. New_id="")
+
+Build a homogeneous cable design that preserves each radial assembly member
+and matches the effective conductor and dielectric properties of `design`.
+
+The physical source design remains unchanged. The reduction uses only scalar
+series and parallel circuit calculations. It does not calculate line-parameter
+matrices, mutual coupling, or earth return.
+
+# Arguments
+
+- `design`: completed physical cable design.
+
+# Keywords
+
+- `new_id`: identifier for the returned design. An empty value appends
+ `"_equivalent"` to the source identifier.
+
+# Returns
+
+- A completed homogeneous `CableDesign`.
+"""
+function homogenize end
+
+"""
+Evaluate the constitutive relation selected for a material value.
+"""
+function constitutive end
+
+"""
+Return the physical storage basis of a result.
+"""
+function basis end
+function line_length end
+
+function R end
+function L end
+function C end
+function resistance end
+function inductance end
+function capacitance end
+
+"""
+Return owned scientific text for a formulation. `compact=true` selects its
+short display name. `formula_id` remains its scientific identity.
+"""
+function description end
+
+"""
+Declare a formula selection for resolution by its receiving formulation owner.
+"""
+function formula end
+
+"""
+Return the stable scientific identifier of a formula selection.
+"""
+function formula_id end
diff --git a/src/logging.jl b/src/logging.jl
new file mode 100644
index 000000000..6ecbe2e1d
--- /dev/null
+++ b/src/logging.jl
@@ -0,0 +1,66 @@
+"""
+$(TYPEDSIGNATURES)
+
+Validate verbosity levels, or select a level from execution options. Levels
+0, 1, and 2 permit warnings, information, and debug messages respectively.
+The `progress` group uses its explicit level or `default`. Other messages use
+the nearest explicitly configured module ancestor before falling back to `default`.
+"""
+function verbosity(levels::NamedTuple)
+ haskey(levels, :default) ||
+ throw(ArgumentError("verbosity must define a default level"))
+ all(value -> value isa Integer && value in 0:2, values(levels)) ||
+ throw(ArgumentError("verbosity levels must be integers from 0 to 2"))
+ return NamedTuple{keys(levels)}(Int.(values(levels)))
+end
+verbosity(levels) = throw(ArgumentError("verbosity must be a named tuple"))
+function verbosity(record::ComputationOptions, key::Symbol)
+ levels = get(record.data, :verbosity, (default = 0,))
+ return get(levels, key, levels.default)
+end
+function verbosity(levels::NamedTuple, source::Union{Module, Nothing}, group)
+ group === :progress && return get(levels, :progress, levels.default)
+ source === nothing && return levels.default
+ while true
+ haskey(levels, nameof(source)) && return levels[nameof(source)]
+ ancestor = parentmodule(source)
+ ancestor === source && return levels.default
+ source = ancestor
+ end
+end
+
+"""
+$(TYPEDEF)
+
+Filter ordinary Julia log records by execution verbosity and forward accepted
+records to the caller's logger. The parent logger retains its filtering and
+exception handling. Progress counters and output resources remain outside this filter.
+
+$(TYPEDFIELDS)
+"""
+struct VerbosityLogger{L <: Logging.AbstractLogger, V <: NamedTuple} <:
+ Logging.AbstractLogger
+ "Caller-owned destination."
+ parent::L
+ "Validated verbosity levels."
+ levels::V
+end
+
+# Julia's logger protocol methods are not public stdlib bindings.
+function Logging.min_enabled_level(logger::VerbosityLogger)
+ Logging.min_enabled_level(logger.parent)
+end
+Logging.catch_exceptions(logger::VerbosityLogger) = Logging.catch_exceptions(logger.parent)
+function Logging.shouldlog(logger::VerbosityLogger, level, source, group, id)
+ selected = verbosity(logger.levels, source, group)
+ threshold = selected == 0 ? Logging.Warn : selected == 1 ? Logging.Info : Logging.Debug
+ return level >= threshold && level >= Logging.min_enabled_level(logger.parent) &&
+ Logging.shouldlog(logger.parent, level, source, group, id)
+end
+function Logging.handle_message(logger::VerbosityLogger, level, message, source, group,
+ id, file, line; kwargs...)
+ return Logging.handle_message(logger.parent, level, message, source, group, id,
+ file, line; kwargs...)
+end
+
+public verbosity, VerbosityLogger
diff --git a/src/materials/Materials.jl b/src/materials/Materials.jl
index 3bf599633..209952bbc 100644
--- a/src/materials/Materials.jl
+++ b/src/materials/Materials.jl
@@ -1,97 +1,43 @@
"""
- LineCableModels.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.
+Define electromagnetic material records and an in-memory material library.
-# Overview
+# Public actions
-- 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.
+- Construct and validate [`Material`](@ref) values.
+- Add, remove, and retrieve materials in a [`MaterialsLibrary`](@ref).
+- Present material data through the Base display protocol.
+
+JSON persistence belongs to `LineCableModels.ImportExport`.
# 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
-
+export AbstractMaterial, Material, RadialDielectric, MaterialsLibrary, add!
+
+#! explicit-imports: off
+# IMPORTS is expanded in the module docstring rather than called as Julia code.
+using DocStringExtensions: IMPORTS
+#! explicit-imports: on
+using DocStringExtensions: TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES, FUNCTIONNAME
+using RequiredInterfaces: @required
+import ..LineCableModels: add!, validate
+import ..Commons
+import ..TextDisplay
+
+include("material.jl")
+include("radialdielectric.jl")
+include("temperaturedependent/TemperatureDependent.jl")
+public TemperatureDependent
include("materialslibrary.jl")
-include("dataframe.jl")
include("base.jl")
-include("typecoercion.jl")
+
+@required AbstractMaterial begin
+ validate(::AbstractMaterial)
+end
end # module Materials
diff --git a/src/materials/base.jl b/src/materials/base.jl
index fdc7d1b9e..fb005c87f 100644
--- a/src/materials/base.jl
+++ b/src/materials/base.jl
@@ -3,196 +3,163 @@ 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)
+function Base.setindex!(lib::MaterialsLibrary, value::Material, key)
+ setindex!(lib.data, validate(value), key)
+ return lib
+end
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)
-
+Base.haskey(lib::MaterialsLibrary, key) = haskey(lib.data, key)
+Base.getindex(lib::MaterialsLibrary, key) = getindex(lib.data, key)
"""
$(TYPEDSIGNATURES)
-Removes a material from a [`MaterialsLibrary`](@ref).
+Remove the material stored under `name`.
# Arguments
-- `library`: Instance of [`MaterialsLibrary`](@ref) from which the material will be removed.
-- `name`: Name of the material to be removed.
+- `library`: material library.
+- `name`: stored material name.
# 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")
-```
+- The modified `library`.
-# 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."
+function Base.delete!(library::MaterialsLibrary, name)
+ delete!(library.data, name)
+ return library
end
-
-
"""
$(TYPEDSIGNATURES)
-Retrieves a material from a [`MaterialsLibrary`](@ref) by name.
+Return the material stored under `name`, or `default` when absent.
# Arguments
-- `library`: Instance of [`MaterialsLibrary`](@ref) containing the materials.
-- `name`: Name of the material to retrieve.
+- `library`: material library.
+- `name`: stored material name.
+- `default`: value returned when `name` is absent.
# Returns
-- The requested [`Material`](@ref) if found, otherwise `nothing`.
+- The stored [`Material`](@ref), or `default`.
-# Examples
+"""
+function Base.get(library::MaterialsLibrary, name, default)
+ return get(library.data, name, default)
+end
-```julia
-library = MaterialsLibrary()
-material = $(FUNCTIONNAME)(library, "copper")
-```
+function Base.get(
+ default::Union{Function, Type}, library::MaterialsLibrary, name
+)
+ return get(default, library.data, name)
+end
-# See also
+"""
+Return an empty material library without repopulating built-in records.
+"""
+Base.empty(::MaterialsLibrary) = MaterialsLibrary(; add_defaults = false)
-- [`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
+Remove every stored material and return `library`.
+"""
+function Base.empty!(library::MaterialsLibrary)
+ empty!(library.data)
+ return library
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.
+Return a shallow material-library copy with independent dictionary storage.
"""
-function Base.show(io::IO, ::MIME"text/plain", material::Material)
- print(io, "Material with properties: [")
+Base.copy(library::MaterialsLibrary) = MaterialsLibrary(copy(library.data))
- # Define fields to display
- fields = [:rho, :eps_r, :mu_r, :T0, :alpha]
+TextDisplay.name(::Type{<:Material}) = "Material"
- # Print each field with proper formatting
- for (i, field) in enumerate(fields)
- value = getproperty(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
+function Base.summary(io::IO, material::Material)
+ print(io, "Material · ", material.kind)
+end
- print(io, "]")
+function _material_fields(material::Material)
+ return (
+ ρ = TextDisplay.engineering(material.rho, :ohm_meter),
+ εᵣ = TextDisplay.value(material.eps_r),
+ μᵣ = TextDisplay.value(material.mu_r),
+ T₀ = TextDisplay.engineering(material.T0, :celsius),
+ α = iszero(material.alpha) ? nothing :
+ TextDisplay.engineering(material.alpha, :kelvin_inverse),
+ ρₜₕ = iszero(material.rho_thermal) ? nothing :
+ string(TextDisplay.value(material.rho_thermal), " K·m/W"),
+ θₘₐₓ = material.theta_max == oftype(material.theta_max, 90) ? nothing :
+ TextDisplay.engineering(material.theta_max, :celsius),
+ tanδ = iszero(material.tan_delta) ? nothing :
+ TextDisplay.value(material.tan_delta),
+ σₛ = iszero(material.sigma_solar) ? nothing :
+ TextDisplay.value(material.sigma_solar)
+ )
end
-"""
-$(TYPEDSIGNATURES)
+function Base.show(io::IO, material::Material)
+ fields = _material_fields(material)
+ print(io, "Material(:", material.kind, "; ρ=", fields.ρ,
+ ", εᵣ=", fields.εᵣ, ", μᵣ=", fields.μᵣ)
+ for key in (:ρₜₕ, :θₘₐₓ, :tanδ, :σₛ)
+ displayed = getproperty(fields, key)
+ displayed === nothing && continue
+ print(io, ", ", key, "=", displayed)
+ end
+ print(io, ")")
+end
-Defines the display representation of a [`MaterialsLibrary`](@ref) object for REPL or text output.
+function Base.show(io::IO, ::MIME"text/plain", material::Material)
+ get(io, :compact, false) && return show(io, material)
+ return TextDisplay.fields(
+ io,
+ "Material · $(material.kind)",
+ _material_fields(material);
+ multiline = true
+ )
+end
-# Arguments
+TextDisplay.name(::Type{<:MaterialsLibrary}) = "MaterialsLibrary"
-- `io`: Output stream.
-- `::MIME"text/plain"`: MIME type for plain text output.
-- `library`: The [`MaterialsLibrary`](@ref) instance to be displayed.
+function Base.summary(io::IO, library::MaterialsLibrary)
+ count = length(library)
+ print(io, "MaterialsLibrary with ", count, count == 1 ? " material" : " materials")
+end
-# Returns
+function Base.show(io::IO, library::MaterialsLibrary)
+ count = length(library)
+ print(io, "MaterialsLibrary(", count, count == 1 ? " material)" : " materials)")
+end
-- 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
+ get(io, :compact, false) && return show(io, library)
+ count = length(library)
+ header = "MaterialsLibrary · $count $(count == 1 ? "material" : "materials")"
+ children = [
+ string(name, " · ", sprint(summary, library[name]))
+ for name in sort!(collect(keys(library)))
+ ]
+ return TextDisplay.tree(io, header, children; noun = "materials")
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
+function Base.convert(::Type{Material{T}}, m::Material) where {T <: Real}
+ Material{T}(
+ m.kind,
+ convert(T, m.rho),
+ convert(T, m.eps_r),
+ convert(T, m.mu_r),
+ convert(T, m.T0),
+ convert(T, m.alpha),
+ convert(T, m.rho_thermal),
+ convert(T, m.theta_max),
+ convert(T, m.tan_delta),
+ convert(T, m.sigma_solar)
+ )
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))
+Base.convert(::Type{Material{T}}, material::Material{T}) where {T <: Real} = material
diff --git a/src/materials/dataframe.jl b/src/materials/dataframe.jl
deleted file mode 100644
index b3fbf645a..000000000
--- a/src/materials/dataframe.jl
+++ /dev/null
@@ -1,41 +0,0 @@
-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
-end
\ No newline at end of file
diff --git a/src/materials/material.jl b/src/materials/material.jl
new file mode 100644
index 000000000..61311be9f
--- /dev/null
+++ b/src/materials/material.jl
@@ -0,0 +1,172 @@
+"""
+Supertype for package-owned electromagnetic material values.
+"""
+abstract type AbstractMaterial end
+
+"""
+$(TYPEDEF)
+
+Store one broad material class and its electromagnetic and thermal properties.
+
+$(TYPEDFIELDS)
+"""
+struct Material{T <: Real} <: AbstractMaterial
+ "Broad physical class used by formulation dispatch."
+ kind::Symbol
+ "Electrical resistivity at reference temperature T0 \\[Ω·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 resistivity \\[K·m/W\\]."
+ rho_thermal::T
+ "Maximum continuous operating temperature \\[°C\\]."
+ theta_max::T
+ "Polarization loss tangent, excluding conduction represented by rho \\[dimensionless\\]."
+ tan_delta::T
+ "Solar-absorption coefficient \\[dimensionless\\]."
+ sigma_solar::T
+
+ @inline function Material{T}(
+ kind::Symbol,
+ rho::T,
+ eps_r::T,
+ mu_r::T,
+ T0::T,
+ alpha::T,
+ rho_thermal::T,
+ theta_max::T,
+ tan_delta::T,
+ sigma_solar::T
+ ) where {T <: Real}
+ return validate(new{T}(
+ kind, rho, eps_r, mu_r, T0, alpha, rho_thermal,
+ theta_max, tan_delta, sigma_solar
+ ))
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct a material after floating and promoting its real-valued properties.
+
+# Arguments
+- `kind`: broad physical class, such as `:conductor`, `:insulator`, or
+ `:semicon`.
+- `rho`: resistivity \\[Ω·m\\].
+- `eps_r`: relative permittivity \\[dimensionless\\].
+- `mu_r`: relative permeability \\[dimensionless\\].
+- `T0`: reference temperature \\[°C\\].
+- `alpha`: temperature coefficient of resistivity \\[1/°C\\].
+
+# Keywords
+
+- `rho_thermal=0`: thermal resistivity \\[K·m/W\\].
+- `theta_max=90`: maximum continuous operating temperature \\[°C\\].
+- `tan_delta=0`: polarization loss tangent \\[dimensionless\\], excluding the
+ conduction contribution already represented by `rho`. A measured total loss
+ tangent must be separated into those contributions before supplying both.
+ Lossless constitutive selections explicitly suppress this input and conductivity.
+- `sigma_solar=0`: solar-absorption coefficient.
+
+# Returns
+- A validated `Material` whose scalar type is the promoted floating type of
+ its inputs.
+
+# Errors
+
+- Throws `DomainError` when a property is outside its accepted physical
+ domain or is not finite, except that infinite resistivity is accepted.
+"""
+@inline function Material(
+ kind::Symbol,
+ rho,
+ eps_r,
+ mu_r,
+ T0,
+ alpha,
+ rho_thermal,
+ theta_max,
+ tan_delta,
+ sigma_solar
+)
+ values = map(float, promote(
+ rho, eps_r, mu_r, T0, alpha, rho_thermal, theta_max,
+ tan_delta, sigma_solar
+ ))
+ return Material{typeof(first(values))}(kind, values...)
+end
+
+@inline function Material(
+ kind::Symbol,
+ rho,
+ eps_r = 1,
+ mu_r = 1,
+ T0 = 20,
+ alpha = 0;
+ rho_thermal = 0,
+ theta_max = 90,
+ tan_delta = 0,
+ sigma_solar = 0
+)
+ return Material(
+ kind, rho, eps_r, mu_r, T0, alpha, rho_thermal,
+ theta_max, tan_delta, sigma_solar
+ )
+end
+
+function validate(material::Material)
+ isempty(string(material.kind)) && throw(ArgumentError(
+ "Material.kind cannot be empty; received $(repr(material.kind))"
+ ))
+ isnan(material.rho) && throw(DomainError(
+ material.rho,
+ "Material.rho must not be NaN"
+ ))
+ material.rho > zero(material.rho) ||
+ throw(DomainError(material.rho, "Material.rho must be positive"))
+ isfinite(material.eps_r) && material.eps_r >= zero(material.eps_r) ||
+ throw(DomainError(
+ material.eps_r,
+ "Material.eps_r must be nonnegative and finite"
+ ))
+ isfinite(material.mu_r) && material.mu_r > zero(material.mu_r) ||
+ throw(DomainError(
+ material.mu_r,
+ "Material.mu_r must be positive and finite"
+ ))
+ isfinite(material.T0) ||
+ throw(DomainError(material.T0, "Material.T0 must be finite"))
+ isfinite(material.alpha) ||
+ throw(DomainError(material.alpha, "Material.alpha must be finite"))
+ isfinite(material.rho_thermal) && material.rho_thermal >= zero(material.rho_thermal) ||
+ throw(DomainError(
+ material.rho_thermal,
+ "Material.rho_thermal must be nonnegative and finite"
+ ))
+ isfinite(material.theta_max) ||
+ throw(DomainError(material.theta_max, "Material.theta_max must be finite"))
+ isfinite(material.tan_delta) && material.tan_delta >= zero(material.tan_delta) ||
+ throw(DomainError(
+ material.tan_delta,
+ "Material.tan_delta must be nonnegative and finite"
+ ))
+ isfinite(material.sigma_solar) && material.sigma_solar >= zero(material.sigma_solar) ||
+ throw(DomainError(
+ material.sigma_solar,
+ "Material.sigma_solar must be nonnegative and finite"
+ ))
+ return material
+end
+
+Commons.input_fields(::Type{<:Material}) = (rho=(name="electrical resistivity",unit="Ω·m"),
+ eps_r=(name="relative permittivity",unit=""),mu_r=(name="relative permeability",unit=""),
+ T0=(name="reference temperature",unit="°C"),alpha=(name="temperature coefficient",unit="1/°C"),
+ rho_thermal=(name="thermal resistivity",unit="K·m/W"),theta_max=(name="maximum temperature",unit="°C"),
+ tan_delta=(name="loss tangent",unit=""),sigma_solar=(name="solar absorption coefficient",unit=""))
diff --git a/src/materials/materialslibrary.jl b/src/materials/materialslibrary.jl
index c165b9d49..68db059b3 100644
--- a/src/materials/materialslibrary.jl
+++ b/src/materials/materialslibrary.jl
@@ -1,147 +1,128 @@
"""
$(TYPEDEF)
-Stores a collection of predefined materials for cable modeling, indexed by material name:
+Store materials by name as an `AbstractDict{String, Material}`.
+
+Ordinary indexed assignment inserts or replaces a material. Use [`add!`](@ref)
+when an existing name must be rejected.
$(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
+ "Materials indexed by name."
+ data::Dict{String, Material}
end
"""
$(TYPEDSIGNATURES)
-Constructs an empty [`MaterialsLibrary`](@ref) instance and initializes with default materials.
+Construct a material library.
-# Arguments
+# Keywords
-- None.
+- `add_defaults`: add the package's built-in material records. Default: `true`.
# Returns
-- A [`MaterialsLibrary`](@ref) object populated with default materials.
+- A [`MaterialsLibrary`](@ref), populated with built-in records when
+ `add_defaults` is `true`.
# Examples
-```julia
-# Create a new, empty library
-library = $(FUNCTIONNAME)()
+```jldoctest
+library = $(FUNCTIONNAME)(; add_defaults=false)
+isempty(library)
+# output
+true
```
-# See also
-
-- [`Material`](@ref)
-- [`_add_default_materials!`](@ref)
"""
function MaterialsLibrary(; add_defaults::Bool = true)::MaterialsLibrary
- library = MaterialsLibrary(Dict{String, Material}())
+ library = MaterialsLibrary(Dict{String, Material}())
- if add_defaults
- @info "Initializing default materials database..."
- _add_default_materials!(library)
- end
+ if add_defaults
+ _add_default_materials!(library)
+ end
- return library
+ return library
end
"""
$(TYPEDSIGNATURES)
-Populates a [`MaterialsLibrary`](@ref) with commonly used materials, assigning predefined electrical and thermal properties.
+Add the built-in material records to `library`.
# Arguments
-- `library`: Instance of [`MaterialsLibrary`](@ref) to be populated.
+- `library`: destination material library.
# Returns
-- The modified instance of [`MaterialsLibrary`](@ref) containing the predefined materials.
-
-# Examples
-
-```julia
-library = MaterialsLibrary()
-$(FUNCTIONNAME)(library)
-```
-
-# See also
+- The modified `library`.
-- [`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),
- )
- add!(library, "lead", Material(21.4e-8, 1.0, 0.999983, 20.0, 0.00400)) # Lead or lead alloy
- add!(library, "steel", Material(13.8e-8, 1.0, 300.0, 20.0, 0.00450)) # Steel
- add!(library, "pp", Material(1e15, 2.8, 1.0, 20.0, 0.0)) # Laminated paper propylene
+ add!(library, "air", Material(:insulator, Inf, 1.0, 1.0, 20.0, 0.0))
+ add!(library, "pec", Material(:conductor, eps(), 1.0, 1.0, 20.0, 0.0))
+ add!(
+ library,
+ "copper",
+ Material(:conductor, 1.7241e-8, 1.0, 0.999994, 20.0, 0.00393)
+ )
+ add!(
+ library,
+ "aluminum",
+ Material(:conductor, 2.8264e-8, 1.0, 1.000022, 20.0, 0.00429)
+ )
+ add!(library, "xlpe", Material(:insulator, 1.97e14, 2.5, 1.0, 20.0, 0.0))
+ add!(library, "pe", Material(:insulator, 1.97e14, 2.3, 1.0, 20.0, 0.0))
+ add!(
+ library,
+ "semicon1",
+ Material(:semicon, 1000.0, 1000.0, 1.0, 20.0, 0.0)
+ )
+ add!(
+ library,
+ "semicon2",
+ Material(:semicon, 500.0, 1000.0, 1.0, 20.0, 0.0)
+ )
+ add!(
+ library,
+ "polyacrylate",
+ Material(:semicon, 5.3e3, 32.3, 1.0, 20.0, 0.0)
+ )
+ add!(library, "lead", Material(:conductor, 21.4e-8, 1.0, 0.999983, 20.0, 0.00400)) # Lead or lead alloy
+ add!(library, "steel", Material(:conductor, 13.8e-8, 1.0, 300.0, 20.0, 0.00450)) # Steel
+ add!(library, "pp", Material(:insulator, 1e15, 2.8, 1.0, 20.0, 0.0)) # Laminated paper propylene
end
-
"""
$(TYPEDSIGNATURES)
-Adds a new material to a [`MaterialsLibrary`](@ref).
+Add `material` under `name`.
# 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.
+- `library`: destination material library.
+- `name`: name of the material.
+- `material`: validated material record.
# Returns
-- The modified instance of [`MaterialsLibrary`](@ref) with the new material added.
+- The modified `library`.
# 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)
-```
+- Throws `ArgumentError` when `name` already exists.
"""
function add!(
- library::MaterialsLibrary,
- name::AbstractString,
- material::Material,
+ library::MaterialsLibrary,
+ name::Union{AbstractString, Symbol},
+ material::Material
)
- if haskey(library, name)
- Base.error("Material $name already exists in the library.")
- end
- library[String(name)] = material
- library
+ key = String(name)
+ haskey(library, key) && throw(ArgumentError("material '$key' already exists"))
+ validate(material)
+ library[key] = material
+ return library
end
-
diff --git a/src/materials/radialdielectric.jl b/src/materials/radialdielectric.jl
new file mode 100644
index 000000000..568a51df3
--- /dev/null
+++ b/src/materials/radialdielectric.jl
@@ -0,0 +1,113 @@
+"""
+$(TYPEDEF)
+
+Frequency-independent description of dielectric materials combined radially
+in series. Constituent kinds retain independent insulation and semicon
+formulation selection. No constitutive law or reference frequency is selected.
+
+For logarithmic radial weights ``q_i = \\log(r_{i+1}/r_i)``, the effective
+admittivity is ``\\kappa_{eq} = \\sum_i q_i / \\sum_i(q_i/\\kappa_i)``.
+The engine evaluates each ``\\kappa_i`` before combining it.
+
+The displayed `rho` is the DC radial resistivity and `eps_r` is the lossless
+radial permittivity, not a lossy fit. There is no frequency-independent
+equivalent loss tangent in general.
+
+$(TYPEDFIELDS)
+"""
+struct RadialDielectric{T <: Real} <: AbstractMaterial
+ "Original insulation and semicon materials."
+ materials::Vector{Material{T}}
+ "Positive logarithmic radial weights, dimensionless."
+ weights::Vector{T}
+ "Passive region classification. Constituent kinds remain authoritative."
+ kind::Symbol
+ "Series DC resistivity, in Ω·m."
+ rho::T
+ "Lossless relative permittivity, dimensionless."
+ eps_r::T
+ "Equivalent relative permeability, dimensionless."
+ mu_r::T
+ "Shared constituent reference temperature, in °C."
+ T0::T
+ function RadialDielectric(materials::Vector{Material{T}}, weights::Vector{T},
+ mu_r::T) where {T <: Real}
+ isempty(materials) && throw(ArgumentError("RadialDielectric requires at least one constituent"))
+ length(materials) == length(weights) || throw(DimensionMismatch(
+ "RadialDielectric requires one radial weight per constituent"))
+ total = sum(weights)
+ rho = sum(w * m.rho for (w, m) in zip(weights, materials)) / total
+ eps_r = total / sum(w / m.eps_r for (w, m) in zip(weights, materials))
+ return validate(new{T}(copy(materials), copy(weights), :insulator,
+ rho, eps_r, mu_r, first(materials).T0))
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Describe a radial series composition without selecting its dielectric law.
+
+# Arguments
+
+- `materials`: physical [`Material`](@ref) constituents, each classified as
+ `:insulator` or `:semicon`.
+- `weights`: positive logarithmic radius ratios, dimensionless.
+
+# Keywords
+
+- `mu_r`: equivalent relative permeability. Defaults to the radial weighted
+ mean. Homogenization may supply its helical-solenoid correction.
+
+# Returns
+
+- A [`RadialDielectric`](@ref) with promoted concrete scalar storage.
+"""
+function RadialDielectric(materials, weights;
+ mu_r = sum(w * m.mu_r for (w, m) in zip(weights, materials)) / sum(weights))
+ T = promote_type(typeof(float(mu_r)), map(eltype, materials)...,
+ map(x -> typeof(float(x)), weights)...)
+ return RadialDielectric(Material{T}[convert(Material{T}, m) for m in materials],
+ T[weights...], convert(T, mu_r))
+end
+
+function validate(material::RadialDielectric)
+ all(w -> isfinite(w) && w > zero(w), material.weights) || throw(ArgumentError(
+ "RadialDielectric.weights must be finite and positive logarithmic radius ratios"))
+ for (index, constituent) in enumerate(material.materials)
+ validate(constituent)
+ constituent.kind in (:insulator, :semicon) || throw(ArgumentError(
+ "RadialDielectric.materials[$index] must be insulation or semicon, not :$(constituent.kind)"))
+ isapprox(constituent.T0, material.T0) || throw(ArgumentError(
+ "RadialDielectric constituents must share one reference temperature"))
+ end
+ isfinite(material.mu_r) && material.mu_r > zero(material.mu_r) ||
+ throw(ArgumentError("RadialDielectric.mu_r must be finite and positive"))
+ return material
+end
+
+Base.eltype(::RadialDielectric{T}) where {T} = T
+Base.eltype(::Type{RadialDielectric{T}}) where {T} = T
+function Base.convert(::Type{RadialDielectric{T}}, material::RadialDielectric) where {T <: Real}
+ return RadialDielectric(Material{T}[convert(Material{T}, m) for m in material.materials],
+ T[material.weights...], convert(T, material.mu_r))
+end
+Base.convert(::Type{RadialDielectric{T}}, material::RadialDielectric{T}) where {T <: Real} = material
+Base.:(==)(a::RadialDielectric, b::RadialDielectric) =
+ a.materials == b.materials && a.weights == b.weights && a.mu_r == b.mu_r
+Base.isequal(a::RadialDielectric, b::RadialDielectric) =
+ isequal(a.materials, b.materials) && isequal(a.weights, b.weights) && isequal(a.mu_r, b.mu_r)
+Base.hash(material::RadialDielectric, h::UInt) =
+ hash(material.mu_r, hash(material.weights, hash(material.materials, hash(:RadialDielectric, h))))
+TextDisplay.name(::Type{<:RadialDielectric}) = "RadialDielectric"
+Base.summary(io::IO, material::RadialDielectric) =
+ print(io, "RadialDielectric · ", length(material.materials), " constituents")
+Base.show(io::IO, material::RadialDielectric) = summary(io, material)
+function Base.show(io::IO, ::MIME"text/plain", material::RadialDielectric)
+ get(io, :compact, false) && return show(io, material)
+ return TextDisplay.fields(io, "RadialDielectric", (
+ constituents = length(material.materials),
+ ρ_DC = TextDisplay.engineering(material.rho, :ohm_meter),
+ εᵣ_lossless = TextDisplay.value(material.eps_r),
+ μᵣ = TextDisplay.value(material.mu_r)); multiline = true)
+end
diff --git a/src/materials/temperaturedependent/TemperatureDependent.jl b/src/materials/temperaturedependent/TemperatureDependent.jl
new file mode 100644
index 000000000..86c76b796
--- /dev/null
+++ b/src/materials/temperaturedependent/TemperatureDependent.jl
@@ -0,0 +1,44 @@
+"""
+ LineCableModels.Materials.TemperatureDependent
+
+Evaluate electrical resistivity at a prescribed temperature from a material's
+reference calibration and a selected constitutive equation.
+
+# Dependencies
+
+$(IMPORTS)
+"""
+module TemperatureDependent
+import ...Commons: FormulationOptions, formulas
+
+export Formula, formula_id, formulas
+public temperature_resistivity
+
+#! explicit-imports: off
+using DocStringExtensions: IMPORTS, TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+#! explicit-imports: on
+using ..Materials: Material
+import ...TextDisplay
+import ...Commons: AbstractFormulation, Functor, formulation_options
+import ...LineCableModels: FormulaDefinition, Expression, constitutive,
+ formula_id, validate
+#! explicit-imports: off
+# Used by the formula files included below.
+import ...LineCableModels: description
+#! explicit-imports: on
+
+include("interface.jl")
+
+public TemperatureDependentFormulation
+
+#! explicit-imports: off
+const FORMULAS = (
+ include("formulas/linear.jl"),
+ include("formulas/default.jl"),
+)
+#! explicit-imports: on
+
+"""Return the registered electrical-resistivity temperature laws."""
+formulas(::Type{<:Formula}) = FORMULAS
+
+end # module TemperatureDependent
diff --git a/src/materials/temperaturedependent/formulas/default.jl b/src/materials/temperaturedependent/formulas/default.jl
new file mode 100644
index 000000000..821e0315c
--- /dev/null
+++ b/src/materials/temperaturedependent/formulas/default.jl
@@ -0,0 +1,12 @@
+"""
+$(TYPEDSIGNATURES)
+
+Route the default selection to the explicit `:linear` implementation.
+No numerical equation is owned by `:default`.
+"""
+description(::Type{<:Formula{:default}}; compact::Bool=false) =
+ compact ? "Default" : "Default routing to :linear"
+
+Formula{:default}(; kwargs...) = Formula{:linear}(; kwargs...)
+
+:default
diff --git a/src/materials/temperaturedependent/formulas/linear.jl b/src/materials/temperaturedependent/formulas/linear.jl
new file mode 100644
index 000000000..f986468e5
--- /dev/null
+++ b/src/materials/temperaturedependent/formulas/linear.jl
@@ -0,0 +1,31 @@
+"""
+$(TYPEDSIGNATURES)
+
+**Identification.** Linear electrical-resistivity temperature dependence.
+
+**Expression.** ``\\rho(T)=\\rho_0[1+\\alpha(T-T_0)]``, with resistivity in Ω·m,
+temperature in °C and ``\\alpha`` in K⁻¹. Each reference material supplies its
+own calibration. Infinite passive resistivity remains infinite.
+
+**Applicability.** The applicability limit is ``|T-T_0|<150`` K and a
+finite positive correction factor. This is a restriction of this approximation,
+not a thermal-rating or material operating-temperature limit.
+
+**Reference.** Package default linear resistivity approximation.
+"""
+description(::Type{<:Formula{:linear}}; compact::Bool=false) = compact ? "Linear" : "Linear electrical-resistivity temperature dependence"
+
+function temperature_resistivity(::Formula{:linear}, functor, workspace)
+ (; material, temperature) = functor.input
+ difference = temperature - material.T0
+ abs(difference) < oftype(difference, 150) || throw(DomainError(temperature,
+ "temperature is outside the linear resistivity model range relative to $(material.T0) °C"))
+ factor = one(difference) + material.alpha * difference
+ isfinite(factor) && factor > zero(factor) || throw(DomainError(factor,
+ "linear resistivity correction factor must be positive and finite"))
+ return isinf(material.rho) ? material.rho : material.rho * factor
+end
+
+formulation_options(::Expression{<:Formula{:linear}, typeof(temperature_resistivity)}) = FormulationOptions()
+
+:linear
diff --git a/src/materials/temperaturedependent/interface.jl b/src/materials/temperaturedependent/interface.jl
new file mode 100644
index 000000000..8d71704ab
--- /dev/null
+++ b/src/materials/temperaturedependent/interface.jl
@@ -0,0 +1,138 @@
+"""
+Interface for a selected temperature-dependent resistivity law.
+Concrete selections expose model `parameters` and numerical `options` records.
+"""
+abstract type TemperatureDependentFormulation <: AbstractFormulation end
+
+"""
+$(TYPEDEF)
+
+Select a scalar electrical-resistivity law evaluated from reference material
+properties and prescribed temperature. Reference material values are immutable.
+
+$(TYPEDFIELDS)
+"""
+struct Formula{ID, P <: NamedTuple, O <: FormulationOptions} <:
+ TemperatureDependentFormulation
+ "Resolved physical model parameters."
+ parameters::P
+ "Normalized numerical sections for this equation."
+ options::O
+end
+
+TextDisplay.@showfields Formula "Formula" selected -> (
+ id = formula_id(selected),)
+
+"""Evaluate a temperature-dependent electrical resistivity in ohm meters."""
+function temperature_resistivity end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct a temperature-dependent resistivity law with model parameters and
+numerical controls. Custom formulations extend
+`temperature_resistivity(selected, functor, workspace)` on their own concrete selection type.
+The input of `functor` holds the reference material, the temperature and the options.
+"""
+function Formula{ID}(; parameters::NamedTuple = (;),
+ options::Union{NamedTuple, FormulationOptions} = FormulationOptions()) where {ID}
+ options = options isa NamedTuple ? FormulationOptions(options) : options
+ isempty(parameters) ||
+ throw(ArgumentError("formula :$ID has no configurable model parameters"))
+ selected = Formula{ID, typeof(parameters), typeof(options)}(parameters, options)
+ expression = Expression(selected, temperature_resistivity)
+ normalized = formulation_options(expression, options)
+ return Formula{ID, typeof(parameters), typeof(normalized)}(parameters, normalized)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Validate evaluated resistivity \\[Ω·m\\] for a reference material and prescribed
+temperature \\[°C\\]. Resistivity must be real and positive. Conductors require a
+finite value. Passive materials admit infinite resistivity. Return `rho`.
+"""
+function validate(rho, ::Union{Nothing, TemperatureDependentFormulation}, material::Material, temperature::Real)
+ isfinite(temperature) || throw(DomainError(temperature,
+ "constitutive temperature must be finite"))
+ rho isa Real && !isnan(rho) && rho > zero(rho) || throw(DomainError(rho,
+ "temperature law must return positive real resistivity for $(material.kind) at $temperature °C"))
+ material.kind === :conductor && !isfinite(rho) &&
+ throw(DomainError(rho,
+ "conductor resistivity must be finite at $temperature °C"))
+ return rho
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build the Functor of a temperature-dependent resistivity law for one reference material at
+one temperature, after checking the temperature. The state is empty.
+"""
+function Functor(formula::TemperatureDependentFormulation, input::NamedTuple;
+ workspace = nothing)
+ (; temperature) = input
+ isfinite(temperature) || throw(DomainError(temperature,
+ "constitutive temperature must be finite"))
+ return Functor(formula, input, (;))
+end
+
+@inline function (formula::TemperatureDependentFormulation)(
+ material::Material{T}, temperature::T;
+ workspace = nothing) where {T <: Real}
+ functor = Functor(formula, (; material, temperature, options = formula.options);
+ workspace)
+ rho = Expression(formula, temperature_resistivity)(functor, workspace)
+ return validate(rho, formula, material, temperature)
+end
+
+function (formula::TemperatureDependentFormulation)(
+ material::Material{T}, temperature::Real;
+ workspace = nothing) where {T <: Real}
+ U = promote_type(T, typeof(float(temperature)))
+ return formula(convert(Material{U}, material), convert(U, temperature); workspace)
+end
+
+"""Evaluate a selected resistivity law at prescribed temperature in °C. Return Ω·m."""
+function constitutive(
+ formula::TemperatureDependentFormulation, material::Material, temperature::Real;
+ workspace = nothing)
+ formula(material, temperature; workspace)
+end
+
+"""Retain reference resistivity in Ω·m when no temperature law is selected."""
+function constitutive(::Nothing, material::Material, temperature::Real; workspace = nothing)
+ validate(material.rho, nothing, material, temperature)
+end
+
+Formula(identifier::Symbol; kwargs...) = Formula(Val(identifier); kwargs...)
+Formula(::Val{ID}; kwargs...) where {ID} = Formula{ID}(; kwargs...)
+Formula(selected::TemperatureDependentFormulation) = selected
+
+function Formula(selection::FormulaDefinition{ID, Order}) where {ID, Order}
+ Order === :default || throw(ArgumentError("order applies only to equivalent_earth"))
+ selection.equivalent_earth === nothing || throw(ArgumentError(
+ "equivalent_earth applies only to external earth formulas"))
+ return Formula{ID}(; parameters = selection.parameters,
+ options = selection.options)
+end
+
+"""Return the stable identifier of a temperature-dependent resistivity law."""
+formula_id(::Formula{ID}) where {ID} = ID
+formula_id(::Type{<:Formula{ID}}) where {ID} = ID
+# Identity-only dispatch also describes retained selections without constructors.
+description(value::Formula; compact::Bool = false) = description(typeof(value); compact)
+formulation_options(value::Formula) = value.options
+
+"""
+$(TYPEDSIGNATURES)
+
+Expose the selected identity, model parameters, and numerical options as a native record.
+"""
+function Base.NamedTuple(value::Formula)
+ return (identifier = formula_id(value),
+ parameters = value.parameters, options = value.options.data)
+end
+
+"""Iterate the independently selectable child slots admitted by this formula family."""
+Base.pairs(::Type{<:Formula}; quantity = nothing) = pairs((;))
diff --git a/src/materials/typecoercion.jl b/src/materials/typecoercion.jl
deleted file mode 100644
index 9cd1b075a..000000000
--- a/src/materials/typecoercion.jl
+++ /dev/null
@@ -1,11 +0,0 @@
-# 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),
-)
\ No newline at end of file
diff --git a/src/parametricbuilder/ParametricBuilder.jl b/src/parametricbuilder/ParametricBuilder.jl
index a9a87fd8e..5031514a1 100644
--- a/src/parametricbuilder/ParametricBuilder.jl
+++ b/src/parametricbuilder/ParametricBuilder.jl
@@ -1,92 +1,100 @@
+"""
+ LineCableModels.ParametricBuilder
+
+Construct finite parameter spaces and materialize cable problems from explicit
+`Grid` inputs.
+
+# Overview
+
+- Define deterministic and uncertainty-bearing finite sources.
+- Compose sources with product or zip semantics.
+- Materialize materials, cable parts, cable designs, positions, earth models,
+ and line-parameter problems.
+- Evaluate every materialized problem with `Combinatorial`.
+- Transport completed result spaces into target-bearing downstream problem
+ spaces.
+- Estimate stranded-conductor and wire-screen patterns.
+"""
module ParametricBuilder
+import ..LineCableModels
+
+export Grid, AbsoluteError, DeterministicGrid, RelativeGrid, AbsoluteGrid
+export AbstractGrid, AbstractUncertainGrid, UncertainValue
+export Gridspace
+export has_uncertainty, nominal, uncertainty
+export @gridspace
+export build
+
+export Combinatorial, ParametricProblem, ParametricResult
+
+export Material
+export CableDesign, LineCableSystem
+export Disk, Rectangle, Ellipse, Sector, Annulus, Polygon, Shell, Pose2
+export Region, Stack
+export Group, Assembly
+export Enclosure
+export terminal, core, stranded, milliken, rope, cores, tape, insulation, screen, sheath
+export armor, bedding, jacket, filler, pipe, duct
+export solid, shell, wires, layers, assembly
+export capacity
+export FillFactor
+export at, trefoil, hflat, vflat, layer, homogeneous, EarthLayer, EarthModel
+export @cable, @system, @earth, @terminal, @assembly, @pipe, @duct
+export @at, @hflat, @vflat, @trefoil
+export @distribute
+export WireEstimate, estimate_stranding, estimate_screen
+
+using DocStringExtensions: SIGNATURES, TYPEDSIGNATURES, TYPEDEF, TYPEDFIELDS
+import Random
+import ..LineCableModels: add!, build, Gridpoint, validate
+import ..LineCableModels: Grid, AbsoluteError, DeterministicGrid, RelativeGrid
+import ..LineCableModels: AbsoluteGrid, AbstractGrid, AbstractUncertainGrid
+import ..LineCableModels: UncertainValue, Gridspace, has_uncertainty
+import ..LineCableModels: parameterize, materialize, points
+import ..LineCableModels: verbosity, VerbosityLogger
+import Logging
+import ..Commons
+import ..Commons: compute, computation_options, computation_details, details,
+ nominal, uncertainty
+using ..Commons:
+ AbstractProblemDefinition, AbstractFormulation, AbstractResultSpace,
+ AbstractParametricResult,
+ ComputationOptions, ComputationDetails
+import ..Materials
+import ..Materials: Material
+import ..DataModel
+import ..DataModel: Disk, Rectangle, Ellipse, Sector, Annulus, Polygon
+import ..DataModel: Shell, Pose2
+import ..DataModel: Region, Stack
+import ..DataModel: Group, Assembly
+import ..DataModel: Enclosure
+import ..DataModel: capacity, FillFactor
+import ..DataModel: CableDesign, LineCableSystem
+import ..Earth
+using ..Earth: EarthLayer, EarthModel, layer, homogeneous
+import ..Engine
+import ..Engine: compare
+import ..TextDisplay
+
+include("macros.jl")
+include("results.jl")
+
+include("material.jl")
+include("geometry.jl")
+include("physicaltree.jl")
+include("positions.jl")
+include("construction_macros.jl")
+include("system.jl")
+
+include("engine/cableconstants.jl")
+include("traversal.jl")
+include("comparisons.jl")
-# Export public API
-export make_stranded, make_screened
-export Conductor, Insulator, Material, CableBuilder
-export at, trifoil, Earth, SystemBuilder
-export determinize
-
-# Module-specific dependencies
-using ..Commons
-import ..Commons: add!
-using ..Materials: Materials
-using ..DataModel: DataModel, trifoil_formation, CableDesign, get_outer_radius
-using ..EarthProps: EarthModel
-using ..Engine: LineParametersProblem
-using ..Utils: to_nominal
-using Measurements
-using Base.Iterators: product
-
-
-
-# normalize input to (spec, pct)
-function _spec(x)
- if x isa Tuple && length(x) == 2
- spec, pct = x
- _validate_valuespec(spec)
- _validate_valuespec(pct)
- return (spec, pct)
- else
- _validate_valuespec(x)
- return (x, nothing)
- end
-end
-
-# THIS is the minimal validator
-@inline function _validate_valuespec(x)
- if x isa Tuple && length(x) == 3 && x[1] isa Number && x[2] isa Number &&
- x[3] isa Integer
- lo, hi, n = x
- n >= 2 || error("Range (lo,hi,n) must have n ≥ 2, got $x")
- end
- return nothing
-end
-
-
-_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(t[1], t[2]; length = t[3])
-
-
-function _make_range(spec; pct = nothing)
+include("wirepatterns/WirePatterns.jl")
+using .WirePatterns: WireEstimate, estimate_stranding, estimate_screen
- if spec isa Tuple && length(spec)==3
- lo, hi, n = spec
- n < 2 && Base.error("Invalid (lo,hi,n) values range: n=$n must be ≥2")
- end
+include("textdisplay.jl")
- 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
+public traverse
-# 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("determinize.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
deleted file mode 100644
index 0a0bfcdf0..000000000
--- a/src/parametricbuilder/base.jl
+++ /dev/null
@@ -1,318 +0,0 @@
-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(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) && Base.error("component '$cname' has no conductors")
- isempty(insu) && Base.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) && Base.error("component '$cname' has no conductors")
- isempty(insu) && Base.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)
-
-_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(s::SystemBuilderSpec)
- # designs from CableBuilderSpec (uses existing cardinality(cbs::CableBuilderSpec))
- n_builder = cardinality(s.builder)
-
- # length / temperature / earth choices via existing expanders
- n_len = length(collect(_expand_pair(s.length)))
- n_temp = length(collect(_expand_pair(s.temperature)))
- n_earth = length(collect(_expand_earth(s.earth)))
-
- # positions: product over singles and groups
- n_pos = isempty(s.positions) ? 1 : prod(_position_choice_count(p) for p in s.positions)
-
- return n_builder * n_len * n_temp * n_earth * n_pos
-end
-
-Base.length(spec::SystemBuilderSpec) = cardinality(spec)
-
-# == Iterator over fully formed LineParametersProblem (skips overlaps silently) ==
-function Base.iterate(spec::SystemBuilderSpec)
- ch = iterate(spec)
- try
- x = take!(ch);
- return (x, ch)
- catch e
- @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))
-# axis around anchor (handles (nothing, pct) → uncertain anchor)
-_vals_axis(anchor, dspec) = collect(_axis(anchor, dspec))
-
-# 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
-
-# helper for printing a single arbitrary position (old behaviour)
-function _show_position(io::IO, i::Int, p::PositionSpec)
- 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
-
-# helper for printing a grouped formation
-function _show_position(io::IO, i::Int, g::PositionGroupSpec)
- x0, y0 = g.anchor
- dvals = _vals_pair(g.d)
-
- # phases: show one map per leg, reusing _fmt_map
- phase_str = "[" * join((_fmt_map(c) for c in g.conn), "; ") * "]"
-
- println(
- io,
- " • p", i,
- " group(", g.arrangement, ", n=", g.n, ")",
- " anchor=(", x0, ", ", y0, ")",
- ", d: ", _fmt_vals(dvals),
- ", phases: ", phase_str,
- )
-end
-
-function Base.show(io::IO, ::MIME"text/plain", spec::SystemBuilderSpec)
- println(io, "SystemBuilder(\"", spec.system_id, "\")")
- println(io, " designs × = ", cardinality(spec.builder))
-
- # positions block
- println(io, " positions = ", length(spec.positions))
- for (i, p) in enumerate(spec.positions)
- _show_position(io, i, p) # dispatches on PositionSpec vs PositionGroupSpec
- 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(getproperty(spec, :frequencies))
- f = getproperty(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
deleted file mode 100644
index b09bd61ff..000000000
--- a/src/parametricbuilder/cablebuilderspec.jl
+++ /dev/null
@@ -1,523 +0,0 @@
-# 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 # CircStrands, 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
- elseif isnothing(x)
- @warn "Ignoring `nothing` in parts collection."
- else
- Base.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 = getproperty(g, :layers)
- @assert !isempty(L) "ConductorGroup has no layers to anchor on."
- return L[end]
-end
-@inline function _anchor(g::DataModel.InsulatorGroup)
- L = getproperty(g, :layers)
- @assert !isempty(L) "InsulatorGroup has no layers to anchor on."
- return L[end]
-end
-
-# ----- proxy for r_ex by CONTRACT -----
-@inline function _resolve_dim(T::Type, is_abs_first::Bool)
- return T <: DataModel.AbstractStrandsLayer ? :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 (CircStrands ignores this)
-
- if T <: DataModel.AbstractStrandsLayer
- @assert length(args_pos) ≥ 1 "CircStrands needs (n, [lay])."
- n = args_pos[1]
- lay = length(args_pos) ≥ 2 ? args_pos[2] : 0.0
- return DataModel.ConductorGroup(
- DataModel.CircStrands(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.AbstractStrandsLayer
- @assert length(args_pos) ≥ 1 "CircStrands needs (n, [lay])."
- n = args_pos[1]
- lay = length(args_pos) ≥ 2 ? args_pos[2] : 0.0
- add!(cg, DataModel.CircStrands, 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 = 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(cbs) -> Channel{DataModel.CableDesign}
-
-Lazy stream of `CableDesign`s built from `CableBuilderSpec` without allocating all of them.
-Works with `for d in iterate(cbs)`.
-"""
-function iterate(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, m) =
- PartSpec(component, DataModel.CircStrands, layers;
- dim = _spec(d), args = (n, _spec(lay)), material = m)
-
-# tube: no extra args
-Tubular(component::Symbol; layers::Int, t, m) =
- PartSpec(component, DataModel.Tubular, layers;
- dim = _spec(t), args = (), material = m)
-
-# strip: args are (width, lay)
-Strip(component::Symbol; layers::Int, t, w, lay = 0.0, m) =
- PartSpec(component, DataModel.Strip, layers;
- dim = _spec(t), args = (_spec(w), _spec(lay)), material = m)
-
-# solid: inherits inner radius = 0.0, builds from diameter
-Solid(component::Symbol; d, m) =
- PartSpec(component, DataModel.Tubular, 1;
- dim = _spec(d), args = (), material = m)
-
-# central + hex rings sugar
-function Stranded(component::Symbol; layers::Int, d, n::Int, lay = 11.0, m)
- @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.CircStrands, 1;
- dim = dspec, args = (1, (0.0, nothing)), material = m),
- )
-
- # 2) rings: (layers-1) layers, base n, common lay
- if layers > 1
- push!(
- specs,
- PartSpec(component, DataModel.CircStrands, layers - 1;
- dim = dspec, args = (n, _spec(lay)), material = m),
- )
- end
-
- return specs
-end
-
-end
-
-module Insulator
-
-using ..ParametricBuilder: PartSpec, _spec
-using ...DataModel: DataModel
-
-Tubular(component::Symbol; layers::Int, t, m) =
- PartSpec(component, DataModel.Insulator, layers;
- dim = _spec(t), args = (), material = m)
-
-Semicon(component::Symbol; layers::Int, t, m) =
- PartSpec(component, DataModel.Semicon, layers;
- dim = _spec(t), args = (), material = m)
-end
diff --git a/src/parametricbuilder/comparisons.jl b/src/parametricbuilder/comparisons.jl
new file mode 100644
index 000000000..e060ed833
--- /dev/null
+++ b/src/parametricbuilder/comparisons.jl
@@ -0,0 +1,35 @@
+"""
+$(TYPEDSIGNATURES)
+
+Compare one reference with every result while retaining the problem and
+formulation axes. The returned ParametricResult contains per-term RMSError
+values in the same order as the input results. No formulation is evaluated.
+"""
+function compare(reference::Commons.AbstractCoreResult, result::ParametricResult,
+ quantity::Union{Function,Tuple}; kwargs...)
+ isempty(result.axes) && throw(ArgumentError("comparison requires retained problem/formulation axes"))
+ errors=[compare(reference, value, quantity::Union{Function,Tuple}; kwargs...) for value in result]
+ return ParametricResult(result.formulation, errors, result.axes, ComputationDetails())
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compare two retained result spaces with an explicit reference index for every
+result point. `pairing` is a vector of `(reference_index, result_index)`
+pairs. Each result must occur exactly once. Use the declared pairs even when the lengths are equal.
+"""
+function compare(reference::ParametricResult, result::ParametricResult,
+ quantity::Union{Function,Tuple}; pairing=nothing, kwargs...)
+ pairing === nothing && throw(ArgumentError("two result spaces require explicit pairing"))
+ length(pairing) == length(result) &&
+ sort(last.(pairing)) == collect(eachindex(result.values)) ||
+ throw(ArgumentError("pairing must select every result exactly once"))
+ all(pair -> pair isa Tuple{Integer,Integer} &&
+ !(first(pair) isa Bool) && !(last(pair) isa Bool) &&
+ 1 <= first(pair) <= length(reference), pairing) ||
+ throw(ArgumentError("pairing contains an invalid reference/result index"))
+ ordered=sort(collect(pairing); by=last)
+ errors=[compare(reference[i], result[j], quantity::Union{Function,Tuple}; kwargs...) for (i,j) in ordered]
+ return ParametricResult(result.formulation, errors, result.axes, ComputationDetails())
+end
diff --git a/src/parametricbuilder/construction_macros.jl b/src/parametricbuilder/construction_macros.jl
new file mode 100644
index 000000000..22349663a
--- /dev/null
+++ b/src/parametricbuilder/construction_macros.jl
@@ -0,0 +1,485 @@
+function _macro_block(value, name::Symbol)
+ value isa Expr && value.head === :block || throw(ArgumentError(
+ "@$name requires a begin/end block"
+ ))
+ parts = Any[]
+ for item in value.args
+ item isa LineNumberNode && continue
+ if item isa Expr && item.head === :macrocall &&
+ first(item.args) in (GlobalRef(Core, Symbol("@doc")), Symbol("@doc"))
+ item = last(item.args)
+ end
+ item isa AbstractString || item === nothing || item === :nothing ||
+ push!(parts, item)
+ end
+ return parts
+end
+
+_macro_call(name::Symbol, args...) = Expr(
+ :call, GlobalRef(@__MODULE__, name), args...
+)
+
+function _macro_keywords(values, name::Symbol)
+ keywords = Pair{Symbol, Any}[]
+ for value in values
+ value isa Expr && value.head === :(=) && value.args[1] isa Symbol ||
+ throw(ArgumentError("@$name accepts keyword assignments"))
+ push!(keywords, value.args[1] => value.args[2])
+ end
+ allunique(first.(keywords)) || throw(ArgumentError(
+ "@$name does not accept duplicate keyword assignments"
+ ))
+ return keywords
+end
+
+function _call_with_keywords(name::Symbol, arguments, keywords)
+ parameters = Expr(:parameters, [Expr(:kw, key, value) for (key, value) in keywords]...)
+ return Expr(:call, GlobalRef(@__MODULE__, name), parameters, arguments...)
+end
+
+"""
+ @cable identifier [nominal_data=value] [combine=:product] begin
+ parts...
+ end
+
+Build a completed cable from physical declarations ordered from the center
+outward.
+
+# Arguments
+
+- `identifier`: stable cable identifier.
+- `parts`: ordered physical cable declarations.
+
+# Keywords
+
+- `nominal_data=nothing`: descriptive catalog data stored with the design.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A completed `CableDesign`, or a `Gridspace{CableDesign}` when a direct input
+ varies.
+"""
+macro cable(identifier, arguments...)
+ isempty(arguments) && throw(ArgumentError(
+ "@cable requires a begin/end block"
+ ))
+ block = last(arguments)
+ parts = _macro_block(block, :cable)
+ isempty(parts) && throw(ArgumentError("@cable requires at least one part"))
+ keywords = _macro_keywords(arguments[1:(end - 1)], :cable)
+ allowed = (:nominal_data, :combine)
+ unsupported = filter(pair -> !(first(pair) in allowed), keywords)
+ isempty(unsupported) || throw(ArgumentError(
+ "@cable accepts only nominal_data and combine"
+ ))
+ call = if isempty(keywords)
+ _macro_call(
+ :build,
+ GlobalRef(DataModel, :CableDesign),
+ identifier,
+ parts...
+ )
+ else
+ _call_with_keywords(
+ :build,
+ (GlobalRef(DataModel, :CableDesign), identifier, parts...),
+ keywords
+ )
+ end
+ return esc(call)
+end
+
+"""
+ @system identifier [keyword=value ...] begin
+ placements...
+ end
+
+Build a completed line-cable system from placed cable declarations.
+
+# Arguments
+
+- `identifier`: stable system identifier.
+- `placements`: ordered expressions that produce placed cable declarations.
+
+# Keywords
+
+- `environment=nothing`: optional physical environment declaration.
+- `line_length=1`: physical line length \\[m\\].
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A completed `LineCableSystem`, or a `Gridspace{LineCableSystem}` when a
+ placement or keyword value varies.
+"""
+macro system(identifier, arguments...)
+ isempty(arguments) && throw(ArgumentError(
+ "@system requires a begin/end block"
+ ))
+ block = last(arguments)
+ placements = _macro_block(block, :system)
+ isempty(placements) && throw(ArgumentError(
+ "@system requires at least one placed cable"
+ ))
+ keywords = _macro_keywords(arguments[1:(end - 1)], :system)
+ allowed = (:environment, :line_length, :combine)
+ unsupported = filter(pair -> !(first(pair) in allowed), keywords)
+ isempty(unsupported) || throw(ArgumentError(
+ "@system accepts only environment, line_length, and combine"
+ ))
+ pushfirst!(keywords, :system_id => identifier)
+ declarations = Expr(:tuple, placements...)
+ return esc(_call_with_keywords(
+ :build,
+ (GlobalRef(DataModel, :LineCableSystem), declarations),
+ keywords
+ ))
+end
+
+"""
+ @earth [keyword=value ...] begin
+ layers...
+ end
+
+Build a completed earth model from ordered `layer(...)` declarations. For
+horizontal interfaces, the block runs from the earth surface downward.
+Semi-infinite air is implicit.
+
+# Arguments
+
+- `layers`: ordered expressions that produce completed earth layers.
+
+# Keywords
+
+- `vertical_layers=false`: whether earth interfaces are vertical.
+- `air_layer=nothing`: optional explicit semi-infinite air layer.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- An `EarthModel`, or a `Gridspace{EarthModel}` when a layer or keyword value
+ varies.
+"""
+macro earth(arguments...)
+ isempty(arguments) && throw(ArgumentError(
+ "@earth requires a begin/end block"
+ ))
+ block = last(arguments)
+ layers = _macro_block(block, :earth)
+ isempty(layers) && throw(ArgumentError(
+ "@earth requires at least one earth layer"
+ ))
+ keywords = _macro_keywords(arguments[1:(end - 1)], :earth)
+ allowed = (:vertical_layers, :air_layer, :combine)
+ unsupported = filter(pair -> !(first(pair) in allowed), keywords)
+ isempty(unsupported) || throw(ArgumentError(
+ "@earth accepts only vertical_layers, air_layer, and combine"
+ ))
+ declarations = Expr(:tuple, layers...)
+ return esc(_call_with_keywords(
+ :build,
+ (GlobalRef(Earth, :EarthModel), declarations),
+ keywords
+ ))
+end
+
+"""
+ @terminal name [combine=:product] begin
+ parts...
+ end
+
+Coalesce the conductive descendants in one ordered block as a terminal.
+
+# Arguments
+
+- `name`: retained electrical terminal name.
+- `parts`: ordered physical cable declarations.
+
+# Keywords
+
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A terminal-owning `Group`, or a `Gridspace{Group}` when a direct input
+ varies.
+"""
+macro terminal(name, arguments...)
+ isempty(arguments) && throw(ArgumentError(
+ "@terminal requires a begin/end block"
+ ))
+ block = last(arguments)
+ parts = _macro_block(block, :terminal)
+ isempty(parts) && throw(ArgumentError("@terminal requires at least one part"))
+ keywords = _macro_keywords(arguments[1:(end - 1)], :terminal)
+ all(pair -> first(pair) === :combine, keywords) || throw(ArgumentError(
+ "@terminal accepts only combine"
+ ))
+ call = if isempty(keywords)
+ _macro_call(:terminal, name, parts...)
+ else
+ _call_with_keywords(:terminal, (name, parts...), keywords)
+ end
+ return esc(call)
+end
+
+"""
+ @assembly [combine=:product] begin
+ members...
+ end
+
+Assemble independent physical members while preserving their terminal
+identities.
+
+# Arguments
+
+- `members`: independent physical declarations, optionally placed with `@at`.
+
+# Keywords
+
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- An `Assembly`, or a `Gridspace{Assembly}` when a direct input varies.
+"""
+macro assembly(arguments...)
+ isempty(arguments) && throw(ArgumentError(
+ "@assembly requires a begin/end block"
+ ))
+ block = last(arguments)
+ members = _macro_block(block, :assembly)
+ isempty(members) && throw(ArgumentError("@assembly requires at least one member"))
+ keywords = _macro_keywords(arguments[1:(end - 1)], :assembly)
+ all(pair -> first(pair) === :combine, keywords) || throw(ArgumentError(
+ "@assembly accepts only combine"
+ ))
+ call = if isempty(keywords)
+ _macro_call(:assembly, members...)
+ else
+ _call_with_keywords(:assembly, members, keywords)
+ end
+ return esc(call)
+end
+
+function _enclosure_macro(name::Symbol, arguments)
+ isempty(arguments) && throw(ArgumentError(
+ "@$name requires keywords and a begin/end block"
+ ))
+ block = last(arguments)
+ members = _macro_block(block, name)
+ isempty(members) && throw(ArgumentError("@$name requires enclosed content"))
+ keywords = _macro_keywords(arguments[1:(end - 1)], name)
+ keys = first.(keywords)
+ :shape in keys || throw(ArgumentError("@$name requires shape"))
+ :fill in keys || throw(ArgumentError("@$name requires fill"))
+ allowed = name === :pipe ? (:shape, :fill, :wall, :at, :combine) :
+ (:shape, :fill, :wall, :formation, :at, :combine)
+ unsupported = filter(pair -> !(first(pair) in allowed), keywords)
+ isempty(unsupported) || throw(ArgumentError(
+ "@$name accepts only $(join(allowed, ", "))"
+ ))
+ return _call_with_keywords(name, members, keywords)
+end
+
+"""
+ @pipe shape=value fill=value [keyword=value ...] begin
+ members...
+ end
+
+Contain one or more physical members inside a pipe cross-section.
+
+# Arguments
+
+- `members`: enclosed physical declarations.
+
+# Keywords
+
+- `shape`: intrinsic containing primitive.
+- `fill`: filling material or explicit filling region.
+- `wall=nothing`: optional outward wall declaration.
+- `at=nothing`: pipe pose relative to its parent frame.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- An `Enclosure`, or a `Gridspace{Enclosure}` when a direct input varies.
+"""
+macro pipe(arguments...)
+ return esc(_enclosure_macro(:pipe, arguments))
+end
+
+"""
+ @duct shape=value fill=value [keyword=value ...] begin
+ members...
+ end
+
+Contain one or more physical members inside a duct cross-section.
+
+# Arguments
+
+- `members`: enclosed physical declarations.
+
+# Keywords
+
+- `shape`: intrinsic containing primitive.
+- `fill`: filling material or explicit filling region.
+- `wall=nothing`: optional outward wall declaration.
+- `formation=nothing`: placement pattern for one repeated prototype.
+- `at=nothing`: duct pose relative to its parent frame.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- An `Enclosure`, or a `Gridspace{Enclosure}` when a direct input varies.
+"""
+macro duct(arguments...)
+ return esc(_enclosure_macro(:duct, arguments))
+end
+
+function _coordinate_tuple(value, name::Symbol)
+ value isa Expr && value.head === :tuple && length(value.args) in (2, 3) ||
+ throw(ArgumentError("@$name requires a two- or three-coordinate tuple"))
+ return copy(value.args)
+end
+
+"""
+Place a physical subject using coordinate-tuple notation.
+"""
+macro at(subject, coordinates, assignments...)
+ values = _coordinate_tuple(coordinates, :at)
+ keywords = _macro_keywords(assignments, :at)
+ reserved = (:φ, :connections, :combine)
+ explicit_connections = findall(pair -> first(pair) === :connections, keywords)
+ shorthand = filter(pair -> !(first(pair) in reserved), keywords)
+ isempty(explicit_connections) || isempty(shorthand) ||
+ throw(ArgumentError(
+ "@at cannot mix connections with connection shorthand"
+ ))
+ if isempty(explicit_connections) && !isempty(shorthand)
+ named = Expr(:tuple, Expr(
+ :parameters,
+ [Expr(:kw, key, value) for (key, value) in shorthand]...
+ ))
+ keywords = filter(pair -> first(pair) in reserved, keywords)
+ push!(keywords, :connections => named)
+ end
+ keys = first.(keywords)
+ tuple_has_angle = length(values) == 3
+ tuple_has_angle && :φ in keys &&
+ throw(ArgumentError(
+ "@at cannot receive φ both in the coordinate tuple and as a keyword"
+ ))
+ angle = tuple_has_angle ? pop!(values) : nothing
+ angle === nothing || pushfirst!(keywords, :φ => angle)
+ return esc(_call_with_keywords(:at, (subject, values...), keywords))
+end
+
+function _formation_macro(name::Symbol, design, assignments)
+ keywords = _macro_keywords(assignments, name)
+ reserved = name === :trefoil ?
+ (:spacing, :center, :φ0, :connections, :combine) :
+ (:spacing, :center, :connections, :combine)
+ explicit_connections = findall(pair -> first(pair) === :connections, keywords)
+ length(explicit_connections) <= 1 || throw(ArgumentError(
+ "@$name accepts one connections assignment"
+ ))
+ shorthand = filter(pair -> !(first(pair) in reserved), keywords)
+ isempty(explicit_connections) || isempty(shorthand) ||
+ throw(ArgumentError(
+ "@$name cannot mix connections with connection shorthand"
+ ))
+ if isempty(explicit_connections) && !isempty(shorthand)
+ named = Expr(:tuple, Expr(
+ :parameters,
+ [Expr(:kw, key, value) for (key, value) in shorthand]...
+ ))
+ keywords = filter(pair -> first(pair) in reserved, keywords)
+ push!(keywords, :connections => named)
+ end
+ for index in eachindex(keywords)
+ key, value = keywords[index]
+ if key === :center && value isa Expr && value.head === :tuple
+ coordinates = _coordinate_tuple(value, name)
+ center = length(coordinates) == 2 ?
+ _macro_call(:at, coordinates...) :
+ _call_with_keywords(
+ :at,
+ coordinates[1:2],
+ [:φ => coordinates[3]]
+ )
+ keywords[index] = key => center
+ end
+ end
+ return _call_with_keywords(name, (design,), keywords)
+end
+
+"""
+ @trefoil design spacing=value [keyword=value ...]
+
+Place three copies of `design` in a trefoil formation. `center`, `φ0`,
+`connections`, and `combine` are forwarded to [`trefoil`](@ref). Every other
+keyword is terminal-connection shorthand.
+"""
+macro trefoil(design, assignments...)
+ return esc(_formation_macro(:trefoil, design, assignments))
+end
+
+"""
+ @hflat design spacing=value [keyword=value ...]
+
+Place three copies of `design` in a horizontal flat formation. `center`,
+`connections`, and `combine` are forwarded to [`hflat`](@ref). Every other
+keyword is terminal-connection shorthand.
+"""
+macro hflat(design, assignments...)
+ return esc(_formation_macro(:hflat, design, assignments))
+end
+
+"""
+ @vflat design spacing=value [keyword=value ...]
+
+Place three copies of `design` in a vertical flat formation. `center`,
+`connections`, and `combine` are forwarded to [`vflat`](@ref). Every other
+keyword is terminal-connection shorthand.
+"""
+macro vflat(design, assignments...)
+ return esc(_formation_macro(:vflat, design, assignments))
+end
+
+"""
+Insert the deferred `n = capacity()` count into a repeated-member call.
+"""
+macro distribute(call)
+ call isa Expr && call.head === :call || throw(ArgumentError(
+ "@distribute requires a constructor call"
+ ))
+ rewritten = copy(call)
+ rewritten.args = copy(call.args)
+ callee = first(rewritten.args)
+ callee isa Symbol && callee in (:wires, :rope, :tape) || throw(
+ ArgumentError("@distribute supports wires, rope, and tape")
+ )
+ parameters = findfirst(
+ argument -> argument isa Expr && argument.head === :parameters,
+ rewritten.args
+ )
+ if parameters === nothing
+ insert!(rewritten.args, 2, Expr(:parameters, Expr(
+ :kw, :n, _macro_call(:capacity)
+ )))
+ else
+ original_parameters = rewritten.args[parameters]
+ rewritten.args[parameters] = copy(original_parameters)
+ kwargs = copy(original_parameters.args)
+ rewritten.args[parameters].args = kwargs
+ any(
+ keyword -> keyword isa Expr && keyword.head === :kw &&
+ keyword.args[1] === :n, kwargs) && throw(ArgumentError(
+ "@distribute cannot overwrite an explicit n"
+ ))
+ push!(kwargs, Expr(:kw, :n, _macro_call(:capacity)))
+ end
+ return esc(rewritten)
+end
diff --git a/src/parametricbuilder/determinize.jl b/src/parametricbuilder/determinize.jl
deleted file mode 100644
index da2b55c83..000000000
--- a/src/parametricbuilder/determinize.jl
+++ /dev/null
@@ -1,150 +0,0 @@
-# ─────────────────────────────────────────────────────────────────────────────
-# Deterministic collapse (Monte Carlo on deterministic ranges only)
-# Policy: transform (valuespec, pctspec) → (merged_valuespec, nothing)
-# ─────────────────────────────────────────────────────────────────────────────
-
-# Percent helpers
-@inline _pct(u) = float(u) / 100
-@inline _expand_nom(nom::Number, u::Number) = (nom*(1 - _pct(u)), nom*(1 + _pct(u)))
-@inline _expand_bounds(lo::Number, hi::Number, u1::Number, u2::Number) =
- (lo*(1 - _pct(u1)), hi*(1 + _pct(u2)))
-
-# Deterministic collapse with pct interpreted as percent (not absolute)
-@inline function _det_pair(spec, pct)
- pct === nothing && return (spec, nothing)
-
- # helper: largest percent magnitude in the tuple
- _umax(u1, u2) = max(abs(float(u1)), abs(float(u2)))
-
- # A) spec = (lo,hi,N1), pct = (u1,u2,N2)
- if (spec isa Tuple && length(spec)==3 && all(x->x isa Number, spec)) &&
- (pct isa Tuple && length(pct) == 3 && all(x->x isa Number, pct))
- lo, hi, N1 = float(spec[1]), float(spec[2]), Int(spec[3])
- u1, u2, N2 = float(pct[1]), float(pct[2]), Int(pct[3])
- u = _umax(u1, u2)
- lo_det = lo * (1 - _pct(u))
- hi_det = hi * (1 + _pct(u))
- return ((lo_det, hi_det, N1 * N2), nothing)
- end
-
- # B) spec = (lo,hi,N1), pct = u
- if (spec isa Tuple && length(spec)==3 && all(x->x isa Number, spec)) && (pct isa Number)
- lo, hi, N1 = float(spec[1]), float(spec[2]), Int(spec[3])
- u = abs(float(pct))
- lo_det = lo * (1 - _pct(u))
- hi_det = hi * (1 + _pct(u))
- return ((lo_det, hi_det, N1), nothing)
- end
-
- # C) spec = nom, pct = (u1,u2,N2)
- if (spec isa Number) && (pct isa Tuple && length(pct)==3 && all(x->x isa Number, pct))
- nom = float(spec)
- u1, u2, N2 = float(pct[1]), float(pct[2]), Int(pct[3])
- u = _umax(u1, u2)
- lo_det = nom * (1 - _pct(u))
- hi_det = nom * (1 + _pct(u))
- return ((lo_det, hi_det, max(N2, 2)), nothing)
- end
-
- # D) spec = nom, pct = u
- if (spec isa Number) && (pct isa Number)
- nom = float(spec);
- u = abs(float(pct))
- lo_det = nom * (1 - _pct(u))
- hi_det = nom * (1 + _pct(u))
- return ((lo_det, hi_det, 2), nothing)
- end
-
- # E) fallback
- return (spec, nothing)
-end
-
-# Normalizer: accept a field already in (spec,pct) or as a scalar → return (spec’, nothing)
-@inline _det_field(x) = (x isa Tuple && length(x)==2) ? _det_pair(x[1], x[2]) : (x, nothing)
-
-# ---- MaterialSpec ----
-function determinize(ms::MaterialSpec)
- MaterialSpec(
- rho = _det_field(ms.rho),
- eps_r = _det_field(ms.eps_r),
- mu_r = _det_field(ms.mu_r),
- T0 = _det_field(ms.T0),
- alpha = _det_field(ms.alpha),
- )
-end
-
-# ---- PartSpec (dim, args, material) ----
-function determinize(ps::PartSpec)
- dim_det = _det_field(ps.dim)
- # each arg can be scalar or (spec,pct)
- args_det = map(a -> (a isa Tuple && length(a)==2) ? _det_field(a) : a, ps.args) |> Tuple
- mat_det = determinize(ps.material)
- return PartSpec(
- ps.component,
- ps.part_type,
- ps.n_layers;
- dim = dim_det,
- args = args_det,
- material = mat_det,
- )
-end
-
-# ---- CableBuilderSpec (vector/nested parts) ----
-function determinize(cbs::CableBuilderSpec)
- parts_det = PartSpec[determinize(p) for p in cbs.parts]
- return CableBuilderSpec(cbs.cable_id, parts_det, cbs.nominal)
-end
-
-# ─────────────────────────────────────────────────────────────────────────────
-# Deterministic collapse for SystemBuilderSpec (non-materializing)
-# ─────────────────────────────────────────────────────────────────────────────
-
-@inline _det_axis(a) = (a isa Tuple && length(a)==2) ? _det_pair(a[1], a[2]) : a
-# determinize EarthSpec
-function determinize(e::EarthSpec)
- EarthSpec(
- rho = _det_field(e.rho),
- eps_r = _det_field(e.eps_r),
- mu_r = _det_field(e.mu_r),
- t = _det_field(e.t),
- )
-end
-
-# determinize PositionSpec (keep anchors; just collapse dx/dy specs)
-function determinize(p::PositionSpec)
- dx_det = _det_axis(p.dx)
- dy_det = _det_axis(p.dy)
- return PositionSpec(
- p.x0,
- p.y0,
- dx_det,
- dy_det,
- p.conn,
- )
-end
-
-# determinize PositionGroupSpec: collapse (valuespec,pctspec) for spacing,
-# keep the rest as-is; still materialized lazily later.
-function determinize(p::PositionGroupSpec)
- dspec_det = _det_field(p.d)
- return PositionGroupSpec(
- p.arrangement,
- p.n,
- p.anchor,
- dspec_det,
- p.conn,
- )
-end
-
-# determinize SystemBuilderSpec
-function determinize(s::SystemBuilderSpec)
- SystemBuilderSpec(
- s.system_id,
- determinize(s.builder),
- [determinize(p) for p in s.positions];
- length = _det_field(s.length),
- temperature = _det_field(s.temperature),
- earth = determinize(s.earth),
- f = s.frequencies,
- )
-end
\ No newline at end of file
diff --git a/src/parametricbuilder/engine/cableconstants.jl b/src/parametricbuilder/engine/cableconstants.jl
new file mode 100644
index 000000000..f0b525365
--- /dev/null
+++ b/src/parametricbuilder/engine/cableconstants.jl
@@ -0,0 +1,43 @@
+"""
+$(TYPEDSIGNATURES)
+
+Map a finite space of completed cable designs to cable constants.
+
+Each deterministic configuration or stochastic realization calls the scalar
+`CableConstants(design; kwargs...)` call exactly once.
+"""
+function Engine.CableConstants(
+ designs::Gridspace{<:DataModel.CableDesign};
+ kwargs...
+)
+ caller = design -> Engine.CableConstants(design; kwargs...)
+ return Gridspace{Engine.CableConstants}(caller, (designs,))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Map a finite space of completed cable designs to earth-free cable-constant
+problems.
+"""
+function Engine.CableConstantsProblem(
+ designs::Gridspace{<:DataModel.CableDesign};
+ temperature = 20,
+ frequency = 50,
+ combine::Symbol = :product
+)
+ sources = (
+ designs,
+ temperature isa Union{AbstractGrid, Gridspace} ? temperature : Grid((temperature,)),
+ frequency isa Union{AbstractGrid, Gridspace} ? frequency : Grid((frequency,))
+ )
+ return Gridspace{Engine.CableConstantsProblem}(
+ _cable_constants_problem,
+ sources;
+ combine
+ )
+end
+
+function _cable_constants_problem(design, temperature, frequency)
+ return Engine.CableConstantsProblem(design; temperature, frequency)
+end
diff --git a/src/parametricbuilder/geometry.jl b/src/parametricbuilder/geometry.jl
new file mode 100644
index 000000000..2136e1af3
--- /dev/null
+++ b/src/parametricbuilder/geometry.jl
@@ -0,0 +1,104 @@
+"""
+Construct one physical pose, or a finite space of poses.
+"""
+function Pose2(;
+ x = 0,
+ y = 0,
+ φ = 0,
+ combine::Symbol = :product
+)
+ return parameterize(DataModel.Pose2, DataModel.Pose2, (x, y, φ); combine)
+end
+
+"""
+Declare a circular primitive, or a finite space of circular primitives.
+"""
+function Disk(r; combine::Symbol = :product)
+ return parameterize(
+ DataModel.Disk,
+ DataModel.Disk,
+ (r,);
+ combine
+ )
+end
+
+"""
+Declare a rectangular primitive, or a finite space of rectangular primitives.
+"""
+function Rectangle(w, h; combine::Symbol = :product)
+ return parameterize(
+ DataModel.Rectangle,
+ DataModel.Rectangle,
+ (w, h);
+ combine
+ )
+end
+
+"""
+Declare an elliptical primitive, or a finite space of elliptical primitives.
+"""
+function Ellipse(a, b; combine::Symbol = :product)
+ return parameterize(
+ DataModel.Ellipse,
+ DataModel.Ellipse,
+ (a, b);
+ combine
+ )
+end
+
+"""
+Declare a filleted cable-sector primitive, or a finite space of sectors.
+"""
+function Sector(;
+ span,
+ r_base,
+ r_back,
+ fillet = 0,
+ combine::Symbol = :product
+)
+ return parameterize(
+ DataModel.Sector,
+ DataModel.Sector,
+ (span, r_base, r_back, fillet);
+ combine
+ )
+end
+
+"""
+Declare an annular primitive, or a finite space of annular primitives.
+"""
+function Annulus(ri, ro; combine::Symbol = :product)
+ return parameterize(
+ DataModel.Annulus,
+ DataModel.Annulus,
+ (ri, ro);
+ combine
+ )
+end
+
+"""
+Declare a contextual shell, or a finite space of contextual shells.
+"""
+function Shell(t; combine::Symbol = :product)
+ return parameterize(
+ DataModel.Shell,
+ DataModel.Shell,
+ (t,);
+ combine
+ )
+end
+
+"""
+Declare a polygon primitive, or a finite space of polygon primitives.
+"""
+function Polygon(
+ points::Union{AbstractGrid, Gridspace};
+ combine::Symbol = :product
+)
+ return parameterize(
+ DataModel.Polygon,
+ DataModel.Polygon,
+ (points,);
+ combine
+ )
+end
diff --git a/src/parametricbuilder/groupspec.jl b/src/parametricbuilder/groupspec.jl
deleted file mode 100644
index 0b72d62db..000000000
--- a/src/parametricbuilder/groupspec.jl
+++ /dev/null
@@ -1,214 +0,0 @@
-# ─────────────────────────────────────────────────────────────────────────────
-# Grouped formations: PositionGroupSpec
-#
-# These are *lazy* group specs. They do NOT carry concrete coordinates; they
-# carry:
-# - an arrangement symbol (:trifoil, :hflat, :vflat, …)
-# - the number of legs n
-# - an anchor (x0,y0)
-# - a spacing spec (values,pct) via the same grammar as everything else
-# - per-leg connection maps (Dict{String,Int})
-#
-# They are materialized to concrete (x,y,conn) tuples *after* the CableDesign is
-# known, so we can enforce a min spacing of 2 * outer_radius.
-# ─────────────────────────────────────────────────────────────────────────────
-struct PositionGroupSpec <: AbstractPositionSpec
- arrangement::Symbol # :trifoil, :hflat, :vflat, …
- n::Int # number of cables in the group
- anchor::Tuple{Float64, Float64} # (x0,y0)
- d::Tuple{Any, Any} # (valuespec, pctspec)
- conn::Vector{Dict{String, Int}} # per-leg connection maps
-end
-
-# ─────────────────────────────────────────────────────────────────────────────
-# Public sugar constructors
-# ─────────────────────────────────────────────────────────────────────────────
-
-"""
- trifoil(; x0 = 0.0, y0, d, phases)
-
-Lazily describes a 3-cable trifoil formation. The anchor `(x0,y0)` is passed to
-`trifoil_formation(x0,y0,d)` when the group is materialized.
-
-The spacing `d` follows the usual `(valuespec, pctspec)` grammar; it will be
-expanded lazily and clamped at runtime to avoid overlaps.
-"""
-function trifoil(; x0::Real = 0.0, y0::Real, d, phases)
- conn = make_phase_maps(phases, 3)
- return PositionGroupSpec(
- :trifoil,
- 3,
- (float(x0), float(y0)),
- _spec(d),
- conn,
- )
-end
-
-"""
- hflat(; x0 = 0.0, y0 = 0.0, d, n = 3, phases)
-
-Horizontal flat formation: first cable at `(x0, y0)`, remaining `n-1` cables at
-`(x0 + k*d, y0)` for `k = 1, …, n-1`.
-
-`d` accepts the `(valuespec, pctspec)` grammar.
-"""
-function hflat(; x0::Real = 0.0, y0::Real = 0.0, d, n::Integer = 3, phases)
- n < 1 && error("hflat requires n ≥ 1")
- conn = make_phase_maps(phases, n)
- return PositionGroupSpec(
- :hflat,
- n,
- (float(x0), float(y0)),
- _spec(d),
- conn,
- )
-end
-
-"""
- vflat(; x0 = 0.0, y0 = 0.0, d, n = 3, phases)
-
-Vertical flat formation: first cable at `(x0, y0)`, remaining `n-1` cables at
-`(x0, y0 - k*d)` for `k = 1, …, n-1`.
-
-`d` accepts the `(valuespec, pctspec)` grammar.
-"""
-function vflat(; x0::Real = 0.0, y0::Real = 0.0, d, n::Integer = 3, phases)
- n < 1 && error("vflat requires n ≥ 1")
- conn = make_phase_maps(phases, n)
- return PositionGroupSpec(
- :vflat,
- n,
- (float(x0), float(y0)),
- _spec(d),
- conn,
- )
-end
-
-# ─────────────────────────────────────────────────────────────────────────────
-# Group materialization (PositionGroupSpec)
-# ─────────────────────────────────────────────────────────────────────────────
-
-# Expand spacing spec and clamp out overlapping choices, based on radius
-function _get_valid_spacings(g::PositionGroupSpec, rout)
- min_spacing = to_nominal(rout) + eps() # tiny epsilon to avoid overlap issues
-
- # Full grid of values × pct → Measurement or plain Real
- raw = collect(_make_range(g.d[1]; pct = g.d[2]))
-
- # Nothing at all? auto-min with same pct grammar.
- if isempty(raw)
- return collect(_make_range(min_spacing; pct = g.d[2]))
- end
-
- valid = Any[]
- discarded = 0
-
- # Filter by geometry, but KEEP the original object (Measurement or Real)
- for s in raw
- ds = to_nominal(s)
- if ds >= min_spacing
- push!(valid, s) # don't strip uncertainty
- else
- discarded += 1
- end
- end
-
- # CASE 1: all invalid → pure AUTO: min_spacing with all pcts
- if isempty(valid)
- @debug "Spacing spec produced only overlapping layouts; clamping to minimum with % uncertainty grid." min_spacing=min_spacing
- return collect(_make_range(min_spacing; pct = g.d[2]))
- end
-
- # CASE 2: some valid, some discarded → inject ONE batch at min_spacing,
- # but only for spacing+uncertainty combos that are not already present.
- if discarded > 0
- @debug "Dropped $discarded spacing samples below minimum center-to-center distance; including one batch at the minimum feasible spacing." min_spacing=min_spacing
-
- autos_all = collect(_make_range(min_spacing; pct = g.d[2]))
-
- # Use a Set to avoid injecting exact duplicates (same Measurement).
- valid_set = Set(valid)
- autos = Any[]
- for a in autos_all
- if !(a in valid_set)
- push!(autos, a)
- end
- end
-
- # Prepend autos so min_spacing layouts come first, but WITHOUT
- # multiplying cardinality by cloning identical points.
- valid = vcat(autos, valid)
- end
-
- return valid
-end
-
-
-"""
- _materialize(g::PositionGroupSpec, des::CableDesign)
-
-Lazily expands a grouped formation into concrete `(x, y, conn)` blocks after the
-external radius is known (`des` is the fully materialized design).
-
-Returns a generator of `Vector{Tuple{Float64,Float64,Dict{String,Int}}}`, one
-vector per valid spacing choice.
-"""
-function _materialize(g::PositionGroupSpec, des::CableDesign)
- r = get_outer_radius(des)
- spacings = _get_valid_spacings(g, r)
-
- # Build one concrete layout (vector of (x,y,conn)) for a given spacing d
- function _make_layout(g::PositionGroupSpec, d::Real)
- x0, y0 = g.anchor
-
- coords =
- g.arrangement == :trifoil ?
- begin
- g.n == 3 || error("trifoil formation expects n = 3, got $(g.n)")
- x0p, y0p, dp = promote(x0, y0, d)
- xa, ya, xb, yb, xc, yc = DataModel.trifoil_formation(x0p, y0p, dp)
- [(xa, ya), (xb, yb), (xc, yc)]
- end :
- g.arrangement == :hflat ? begin
- [(x0 + d * (i - 1), y0) for i in 1:g.n]
- end :
- g.arrangement == :vflat ? begin
- [(x0, y0 - d * (i - 1)) for i in 1:g.n]
- end :
- error("Unknown position group arrangement $(g.arrangement)")
-
- return [(x, y, g.conn[i]) for (i, (x, y)) in enumerate(coords)]
- end
-
- return (_make_layout(g, d) for d in spacings)
-end
-
-
-"""
- _expand_position(position_defs, des)
-
-Top-level helper that yields flattened `Vector{(x,y,conn)}` for every allowed
-combination of positions/groups.
-"""
-function _expand_position(position_defs::Vector{AbstractPositionSpec}, des::CableDesign)
- spaces = Vector{Any}(undef, length(position_defs))
-
- for (i, p) in pairs(position_defs)
- if p isa PositionGroupSpec
- # group: generator of Vector{(x,y,conn)}
- spaces[i] = _materialize(p, des)
- elseif p isa PositionSpec
- # single: wrap each (x,y,conn) into a 1-element vector so the
- # outer logic can always `vcat` vectors.
- spaces[i] = (
- [(x, y, p.conn)]
- for x in _axis(p.x0, p.dx),
- y in _axis(p.y0, p.dy)
- )
- else
- error("Unsupported position spec type: $(typeof(p))")
- end
- end
-
- return (reduce(vcat, combo) for combo in product(spaces...))
-end
diff --git a/src/parametricbuilder/macros.jl b/src/parametricbuilder/macros.jl
new file mode 100644
index 000000000..362a4643e
--- /dev/null
+++ b/src/parametricbuilder/macros.jl
@@ -0,0 +1,149 @@
+# These AST helpers are core composition machinery for `@gridspace`. They
+# operate on exactly one struct and reject ambiguous inputs.
+function _strip_escapes(expression)
+ if expression isa Expr && expression.head === :escape
+ return _strip_escapes(expression.args[1])
+ elseif expression isa Expr
+ return Expr(expression.head, map(_strip_escapes, expression.args)...)
+ end
+ return expression
+end
+
+function _struct_nodes(expression, nodes = Expr[])
+ expression isa Expr || return nodes
+ expression.head === :struct && push!(nodes, expression)
+ for argument in expression.args
+ _struct_nodes(argument, nodes)
+ end
+ return nodes
+end
+
+function _get_struct_node(expression)
+ nodes = _struct_nodes(expression)
+ length(nodes) == 1 || throw(ArgumentError(
+ "macro input must contain exactly one struct definition; found $(length(nodes))",
+ ))
+ return only(nodes)
+end
+
+function _replace_struct(expression, old_struct, new_struct)
+ expression === old_struct && return new_struct
+ expression isa Expr || return expression
+ return Expr(
+ expression.head,
+ map(
+ argument -> _replace_struct(argument, old_struct, new_struct),
+ expression.args
+ )...
+ )
+end
+
+function _parse_fields(struct_body)
+ fields = NamedTuple[]
+ clean_body = Any[]
+ for argument in struct_body.args
+ if argument isa LineNumberNode || argument isa String
+ push!(clean_body, argument)
+ continue
+ end
+ has_default = argument isa Expr && argument.head === :(=)
+ field_expression = has_default ? argument.args[1] : argument
+ default = has_default ? argument.args[2] : nothing
+ if field_expression isa Symbol
+ name = field_expression
+ declared_type = nothing
+ elseif field_expression isa Expr && field_expression.head === :(::)
+ name = field_expression.args[1]
+ declared_type = field_expression.args[2]
+ else
+ push!(clean_body, argument)
+ continue
+ end
+ push!(clean_body, field_expression)
+ push!(fields, (; name, declared_type, has_default, default))
+ end
+ isempty(fields) &&
+ throw(ArgumentError("macro struct must declare at least one field"))
+ return Tuple(fields), clean_body
+end
+
+function _extract_struct_name(struct_node)
+ signature = struct_node.args[2]
+ signature isa Expr && signature.head === :(<:) &&
+ (signature = signature.args[1])
+ signature isa Symbol && return signature
+ signature isa Expr && signature.head === :curly &&
+ return signature.args[1]
+ throw(ArgumentError("malformed struct signature"))
+end
+
+function _rebuild_ast(expression, old_struct, new_struct, generated)
+ replaced = _replace_struct(expression, old_struct, new_struct)
+ return Expr(:block, replaced, generated...)
+end
+
+"""
+$(SIGNATURES)
+
+Retain the strict positional struct and add a keyword constructor that lifts
+only explicit Grid or Gridspace fields into a `Gridspace`. With `Target`, the
+generated space materializes that target instead of the declared struct itself.
+"""
+macro gridspace(arguments...)
+ length(arguments) in (1, 2) ||
+ throw(ArgumentError("@gridspace accepts a struct and optional target"))
+ target_expression,
+ expression = length(arguments) == 1 ? (nothing, arguments[1]) :
+ arguments
+ raw = _strip_escapes(expression)
+ struct_node = _get_struct_node(raw)
+ struct_name = _extract_struct_name(struct_node)
+ fields, clean_body = _parse_fields(struct_node.args[3])
+ target = target_expression === nothing ? struct_name : target_expression
+
+ clean_struct = Expr(
+ :struct,
+ struct_node.args[1],
+ struct_node.args[2],
+ Expr(:block, clean_body...)
+ )
+ keywords = Any[]
+ for field in fields
+ push!(
+ keywords,
+ field.has_default ?
+ Expr(:kw, field.name, field.default) : field.name
+ )
+ end
+ push!(keywords, Expr(:kw, :combine, QuoteNode(:product)))
+ signature = Expr(:call, struct_name, Expr(:parameters, keywords...))
+ values = Expr(:tuple, map(field -> field.name, fields)...)
+ constructor = Expr(:function,
+ signature,
+ quote
+ combine in (:product, :zip) ||
+ throw(ArgumentError("combine must be :product or :zip"))
+ values = $values
+ if any(
+ value -> value isa Union{
+ $(GlobalRef(@__MODULE__, :AbstractGrid)),
+ $(GlobalRef(@__MODULE__, :Gridspace))
+ },
+ values
+ )
+ grids = map(values) do value
+ value isa Union{
+ $(GlobalRef(@__MODULE__, :AbstractGrid)),
+ $(GlobalRef(@__MODULE__, :Gridspace))
+ } ? value : $(GlobalRef(@__MODULE__, :Grid))((value,))
+ end
+ return $(GlobalRef(@__MODULE__, :Gridspace)){$target}(
+ $target,
+ grids;
+ combine
+ )
+ end
+ return $target(values...)
+ end)
+ return esc(_rebuild_ast(raw, struct_node, clean_struct, (constructor,)))
+end
diff --git a/src/parametricbuilder/material.jl b/src/parametricbuilder/material.jl
new file mode 100644
index 000000000..47f0b2aae
--- /dev/null
+++ b/src/parametricbuilder/material.jl
@@ -0,0 +1,111 @@
+"""
+$(TYPEDSIGNATURES)
+
+Construct electromagnetic and thermal material properties. Scalar property
+inputs return a [`Material`](@ref) directly. An explicit [`Grid`](@ref) or
+nested [`Gridspace`](@ref) lifts the construction to a finite space.
+
+# Keywords
+
+- `rho`: electrical resistivity \\[Ω·m\\].
+- `kind`: broad physical class. Deterministic symbol grids are accepted.
+- `eps_r=1`: relative permittivity \\[dimensionless\\].
+- `mu_r=1`: relative permeability \\[dimensionless\\].
+- `T0=20`: reference temperature \\[°C\\].
+- `alpha=0`: temperature coefficient of resistivity \\[1/°C\\].
+- `rho_thermal=0`: thermal resistivity \\[K·m/W\\].
+- `theta_max=90`: maximum continuous operating temperature \\[°C\\].
+- `tan_delta=0`: dielectric loss tangent.
+- `sigma_solar=0`: solar-absorption coefficient.
+- `combine=:product`: local composition rule when an input varies.
+
+# Returns
+
+- A [`Material`](@ref) for scalar inputs, or a `Gridspace{Material}` when at
+ least one direct input is a `Grid` or nested `Gridspace`.
+"""
+function Material(;
+ kind,
+ rho,
+ eps_r = 1,
+ mu_r = 1,
+ T0 = 20,
+ alpha = 0,
+ rho_thermal = 0,
+ theta_max = 90,
+ tan_delta = 0,
+ sigma_solar = 0,
+ combine::Symbol = :product
+)
+ values = (
+ kind, rho, eps_r, mu_r, T0, alpha, rho_thermal,
+ theta_max, tan_delta, sigma_solar
+ )
+ return parameterize(
+ Materials.Material, Materials.Material, values; combine
+ )
+end
+
+function Material(
+ material::Materials.Material;
+ kind = material.kind,
+ rho = material.rho,
+ eps_r = material.eps_r,
+ mu_r = material.mu_r,
+ T0 = material.T0,
+ alpha = material.alpha,
+ rho_thermal = material.rho_thermal,
+ theta_max = material.theta_max,
+ tan_delta = material.tan_delta,
+ sigma_solar = material.sigma_solar,
+ combine::Symbol = :product
+)
+ return Material(;
+ kind, rho, eps_r, mu_r, T0, alpha, rho_thermal,
+ theta_max, tan_delta, sigma_solar, combine
+ )
+end
+
+function Material(
+ library::Materials.MaterialsLibrary,
+ name::Union{AbstractString, Symbol};
+ kwargs...
+)
+ material = get(library, String(name), nothing)
+ material === nothing && throw(KeyError(String(name)))
+ return Material(material; kwargs...)
+end
+
+function Material(unexpected; kwargs...)
+ throw(ArgumentError(
+ "keyword Material construction accepts no positional arguments; " *
+ "got $(repr(unexpected)). To declare uncertainty, keep the error " *
+ "inside Grid: `property = Grid(values, relative_error)` or " *
+ "`property = Grid(values, AbsoluteError(error))`.",
+ ))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Add the one deterministic material represented by `space` to a material
+library.
+
+# Errors
+
+- Throws `ArgumentError` when `space` contains uncertainty or describes more
+ than one material.
+"""
+function add!(
+ library::Materials.MaterialsLibrary,
+ name::Union{AbstractString, Symbol},
+ space::Gridspace{Materials.Material}
+)
+ has_uncertainty(space) && throw(ArgumentError(
+ "a reusable material-library entry must be deterministic",
+ ))
+ length(space) == 1 || throw(ArgumentError(
+ "a reusable material-library entry must describe exactly one material; got $(length(space)) points",
+ ))
+ return add!(library, String(name), only(space))
+end
diff --git a/src/parametricbuilder/materialspec.jl b/src/parametricbuilder/materialspec.jl
deleted file mode 100644
index f18ab2a0b..000000000
--- a/src/parametricbuilder/materialspec.jl
+++ /dev/null
@@ -1,70 +0,0 @@
-# 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)]
-end
\ No newline at end of file
diff --git a/src/parametricbuilder/physicaltree.jl b/src/parametricbuilder/physicaltree.jl
new file mode 100644
index 000000000..13f2ddaf2
--- /dev/null
+++ b/src/parametricbuilder/physicaltree.jl
@@ -0,0 +1,1359 @@
+function Region(tag, primitive, material; combine::Symbol = :product)
+ return parameterize(
+ DataModel.Region, DataModel.Region, (tag, primitive, material); combine
+ )
+end
+
+# `DataModel.Region(::Symbol, primitive, material)` is deliberately permissive
+# for scalar declaration types, so explicit finite inputs need narrower methods
+# to use the collection constructor instead of that scalar constructor.
+const _FiniteRegionInput = Union{AbstractGrid, Gridspace}
+
+function Region(tag::_FiniteRegionInput, primitive, material; combine::Symbol = :product)
+ parameterize(DataModel.Region, DataModel.Region, (tag, primitive, material); combine)
+end
+function Region(tag, primitive::_FiniteRegionInput, material; combine::Symbol = :product)
+ parameterize(DataModel.Region, DataModel.Region, (tag, primitive, material); combine)
+end
+function Region(tag, primitive, material::_FiniteRegionInput; combine::Symbol = :product)
+ parameterize(DataModel.Region, DataModel.Region, (tag, primitive, material); combine)
+end
+function Region(
+ tag::Symbol,
+ primitive::_FiniteRegionInput,
+ material;
+ combine::Symbol = :product
+)
+ parameterize(DataModel.Region, DataModel.Region, (tag, primitive, material); combine)
+end
+function Region(
+ tag::Symbol,
+ primitive,
+ material::_FiniteRegionInput;
+ combine::Symbol = :product
+)
+ parameterize(DataModel.Region, DataModel.Region, (tag, primitive, material); combine)
+end
+function Region(
+ tag::Symbol,
+ primitive::_FiniteRegionInput,
+ material::_FiniteRegionInput;
+ combine::Symbol = :product
+)
+ parameterize(DataModel.Region, DataModel.Region, (tag, primitive, material); combine)
+end
+function Region(
+ tag::_FiniteRegionInput,
+ primitive::_FiniteRegionInput,
+ material;
+ combine::Symbol = :product
+)
+ parameterize(DataModel.Region, DataModel.Region, (tag, primitive, material); combine)
+end
+function Region(
+ tag::_FiniteRegionInput,
+ primitive,
+ material::_FiniteRegionInput;
+ combine::Symbol = :product
+)
+ parameterize(DataModel.Region, DataModel.Region, (tag, primitive, material); combine)
+end
+function Region(
+ tag,
+ primitive::_FiniteRegionInput,
+ material::_FiniteRegionInput;
+ combine::Symbol = :product
+)
+ parameterize(DataModel.Region, DataModel.Region, (tag, primitive, material); combine)
+end
+function Region(
+ tag::_FiniteRegionInput,
+ primitive::_FiniteRegionInput,
+ material::_FiniteRegionInput;
+ combine::Symbol = :product
+)
+ parameterize(DataModel.Region, DataModel.Region, (tag, primitive, material); combine)
+end
+
+function Stack(items...; combine::Symbol = :product)
+ isempty(items) && throw(ArgumentError("layers require at least one part"))
+ return parameterize(DataModel.Stack, DataModel.Stack, items; combine)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compose physical parts in outward order.
+
+# Arguments
+
+- `parts`: physical declarations ordered from the center outward.
+
+# Keywords
+
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A `Stack`, or a `Gridspace{Stack}` when a direct argument varies.
+"""
+layers(parts...; combine::Symbol = :product) = Stack(parts...; combine)
+
+function Group(
+ name,
+ item;
+ at = DataModel.Pose2(0, 0, 0),
+ pattern = nothing,
+ path = nothing,
+ compact = nothing,
+ boundary = nothing,
+ combine::Symbol = :product
+)
+ values = (name, at, item, pattern, path, compact, boundary)
+ return parameterize(DataModel.Group, DataModel.Group, values; combine)
+end
+
+function Assembly(
+ item;
+ at = DataModel.Pose2(0, 0, 0),
+ pattern,
+ names = nothing,
+ path = nothing,
+ compact = nothing,
+ combine::Symbol = :product
+)
+ values = (at, item, pattern, path, compact, names)
+ return parameterize(DataModel.Assembly, DataModel.Assembly, values; combine)
+end
+
+function _explicit_assembly(members...)
+ placed = map(members) do member
+ member isa DataModel.AssemblyMember ? member :
+ member isa DataModel.AbstractCablePart ? DataModel.AssemblyMember(member) :
+ throw(ArgumentError("assembly members must be physical cable parts"))
+ end
+ return DataModel.Assembly(
+ DataModel.Pose2(0, 0, 0), Tuple(placed), nothing, nothing, nothing, nothing
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Preserve explicit physical members and their independent terminal identities.
+
+With `pattern`, retain one prototype and one placement pattern. Without a
+pattern, retain one or more heterogeneous members and their local poses.
+
+# Arguments
+
+- `members`: physical members, optionally placed with [`at`](@ref).
+
+# Keywords
+
+- `pattern=nothing`: placement pattern for one repeated prototype, or explicit members.
+- `names=nothing`: exact terminal names for repeated terminal-bearing members.
+- `path=nothing`: shared longitudinal path declaration.
+- `compact=nothing`: explicit compaction law.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- An `Assembly`, or a `Gridspace{Assembly}` when a direct argument varies.
+"""
+function assembly(
+ members...;
+ pattern = nothing,
+ names = nothing,
+ path = nothing,
+ compact = nothing,
+ combine::Symbol = :product
+)
+ isempty(members) && throw(ArgumentError("assembly requires at least one member"))
+ if pattern === nothing
+ names === nothing && path === nothing && compact === nothing ||
+ throw(ArgumentError(
+ "explicit assembly members own their names, paths and compaction; " *
+ "provide pattern to repeat one prototype"))
+ return parameterize(DataModel.Assembly, _explicit_assembly, members; combine)
+ end
+ length(members) == 1 || throw(ArgumentError(
+ "a repeated assembly requires exactly one prototype with pattern"))
+ return Assembly(
+ first(members);
+ pattern,
+ names,
+ path,
+ compact,
+ combine
+ )
+end
+
+function Enclosure(
+ tag,
+ item;
+ at = DataModel.Pose2(0, 0, 0),
+ primitive,
+ fill,
+ wall = nothing,
+ combine::Symbol = :product
+)
+ values = (tag, at, primitive, item, fill, wall)
+ return parameterize(DataModel.Enclosure, DataModel.Enclosure, values; combine)
+end
+
+_terminal_eligible(region::DataModel.Region) = region.material.kind === :conductor ? 1 : 0
+_terminal_eligible(stack::DataModel.Stack) = sum(_terminal_eligible, stack.items; init = 0)
+_terminal_eligible(group::DataModel.Group) = _terminal_eligible(group.item)
+function _terminal_eligible(
+ assembly::DataModel.Assembly{<:Any, <:DataModel.AbstractCablePart}
+)
+ _terminal_eligible(assembly.item)
+end
+function _terminal_eligible(assembly::DataModel.Assembly{<:Any, <:Tuple})
+ return sum(member -> _terminal_eligible(member.item), assembly.item; init = 0)
+end
+function _terminal_eligible(enclosure::DataModel.Enclosure)
+ count = _terminal_eligible(enclosure.item)
+ enclosure.fill isa DataModel.Region && (count += _terminal_eligible(enclosure.fill))
+ enclosure.wall === nothing || (count += _terminal_eligible(enclosure.wall))
+ return count
+end
+
+function _terminal(name, parts...)
+ root = DataModel.Stack(parts...)
+ _terminal_eligible(root) > 0 || throw(ArgumentError(
+ "terminal :$name requires a conductive descendant"
+ ))
+ return DataModel.Group(
+ name, DataModel.Pose2(0, 0, 0), root, nothing, nothing, nothing
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Coalesce every conductive descendant of an ordered physical subtree into one
+retained terminal.
+
+# Arguments
+
+- `name`: retained electrical terminal name.
+- `parts`: physical declarations ordered from the center outward.
+
+# Keywords
+
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A terminal-owning `Group`, or a `Gridspace{Group}` when a direct argument
+ varies.
+
+# Errors
+
+- Throws `ArgumentError` when the realized subtree contains no conductive
+ descendant.
+"""
+function terminal(name, parts...; combine::Symbol = :product)
+ isempty(parts) && throw(ArgumentError("terminal requires at least one part"))
+ return parameterize(DataModel.Group, _terminal, (name, parts...); combine)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Bind an intrinsic primitive definition to one material and local physical tag.
+
+# Arguments
+
+- `material`: constitutive material record.
+- `primitive`: intrinsic cross-sectional primitive definition.
+
+# Keywords
+
+- `tag=:solid`: local physical identity.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A `Region`, or a `Gridspace{Region}` when a direct argument varies.
+"""
+function solid(material, primitive; tag = :solid, combine::Symbol = :product)
+ Region(tag, primitive, material; combine)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Declare one outward material layer of thickness `t` \\[m\\].
+
+# Arguments
+
+- `material`: constitutive material record.
+
+# Keywords
+
+- `t`: normal layer thickness \\[m\\].
+- `tag=:shell`: local physical identity.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A contextual `Region`, or a `Gridspace{Region}` when a direct argument
+ varies.
+"""
+function shell(material; t, tag = :shell, combine::Symbol = :product)
+ Region(tag, Shell(t), material; combine)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Declare a conductive core region.
+
+# Arguments
+
+- `material`: material with `kind == :conductor`.
+- `primitive`: intrinsic core geometry. The keyword form constructs a disk of
+ radius `r` \\[m\\].
+
+# Keywords
+
+- `r`: disk radius \\[m\\].
+- `tag=:core`: local physical identity.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A conductive `Region`, or a `Gridspace{Region}` when a direct argument
+ varies.
+"""
+function core(material, primitive; tag = :core, combine::Symbol = :product)
+ caller = (resolved_material,
+ resolved_primitive,
+ resolved_tag) -> DataModel.Region(
+ resolved_tag,
+ resolved_primitive,
+ validate(resolved_material, DataModel.Region, :core, (:conductor,))
+ )
+ return parameterize(
+ DataModel.Region, caller, (material, primitive, tag); combine
+ )
+end
+
+function core(material; r, tag = :core, combine::Symbol = :product)
+ core(material, Disk(r); tag, combine)
+end
+
+function _role_shell(role::Symbol, allowed, material, t, tag)
+ return DataModel.Region(
+ tag,
+ DataModel.Shell(t),
+ validate(material, DataModel.Region, role, allowed)
+ )
+end
+
+function _shell_role(
+ role::Symbol, allowed::Tuple, material, t, tag;
+ combine::Symbol
+)
+ caller = (resolved_material,
+ resolved_t,
+ resolved_tag) -> _role_shell(
+ role, allowed, resolved_material, resolved_t, resolved_tag)
+ return parameterize(DataModel.Region, caller, (material, t, tag); combine)
+end
+
+"""Declare an insulating layer of thickness `t` \\[m\\]."""
+function insulation(material; t, tag = :insulation, combine::Symbol = :product)
+ _shell_role(:insulation, (:insulator,), material, t, tag; combine)
+end
+"""Declare a semiconductive or conductive screen layer of thickness `t` \\[m\\]."""
+function screen(material; t, tag = :screen, combine::Symbol = :product)
+ _shell_role(:screen, (:semicon, :conductor), material, t, tag; combine)
+end
+"""Declare a conductive sheath layer of thickness `t` \\[m\\]."""
+function sheath(material; t, tag = :sheath, combine::Symbol = :product)
+ _shell_role(:sheath, (:conductor,), material, t, tag; combine)
+end
+"""Declare a nonconducting bedding layer of thickness `t` \\[m\\]."""
+function bedding(material; t, tag = :bedding, combine::Symbol = :product)
+ _shell_role(:bedding, (:insulator,), material, t, tag; combine)
+end
+"""Declare an insulating jacket layer of thickness `t` \\[m\\]."""
+function jacket(material; t, tag = :jacket, combine::Symbol = :product)
+ _shell_role(:jacket, (:insulator,), material, t, tag; combine)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Bind a nonconducting filler material to an intrinsic primitive definition.
+
+# Arguments
+
+- `material`: material with `kind == :insulator`.
+- `primitive`: intrinsic filler geometry.
+
+# Keywords
+
+- `tag=:filler`: local physical identity.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A filler `Region`, or a `Gridspace{Region}` when a direct argument varies.
+"""
+function filler(material, primitive; tag = :filler, combine::Symbol = :product)
+ caller = (resolved_material,
+ resolved_primitive,
+ resolved_tag) -> DataModel.Region(
+ resolved_tag,
+ resolved_primitive,
+ validate(resolved_material, DataModel.Region, :filler, (:insulator,))
+ )
+ return parameterize(
+ DataModel.Region, caller, (material, primitive, tag); combine
+ )
+end
+
+_path(::Nothing, dir, φ0) = nothing
+_path(path::DataModel.Helix, dir, φ0) = path
+_path(lay, dir, φ0) = DataModel.Helix(lay; dir, φ0)
+
+function _wires(
+ material, shape, pattern, path, compact, tag
+)
+ source = DataModel.Region(
+ tag,
+ shape,
+ validate(material, DataModel.Region, :wires, (:conductor,))
+ )
+ return DataModel.Group(
+ tag, DataModel.Pose2(0, 0, 0), source, pattern, path, compact
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Declare one repeated course of conductive members.
+
+Supply `pattern` and `path` directly for general placement, or supply `n`, `r`,
+and `lay` for the practical ring-course form.
+
+# Arguments
+
+- `material`: material with `kind == :conductor`.
+
+# Keywords
+
+- `shape`: intrinsic member primitive.
+- `pattern=nothing`: explicit member placement pattern.
+- `path=nothing`: explicit longitudinal path.
+- `n=nothing`: exact ring cardinality or `capacity()`.
+- `r=nothing`: member-center ring radius \\[m\\].
+- `gap_frac=0`: fractional adjacent clearance \\[dimensionless\\].
+- `lay=nothing`: one lay law used to construct a `Helix`.
+- `dir=1`: helix handedness, `1` or `-1` \\[dimensionless\\].
+- `φ0=0`: initial angular position \\[rad\\].
+- `compact=nothing`: explicit compaction law.
+- `tag=:wire`: local member and group identity.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A repeated-member `Group`, or a `Gridspace{Group}` when a direct argument
+ varies.
+"""
+function wires(
+ material;
+ shape,
+ pattern = nothing,
+ path = nothing,
+ n = nothing,
+ r = nothing,
+ gap_frac = 0,
+ lay = nothing,
+ dir = 1,
+ φ0 = 0,
+ compact = nothing,
+ tag = :wire,
+ combine::Symbol = :product
+)
+ caller = function (
+ resolved_material, resolved_shape, resolved_pattern, resolved_path,
+ resolved_n, resolved_r, resolved_gap, resolved_lay, resolved_dir,
+ resolved_φ0, resolved_compact, resolved_tag
+ )
+ if resolved_pattern !== nothing
+ resolved_n === nothing && resolved_r === nothing || throw(ArgumentError(
+ "pattern cannot be combined with n or r"
+ ))
+ resolved_lay === nothing || throw(ArgumentError(
+ "pattern-oriented wires use path rather than lay"
+ ))
+ return _wires(
+ resolved_material,
+ resolved_shape,
+ resolved_pattern,
+ resolved_path,
+ resolved_compact,
+ resolved_tag
+ )
+ end
+ resolved_n === nothing && throw(ArgumentError(
+ "ring-course wires require n"
+ ))
+ resolved_r === nothing && throw(ArgumentError(
+ "ring-course wires require r"
+ ))
+ resolved_path === nothing || throw(ArgumentError(
+ "ring-course wires use lay rather than path"
+ ))
+ return _wires(
+ resolved_material,
+ resolved_shape,
+ DataModel.Ring(
+ resolved_n;
+ r = resolved_r,
+ φ0 = resolved_φ0,
+ gap_frac = resolved_gap
+ ),
+ _path(resolved_lay, resolved_dir, resolved_φ0),
+ resolved_compact,
+ resolved_tag
+ )
+ end
+ values = (
+ material, shape, pattern, path, n, r, gap_frac, lay, dir, φ0,
+ compact, tag
+ )
+ return parameterize(DataModel.Group, caller, values; combine)
+end
+
+function _course_schedule(value, count::Int, name::Symbol)
+ if value isa Union{Tuple, AbstractVector}
+ length(value) == count || throw(DimensionMismatch(
+ "$name requires exactly $count course values"
+ ))
+ return Tuple(value)
+ end
+ return ntuple(_ -> value, count)
+end
+
+function _count_schedule(value, count::Int)
+ if value isa Union{Tuple, AbstractVector}
+ length(value) == count || throw(DimensionMismatch(
+ "n requires exactly $count course values"
+ ))
+ return Tuple(value)
+ end
+ value isa Integer && !(value isa Bool) && value > 0 &&
+ return ntuple(index -> index * Int(value), count)
+ value === DataModel.capacity() && return ntuple(_ -> value, count)
+ throw(ArgumentError(
+ "n must be a positive base count, exact course schedule, or capacity()"
+ ))
+end
+
+function _stranding_paths(lay, dir, φ0)
+ lay === nothing && return nothing
+ if lay isa Union{Tuple, AbstractVector}
+ count = length(lay)
+ count > 0 || throw(ArgumentError("lay schedule cannot be empty"))
+ directions = _course_schedule(dir, count, :dir)
+ angles = _course_schedule(φ0, count, :φ0)
+ return ntuple(index -> _path(lay[index], directions[index], angles[index]), count)
+ end
+ dir isa Union{Tuple, AbstractVector} && throw(ArgumentError(
+ "a dir schedule requires a lay schedule of the same length"
+ ))
+ φ0 isa Union{Tuple, AbstractVector} && throw(ArgumentError(
+ "a φ0 schedule requires a lay schedule of the same length"
+ ))
+ return _path(lay, dir, φ0)
+end
+
+function _stranded(
+ material,
+ center,
+ shape,
+ lay,
+ dir,
+ φ0,
+ compact,
+ prescribed_boundary,
+ fill
+)
+ material = validate(material, DataModel.Region, :stranded, (:conductor,))
+ fill = validate(fill, DataModel.Region, :stranded_fill, (:insulator, :semicon))
+ prescribed_boundary isa Union{DataModel.Disk, DataModel.Sector} ||
+ throw(ArgumentError(
+ "stranded requires a nonhollow Disk or Sector boundary"
+ ))
+ shape isa Union{DataModel.Disk, DataModel.Rectangle} || throw(ArgumentError(
+ "stranded members must be Disk wires or Rectangle strands"
+ ))
+ if prescribed_boundary isa DataModel.Sector
+ shape isa DataModel.Disk || throw(ArgumentError(
+ "a sector stranded core requires circular Disk wires"
+ ))
+ center === nothing || throw(ArgumentError(
+ "a sector bundle infers its center strand from shape; omit center"
+ ))
+ compact === nothing || throw(ArgumentError(
+ "sector stranded cores are intrinsically compacted; omit compact"
+ ))
+ compaction = nothing
+ else
+ if shape isa DataModel.Disk
+ center === nothing && (center = shape)
+ compaction = compact === nothing ? false : compact
+ compaction isa Bool || throw(ArgumentError(
+ "circular stranded compaction is selected with compact=true or false"
+ ))
+ else
+ center isa DataModel.Disk || throw(ArgumentError(
+ "a rectangular stranded core requires center=Disk(...)"
+ ))
+ compact === nothing || throw(ArgumentError(
+ "rectangular strands are intrinsically bent; omit compact"
+ ))
+ compaction = true
+ end
+ center isa DataModel.Disk || throw(ArgumentError(
+ "a disk-bounded stranded core requires one circular center wire"
+ ))
+ end
+
+ paths = _stranding_paths(lay, dir, φ0)
+ parts = DataModel.AbstractCablePart[]
+ center === nothing || push!(parts,
+ DataModel.Group(
+ :strand,
+ DataModel.Pose2(0, 0, 0),
+ DataModel.Region(:wire, center, material),
+ nothing,
+ nothing,
+ nothing
+ ))
+ push!(parts,
+ DataModel.Group(
+ :strand,
+ DataModel.Pose2(0, 0, 0),
+ DataModel.Region(:wire, shape, material),
+ nothing,
+ paths,
+ nothing
+ ))
+ bounded = DataModel.Group(
+ :core,
+ DataModel.Pose2(0, 0, 0),
+ DataModel.Stack(parts),
+ nothing,
+ nothing,
+ compaction,
+ prescribed_boundary
+ )
+ shape isa DataModel.Rectangle && return bounded
+ return DataModel.Enclosure(
+ :stranded,
+ DataModel.Pose2(0, 0, 0),
+ prescribed_boundary,
+ bounded,
+ fill,
+ nothing
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Fill one authoritative core boundary with the maximum admissible inventory of
+equal source strands.
+
+Circular source bundles have one center strand and `6k` wire strands in course `k`.
+A sector admits the largest complete inventory satisfying
+``[1 + 3L(L+1)]\\,\\pi a^2 \\leq A_{\\mathrm{sector}}``. Its mapped sites define
+a prescribed-area power diagram. Disks clipped to those cells retain each
+source area. This geometric reconstruction preserves the declared copper
+fill and does not model mechanical forming or plasticity.
+
+Rectangular strands occupy complete, area-preserving annular courses, including
+a full annulus when a course contains one strand. Their requested geometric boundary is
+a packing limit. The resolved geometric boundary is the occupied metal disk. Subsequent
+layers start there, without an automatically generated outer filler film.
+
+# Arguments
+
+- `material`: material with `kind == :conductor`.
+
+# Keywords
+
+- `center=nothing`: circular center member for a disk-bounded core. Circular
+ strands default to one center wire equal to `shape`. Rectangular strands
+ require an explicit `Disk`. A sector bundle infers its center strand from
+ `shape` and does not admit a separate center declaration.
+- `shape`: circular wire or rectangular strand primitive.
+- `boundary`: nonhollow `Disk` or `Sector` core boundary. For rectangular
+ strands, a `Disk` packing limit rather than an imposed finished radius.
+- `fill=air`: interstitial insulating or semiconducting material, unused by
+ contiguous rectangular courses. The default
+ is lossless air (``\\rho=\\infty``, ``\\epsilon_r=\\mu_r=1``).
+- `lay=nothing`: one lay law or a schedule matching the inferred radial courses.
+- `dir=1`: helix handedness or a schedule matching `lay`.
+- `φ0=0`: helix initial angle or a schedule matching `lay` \\[rad\\].
+- `compact=nothing`: circular disk-bounded strands remain circular by default.
+ `true` requests area-preserving deformation. Sector and rectangular
+ wire stranding are intrinsically deformed and do not admit this keyword.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A bounded `Group` for rectangular strands, or a material-complete
+ `Enclosure` for circular strands. Gridded arguments return a `Gridspace`
+ of the corresponding part type, or `AbstractCablePart` for mixed shapes.
+"""
+function stranded(
+ material;
+ center = nothing,
+ shape,
+ lay = nothing,
+ dir = 1,
+ φ0 = 0,
+ compact = nothing,
+ boundary,
+ fill = Materials.Material(:insulator, Inf),
+ combine::Symbol = :product
+)
+ caller = function (
+ resolved_material, resolved_center, resolved_shape, resolved_lay,
+ resolved_dir, resolved_φ0, resolved_compact, resolved_boundary,
+ resolved_fill
+ )
+ return _stranded(
+ resolved_material,
+ resolved_center,
+ resolved_shape,
+ resolved_lay,
+ resolved_dir,
+ resolved_φ0,
+ resolved_compact,
+ resolved_boundary,
+ resolved_fill
+ )
+ end
+ values = (
+ material, center, shape, lay, dir, φ0, compact, boundary, fill
+ )
+ shape_type = shape isa AbstractGrid ? eltype(shape) : typeof(shape)
+ target = if shape_type <: DataModel.Rectangle || shape isa Gridspace{<:DataModel.Rectangle}
+ DataModel.Group
+ elseif shape_type <: DataModel.Disk || shape isa Gridspace{<:DataModel.Disk}
+ DataModel.Enclosure
+ else
+ DataModel.AbstractCablePart
+ end
+ return parameterize(target, caller, values; combine)
+end
+
+function _inner_radius(primitive::DataModel.Disk)
+ return hypot(primitive.at.x, primitive.at.y) - primitive.r
+end
+
+function _inner_radius(primitive::DataModel.Polygon)
+ cosine = cos(primitive.at.φ)
+ sine = sin(primitive.at.φ)
+ points = map(primitive.points) do point
+ return (
+ primitive.at.x + cosine * point[1] - sine * point[2],
+ primitive.at.y + sine * point[1] + cosine * point[2]
+ )
+ end
+ return minimum(eachindex(points)) do index
+ next = mod1(index + 1, length(points))
+ first_point = points[index]
+ last_point = points[next]
+ edge = (
+ last_point[1] - first_point[1],
+ last_point[2] - first_point[2]
+ )
+ length_squared = edge[1]^2 + edge[2]^2
+ fraction = iszero(length_squared) ? zero(length_squared) : clamp(
+ -(first_point[1] * edge[1] + first_point[2] * edge[2]) /
+ length_squared,
+ zero(length_squared),
+ one(length_squared)
+ )
+ hypot(
+ first_point[1] + fraction * edge[1],
+ first_point[2] + fraction * edge[2]
+ )
+ end
+end
+
+function _milliken(
+ material,
+ shape,
+ segment,
+ segments,
+ lay,
+ dir,
+ φ0,
+ fill
+)
+ material = validate(material, DataModel.Region, :milliken, (:conductor,))
+ shape isa DataModel.Disk || throw(ArgumentError(
+ "milliken segment strands must be circular Disk wires"
+ ))
+ segment isa DataModel.Sector || throw(ArgumentError(
+ "milliken segment must be one Sector boundary"
+ ))
+ segments isa Integer && !(segments isa Bool) && segments >= 2 ||
+ throw(ArgumentError("milliken segments must be an integer of at least two"))
+ pitch = 2pi / segments
+ isapprox(segment.span, pitch) || throw(DomainError(
+ segment.span,
+ "milliken segment span must equal its angular pitch $pitch"
+ ))
+ fill = validate(fill, DataModel.Region, :milliken_fill, (:insulator, :semicon))
+
+ segment_part = _stranded(
+ material,
+ nothing,
+ shape,
+ lay,
+ dir,
+ φ0,
+ nothing,
+ segment,
+ fill
+ )
+ _, _, segment_primitives = DataModel.bounded_members(segment_part.item)
+ center_radius = minimum(_inner_radius, segment_primitives)
+ center_radius > zero(center_radius) || throw(DomainError(
+ center_radius,
+ "milliken segment strands leave no positive radius for the center wire"
+ ))
+ center = DataModel.Disk(center_radius)
+ members = DataModel.AssemblyMember[]
+ center_terminal = DataModel.Group(
+ :milliken_center,
+ DataModel.Pose2(0, 0, 0),
+ DataModel.Region(:wire, center, material),
+ nothing,
+ nothing,
+ nothing
+ )
+ push!(members, DataModel.AssemblyMember(center_terminal))
+ for index in 1:Int(segments)
+ name = Symbol(:milliken_segment_, index)
+ terminal = DataModel.Group(
+ name,
+ DataModel.Pose2(0, 0, 0),
+ segment_part.item,
+ nothing,
+ nothing,
+ nothing
+ )
+ push!(members, DataModel.AssemblyMember(
+ terminal,
+ DataModel.Pose2(0, 0, (index - 1) * pitch)
+ ))
+ end
+ assembly = DataModel.Assembly(
+ DataModel.Pose2(0, 0, 0),
+ Tuple(members),
+ nothing,
+ nothing,
+ nothing,
+ nothing
+ )
+ core = DataModel.Group(
+ :core,
+ DataModel.Pose2(0, 0, 0),
+ DataModel.Stack(assembly),
+ nothing,
+ nothing,
+ nothing
+ )
+ return DataModel.Enclosure(
+ :milliken,
+ DataModel.Pose2(0, 0, 0),
+ DataModel.Disk(segment.r_back),
+ core,
+ fill,
+ nothing
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Declare one Milliken conductor as a cable-center wire surrounded by equal
+stranded sector segments. Each segment contains its own bundle-center strand
+and complete `6k` courses. Each conductive descendant resolves to one `:core`
+terminal.
+
+# Arguments
+
+- `material`: conductor material shared by the center and segment strands.
+
+# Keywords
+
+- `shape`: circular source wire repeated inside every segment.
+- `segment`: authoritative geometric boundary of one sector segment. Its span must equal
+ ``2\\pi/N`` for `segments=N`.
+- `segments=6`: number of equal sector segments \\[dimensionless\\].
+- `lay=nothing`: common strand lay law.
+- `dir=1`: helix handedness \\[dimensionless\\].
+- `φ0=0`: helix initial angle \\[rad\\].
+- `fill=air`: interstitial material around the center and every segment strand.
+- `combine=:product`: gridspace composition rule.
+
+The center-wire radius is inferred from the resolved segment packing so that
+the center wire is tangent to the innermost strand of every equal segment.
+
+# Returns
+
+- A material-complete `Enclosure`, or a `Gridspace{Enclosure}` when a direct
+ argument varies.
+"""
+function milliken(
+ material;
+ shape,
+ segment,
+ segments = 6,
+ lay = nothing,
+ dir = 1,
+ φ0 = 0,
+ fill = Materials.Material(:insulator, Inf),
+ combine::Symbol = :product
+)
+ values = (
+ material, shape, segment, segments, lay, dir, φ0, fill
+ )
+ return parameterize(DataModel.Enclosure, _milliken, values; combine)
+end
+
+function _rope(
+ item,
+ course_count,
+ counts,
+ lays,
+ directions,
+ angles,
+ compactions,
+ gaps
+)
+ item isa DataModel.AbstractCablePart || throw(ArgumentError(
+ "rope item must be a physical cable part"
+ ))
+ central = DataModel.Group(
+ :rope, DataModel.Pose2(0, 0, 0), item, nothing, nothing, nothing
+ )
+ parts = DataModel.AbstractCablePart[central]
+ for course in 1:course_count
+ push!(parts,
+ DataModel.Group(
+ :rope,
+ DataModel.Pose2(0, 0, 0),
+ item,
+ DataModel.Ring(
+ counts[course];
+ r = nothing,
+ φ0 = angles[course],
+ gap_frac = gaps[course]
+ ),
+ _path(lays[course], directions[course], angles[course]),
+ compactions[course]
+ ))
+ end
+ return DataModel.Stack(parts)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Repeat one physical item as a central child and concentric outer courses.
+
+# Arguments
+
+- `item`: physical cable part repeated by the rope.
+
+# Keywords
+
+- `layers`: number of outer courses \\[dimensionless\\].
+- `n=6`: base count, exact course schedule or deferred maximum count `capacity()`.
+- `lay=nothing`: one lay law or one law per outer course. Homogeneous schedules
+ may be declared as `LayRatio(q...)`, `Pitch(p...)` or `LayAngle(α...)`.
+- `dir=1`: one handedness or one value per outer course.
+- `φ0=0`: one initial angle or one value per outer course \\[rad\\].
+- `compact=nothing`: one compaction law or one law per outer course.
+ Homogeneous scalar schedules may be declared as `FillFactor(η...)`.
+- `gap_frac=0`: one clearance fraction or one value per outer course
+ \\[dimensionless\\].
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A nested `Stack`, or a `Gridspace{Stack}` when a direct argument varies.
+"""
+function rope(
+ item;
+ layers,
+ n = 6,
+ lay = nothing,
+ dir = 1,
+ φ0 = 0,
+ compact = nothing,
+ gap_frac = 0,
+ combine::Symbol = :product
+)
+ caller = function (
+ resolved_item, resolved_layers, resolved_n, resolved_lay,
+ resolved_dir, resolved_φ0, resolved_compact, resolved_gap
+ )
+ resolved_layers isa Integer && !(resolved_layers isa Bool) &&
+ resolved_layers >= 0 || throw(ArgumentError(
+ "layers must be a nonnegative integer"
+ ))
+ count = Int(resolved_layers)
+ return _rope(
+ resolved_item,
+ count,
+ _count_schedule(resolved_n, count),
+ _course_schedule(resolved_lay, count, :lay),
+ _course_schedule(resolved_dir, count, :dir),
+ _course_schedule(resolved_φ0, count, :φ0),
+ _course_schedule(resolved_compact, count, :compact),
+ _course_schedule(resolved_gap, count, :gap_frac)
+ )
+ end
+ values = (item, layers, n, lay, dir, φ0, compact, gap_frac)
+ return parameterize(DataModel.Stack, caller, values; combine)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Declare a repeated armor-wire course whose radius is resolved from the current
+outer boundary.
+
+# Arguments
+
+- `material`: material with `kind == :conductor`.
+
+# Keywords
+
+- `shape`: intrinsic armor-member primitive.
+- `n`: exact cardinality or deferred maximum count `capacity()`.
+- `lay=nothing`: one helical lay law.
+- `dir=1`: helix handedness, `1` or `-1` \\[dimensionless\\].
+- `φ0=0`: initial angular position \\[rad\\].
+- `compact=nothing`: explicit compaction law.
+- `gap_frac=0`: fractional adjacent clearance \\[dimensionless\\].
+- `tag=:armor`: local member and group identity.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- An armor `Group`, or a `Gridspace{Group}` when a direct argument varies.
+"""
+function armor(
+ material;
+ shape,
+ n,
+ lay = nothing,
+ dir = 1,
+ φ0 = 0,
+ compact = nothing,
+ gap_frac = 0,
+ tag = :armor,
+ combine::Symbol = :product
+)
+ caller = function (
+ resolved_material, resolved_shape, resolved_n, resolved_lay,
+ resolved_dir, resolved_φ0, resolved_compact, resolved_gap,
+ resolved_tag
+ )
+ source = DataModel.Region(
+ resolved_tag,
+ resolved_shape,
+ validate(resolved_material, DataModel.Region, :armor, (:conductor,))
+ )
+ return DataModel.Group(
+ resolved_tag,
+ DataModel.Pose2(0, 0, 0),
+ source,
+ DataModel.Ring(
+ resolved_n;
+ r = nothing,
+ φ0 = resolved_φ0,
+ gap_frac = resolved_gap
+ ),
+ _path(resolved_lay, resolved_dir, resolved_φ0),
+ resolved_compact
+ )
+ end
+ values = (material, shape, n, lay, dir, φ0, compact, gap_frac, tag)
+ return parameterize(DataModel.Group, caller, values; combine)
+end
+
+function _tape(material, section, n, lay, gap, compact, tag)
+ section isa DataModel.Rectangle || throw(ArgumentError(
+ "tape section must be a Rectangle(width, thickness)"
+ ))
+ source = DataModel.Region(tag, section, material)
+ return DataModel.Group(
+ :tapes,
+ DataModel.Pose2(0, 0, 0),
+ source,
+ DataModel.Ring(n; r = nothing, gap_frac = gap),
+ _path(lay, 1, 0),
+ compact
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Declare a repeated conductive, semiconductive, or insulating tape system.
+
+# Arguments
+
+- `material`: tape material.
+
+# Keywords
+
+- `section`: intrinsic tape cross-section.
+- `n`: exact angular cardinality or deferred maximum count `capacity()`.
+- `lay=nothing`: one helical lay law.
+- `gap_frac=0`: fractional angular clearance \\[dimensionless\\].
+- `compact=nothing`: explicit compaction law.
+- `tag=:tape`: local tape identity.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A tape `Group`, or a `Gridspace{Group}` when a direct argument varies.
+"""
+function tape(
+ material;
+ section,
+ n,
+ lay = nothing,
+ gap_frac = 0,
+ compact = nothing,
+ tag = :tape,
+ combine::Symbol = :product
+)
+ values = (material, section, n, lay, gap_frac, compact, tag)
+ return parameterize(DataModel.Group, _tape, values; combine)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Arrange independent cable parts as repeated or explicit cores.
+
+The pattern-backed form retains one prototype. The variadic form preserves
+heterogeneous members and their local poses.
+
+# Arguments
+
+- `members`: explicit core members.
+
+# Keywords
+
+- `n=nothing`: repeated cardinality. Omit for explicit members.
+- `r=nothing`: member-center ring radius \\[m\\], required when `n` is supplied.
+- `names=nothing`: exact terminal names required for repeated terminal-bearing members.
+- `φ0=0`: starting angle \\[rad\\].
+- `span=2π`: angular span \\[rad\\].
+- `path=nothing`: shared longitudinal path.
+- `compact=nothing`: explicit compaction law.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- An `Assembly`, or a `Gridspace{Assembly}` when a direct argument varies.
+
+# Notes
+
+Origin-centered repeated sectors require the sector span to equal their angular
+pitch. Their resolved sides must not overlap. An outer insulating layer may
+close the clearance to zero. Bare sectors require positive side clearance.
+"""
+function cores(
+ members...;
+ n = nothing,
+ r = nothing,
+ names = nothing,
+ φ0 = 0,
+ span = 2π,
+ path = nothing,
+ compact = nothing,
+ combine::Symbol = :product
+)
+ if n === nothing
+ r === nothing && φ0 == 0 && span == 2π || throw(ArgumentError(
+ "cores requires n when specifying a repeated radius or angular placement"))
+ return assembly(members...; names, path, compact, combine)
+ end
+ r === nothing && throw(ArgumentError("repeated cores require an explicit ring radius r"))
+ return assembly(members...;
+ pattern = DataModel.Ring(n; r, φ0, span, combine),
+ names, path, compact, combine)
+end
+
+function _enclosure_item(items::Tuple, formation)
+ if formation === nothing
+ return length(items) == 1 && only(items) isa DataModel.AbstractCablePart ?
+ only(items) : _explicit_assembly(items...)
+ end
+ length(items) == 1 || throw(ArgumentError(
+ "a pattern-backed enclosure requires one repeated prototype"
+ ))
+ only(items) isa DataModel.AbstractCablePart || throw(ArgumentError(
+ "a pattern-backed enclosure requires an unplaced physical prototype"
+ ))
+ return DataModel.Assembly(
+ DataModel.Pose2(0, 0, 0),
+ only(items),
+ formation,
+ nothing,
+ nothing,
+ nothing
+ )
+end
+
+function _enclose(tag, items, shape, fill, wall, pose, formation)
+ item = _enclosure_item(Tuple(items), formation)
+ resolved_pose = pose === nothing ? DataModel.Pose2(0, 0, 0) : pose
+ return DataModel.Enclosure(tag, resolved_pose, shape, item, fill, wall)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Contain one or more physical members inside a pipe cross-section.
+
+# Arguments
+
+- `items`: enclosed physical members. Several members form an explicit
+ assembly.
+
+# Keywords
+
+- `shape`: intrinsic containing primitive.
+- `fill`: filling material or explicit filling region.
+- `wall=nothing`: optional outward wall declaration.
+- `at=nothing`: pipe pose relative to its parent frame.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- An `Enclosure`, or a `Gridspace{Enclosure}` when a direct argument varies.
+"""
+function pipe(
+ items...;
+ shape,
+ fill,
+ wall = nothing,
+ at = nothing,
+ combine::Symbol = :product
+)
+ isempty(items) && throw(ArgumentError("pipe requires enclosed content"))
+ caller = (selected...) -> begin
+ count = length(items)
+ physical = selected[1:count]
+ _enclose(:pipe, physical, selected[(count + 1):end]..., nothing)
+ end
+ values = (items..., shape, fill, wall, at)
+ return parameterize(DataModel.Enclosure, caller, values; combine)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Contain one or more physical members inside a duct cross-section.
+
+A `formation` repeats one prototype without expanding it. Explicitly placed
+members may differ in geometry and terminal structure.
+
+# Arguments
+
+- `items`: enclosed physical members.
+
+# Keywords
+
+- `shape`: intrinsic containing primitive.
+- `fill`: filling material or explicit filling region.
+- `wall=nothing`: optional outward wall declaration.
+- `formation=nothing`: placement pattern for one repeated prototype.
+- `at=nothing`: duct pose relative to its parent frame.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- An `Enclosure`, or a `Gridspace{Enclosure}` when a direct argument varies.
+"""
+function duct(
+ items...;
+ shape,
+ fill,
+ wall = nothing,
+ formation = nothing,
+ at = nothing,
+ combine::Symbol = :product
+)
+ isempty(items) && throw(ArgumentError("duct requires enclosed content"))
+ caller = (selected...) -> begin
+ count = length(items)
+ physical = selected[1:count]
+ _enclose(:duct, physical, selected[(count + 1):end]...)
+ end
+ values = (items..., shape, fill, wall, at, formation)
+ return parameterize(DataModel.Enclosure, caller, values; combine)
+end
+
+function build(
+ ::Type{DataModel.CableDesign},
+ cable_id,
+ parts::Tuple,
+ nominal_data;
+ combine::Symbol = :product
+)
+ isempty(parts) && throw(ArgumentError("a cable design requires one physical part"))
+ caller = (selected...) -> begin
+ id = first(selected)
+ data = last(selected)
+ physical = selected[2:(end - 1)]
+ build(DataModel.CableDesign, id, Tuple(physical), data)
+ end
+ values = (cable_id, parts..., nominal_data)
+ return parameterize(DataModel.CableDesign, caller, values; combine)
+end
+
+function build(
+ ::Type{DataModel.CableDesign},
+ cable_id,
+ parts...;
+ nominal_data = nothing,
+ combine::Symbol = :product
+)
+ values = (cable_id, parts..., nominal_data)
+ any(value -> value isa Union{AbstractGrid, Gridspace}, values) || throw(MethodError(
+ build, (DataModel.CableDesign, cable_id, parts...)
+ ))
+ return build(
+ DataModel.CableDesign,
+ cable_id,
+ Tuple(parts),
+ nominal_data;
+ combine
+ )
+end
diff --git a/src/parametricbuilder/positions.jl b/src/parametricbuilder/positions.jl
new file mode 100644
index 000000000..fc8efa198
--- /dev/null
+++ b/src/parametricbuilder/positions.jl
@@ -0,0 +1,286 @@
+struct _ConnectionsAbsent end
+
+const _CONNECTIONS_ABSENT = _ConnectionsAbsent()
+
+_parameter_type(value) = typeof(value)
+_parameter_type(grid::DeterministicGrid) = eltype(grid)
+_parameter_type(::Union{RelativeGrid, AbsoluteGrid}) = Real
+_parameter_type(::Gridspace{Target}) where {Target} = Target
+
+at(; x = 0, y = 0, φ = 0, combine::Symbol = :product) = at(x, y; φ, combine)
+
+at(pose::DataModel.Pose2) = pose
+
+function _placed_design(design, pose, connections)
+ (
+ design = design,
+ pose = pose,
+ connections = connections
+ )
+end
+
+function _compose_member(outer, member::DataModel.AssemblyMember)
+ DataModel.AssemblyMember(member.item, outer * member.at)
+end
+_compose_member(outer, pose::DataModel.Pose2) = outer * pose
+function _compose_member(outer, placement::NamedTuple)
+ merge(
+ placement,
+ (pose = outer * placement.pose,)
+ )
+end
+
+function _compose_collection(placements, pose)
+ placements isa Union{Tuple, AbstractVector} || throw(ArgumentError(
+ "outer placement requires a tuple or vector"
+ ))
+ return map(member -> _compose_member(pose, member), placements)
+end
+
+_collection_target(::Tuple) = Tuple
+_collection_target(::AbstractVector) = Vector
+_collection_target(::Gridspace{Target}) where {Target} = Target <: Tuple ? Tuple : Vector
+
+function _at_pair(
+ ::Type{<:Real}, ::Type{<:Real},
+ x, y, φ, ::_ConnectionsAbsent, combine::Symbol
+)
+ return parameterize(DataModel.Pose2, DataModel.Pose2, (x, y, φ); combine)
+end
+
+function _at_pair(
+ ::Type{<:DataModel.AbstractCablePart}, ::Type{<:DataModel.Pose2},
+ part, pose, φ, ::_ConnectionsAbsent, combine::Symbol
+)
+ iszero(φ) || throw(ArgumentError(
+ "at(part, pose) does not accept a second rotation"
+ ))
+ return parameterize(
+ DataModel.AssemblyMember, DataModel.AssemblyMember, (part, pose); combine
+ )
+end
+
+function _at_pair(
+ ::Type{<:DataModel.CableDesign}, ::Type{<:DataModel.Pose2},
+ design, pose, φ, connections, combine::Symbol
+)
+ connections isa _ConnectionsAbsent && throw(ArgumentError(
+ "at(design, pose) requires connections"
+ ))
+ iszero(φ) || throw(ArgumentError(
+ "at(design, pose) does not accept a second rotation"
+ ))
+ return parameterize(
+ NamedTuple, _placed_design, (design, pose, connections); combine
+ )
+end
+
+function _at_pair(
+ ::Type{<:Union{Tuple, AbstractVector}}, ::Type{<:DataModel.Pose2},
+ placements, pose, φ, ::_ConnectionsAbsent,
+ combine::Symbol
+)
+ iszero(φ) || throw(ArgumentError(
+ "at(placements, pose) does not accept a second rotation"
+ ))
+ return parameterize(
+ _collection_target(placements),
+ _compose_collection,
+ (placements, pose);
+ combine
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Construct a pose, place a local cable part, place a completed cable with its
+connections, or compose an outer transform onto a placement collection.
+
+# Arguments
+
+- `x`: horizontal translation \\[m\\].
+- `y`: vertical translation \\[m\\].
+- `subject`: physical part, completed design or placement collection.
+- `pose`: existing `Pose2` declaration.
+
+# Keywords
+
+- `φ=0`: counter-clockwise rotation \\[rad\\].
+- `connections`: terminal-to-active-phase declaration required for a completed
+ cable design. Use one-based active phase IDs and `0` for a grounded or eliminated
+ conductor.
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- A `Pose2`, placed member, placed-design record, transformed collection, or
+ the corresponding `Gridspace` when a direct argument varies.
+"""
+function at(
+ left,
+ right;
+ φ = 0,
+ connections = _CONNECTIONS_ABSENT,
+ combine::Symbol = :product
+)
+ left_type = _parameter_type(left)
+ right_type = _parameter_type(right)
+ (left_type === Union{} || right_type === Union{}) && throw(ArgumentError(
+ "at does not accept an empty finite source"
+ ))
+ return _at_pair(left_type, right_type, left, right, φ, connections, combine)
+end
+
+function at(
+ subject,
+ x,
+ y;
+ φ = 0,
+ connections = _CONNECTIONS_ABSENT,
+ combine::Symbol = :product
+)
+ pose = at(x, y; φ, combine)
+ return at(subject, pose; connections, combine)
+end
+
+function _connection_for_member(connections::NamedTuple, member::Int, count::Int)
+ resolved = map(Base.values(connections)) do value
+ if value isa Union{Tuple, AbstractVector}
+ length(value) == count || throw(DimensionMismatch(
+ "connection schedules must contain $count values"
+ ))
+ return value[member]
+ end
+ return value
+ end
+ return NamedTuple{keys(connections)}(resolved)
+end
+
+function _connection_for_member(connections::AbstractDict, member::Int, count::Int)
+ return Dict(key => begin
+ if value isa Union{Tuple, AbstractVector}
+ length(value) == count || throw(DimensionMismatch(
+ "connection schedules must contain $count values"
+ ))
+ value[member]
+ else
+ value
+ end
+ end for (key, value) in connections)
+end
+
+function _connection_for_member(connections::Union{Tuple, AbstractVector}, member, count)
+ length(connections) == count || throw(DimensionMismatch(
+ "formation connections must contain $count declarations"
+ ))
+ return connections[member]
+end
+
+function _formation(design, local_poses, center, connections)
+ count = length(local_poses)
+ return [_placed_design(
+ design,
+ center * pose,
+ _connection_for_member(connections, member, count)
+ )
+ for (member, pose) in enumerate(local_poses)]
+end
+
+function _trefoil(design, center, spacing, connections, φ0)
+ spacing > zero(spacing) || throw(DomainError(
+ spacing, "trefoil spacing must be positive"
+ ))
+ coordinates = DataModel.trefoil_formation(0, 0, spacing / 2)
+ poses = DataModel.Pose2[DataModel.Pose2(coordinates[index], coordinates[index + 1], 0)
+ for index in 1:2:6]
+ origin = center * DataModel.Pose2(0, 0, φ0)
+ return _formation(design, poses, origin, connections)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Place three copies of one completed cable in an equilateral trefoil formation.
+
+# Arguments
+
+- `design`: completed cable design reused by all three members.
+
+# Keywords
+
+- `center=at(0, 0)`: formation-center pose \\[m, m, rad\\].
+- `spacing`: cable center-to-center distance \\[m\\].
+- `connections`: scalar or three-member terminal connection schedules.
+- `φ0=0`: formation rotation \\[rad\\].
+- `combine=:product`: gridspace composition rule.
+
+# Returns
+
+- Three placed-design records, or a `Gridspace{Vector}` when a direct argument
+ varies.
+"""
+function trefoil(
+ design;
+ center = at(0, 0),
+ spacing,
+ connections,
+ φ0 = 0,
+ combine::Symbol = :product
+)
+ values = (design, center, spacing, connections, φ0)
+ return parameterize(Vector, _trefoil, values; combine)
+end
+
+function _flat(design, center, spacing, connections, vertical::Bool)
+ spacing > zero(spacing) || throw(DomainError(
+ spacing, "flat-formation spacing must be positive"
+ ))
+ offsets = (-spacing, zero(spacing), spacing)
+ poses = DataModel.Pose2[vertical ? DataModel.Pose2(0, -offset, 0) :
+ DataModel.Pose2(offset, 0, 0)
+ for offset in offsets]
+ return _formation(design, poses, center, connections)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Place three copies of one completed cable in a horizontal flat formation.
+
+`spacing` is the adjacent center-to-center distance \\[m\\]. Scalar connection
+entries apply to every cable. Three-element entries distribute by member.
+"""
+function hflat(
+ design;
+ center = at(0, 0),
+ spacing,
+ connections,
+ combine::Symbol = :product
+)
+ caller = (resolved...) -> _flat(resolved..., false)
+ return parameterize(
+ Vector, caller, (design, center, spacing, connections); combine
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Place three copies of one completed cable in a vertical flat formation.
+
+`spacing` is the adjacent center-to-center distance \\[m\\]. Scalar connection
+entries apply to every cable. Three-element entries distribute by member.
+"""
+function vflat(
+ design;
+ center = at(0, 0),
+ spacing,
+ connections,
+ combine::Symbol = :product
+)
+ caller = (resolved...) -> _flat(resolved..., true)
+ return parameterize(
+ Vector, caller, (design, center, spacing, connections); combine
+ )
+end
diff --git a/src/parametricbuilder/positionspec.jl b/src/parametricbuilder/positionspec.jl
deleted file mode 100644
index 8037af380..000000000
--- a/src/parametricbuilder/positionspec.jl
+++ /dev/null
@@ -1,79 +0,0 @@
-struct PositionSpec <: AbstractPositionSpec
- x0::Real
- y0::Real
- dx::Any
- dy::Any
- conn::Dict{String, Int}
-end
-
-# ─────────────────────────────────────────────────────────────────────────────
-# Phase mapping helpers
-# Accepts:
-# :core => 1
-# ("core", 1)
-# (:core, 1)
-# [ :core => 1, :sheath => 0 ]
-# etc.
-# ─────────────────────────────────────────────────────────────────────────────
-const _PhaseMapInputs = Union{
- Tuple{Symbol, Any},
- Tuple{String, Any},
- Pair{Symbol, Any},
- Pair{String, Any},
-}
-
-# normalize phases input to a splattable tuple of _PhaseMapInputs
-_normalize_phase_map(p::_PhaseMapInputs) = (p,)
-_normalize_phase_map(p::Tuple) = p
-_normalize_phase_map(v::AbstractVector) = Tuple(v)
-_normalize_phase_map(::Nothing) = ()
-
-"""
- make_phase_maps(phases, n::Int)
-
-Unified helper to process phase DSL inputs.
-- If `n=1`, returns a vector with one Dict (used by `at`).
-- If `n>1`, distributes values:
- - Scalars (e.g. `1`) are broadcast to all `n` legs.
- - Tuples/Vectors (e.g. `(1,2,3)`) are distributed to respective legs.
-"""
-function make_phase_maps(phases, n::Int)
- items = _normalize_phase_map(phases)
- out = [Dict{String, Int}() for _ in 1:n]
-
- for item in items
- # Extract key/value
- key_raw, val_raw = item isa Pair ? (first(item), last(item)) : (item[1], item[2])
- key = string(key_raw)
-
- # Distribute
- if val_raw isa Integer
- # Scalar broadcast
- v = Int(val_raw)
- for i in 1:n
- out[i][key] = v
- end
- elseif (val_raw isa Tuple || val_raw isa AbstractVector)
- # Vector distribution
- if length(val_raw) != n
- error(
- "Dimension mismatch for phase '$key': expected $n elements, got $(length(val_raw))",
- )
- end
- for i in 1:n
- out[i][key] = Int(val_raw[i])
- end
- else
- error(
- "Invalid phase value for '$key': expected Integer or collection of length $n, got $(typeof(val_raw))",
- )
- end
- end
- return out
-end
-
-function at(; x, y, dx = 0.0, dy = 0.0, phases = nothing)
- # n=1 for single position
- maps = make_phase_maps(phases, 1)
- return PositionSpec(x, y, dx, dy, maps[1])
-end
diff --git a/src/parametricbuilder/results.jl b/src/parametricbuilder/results.jl
new file mode 100644
index 000000000..a9a818c16
--- /dev/null
+++ b/src/parametricbuilder/results.jl
@@ -0,0 +1,256 @@
+"""
+$(TYPEDEF)
+
+Evaluate every point in a [`ParametricProblem`](@ref) with `inner`.
+
+`inner` may be one completed formulation, a deterministic target-bearing
+formulation [`Gridspace`](@ref) or a deterministic [`Grid`](@ref) containing
+completed formulations. Problem and formulation points always form a
+Cartesian product. Uncertainty belongs to the problem space and is rejected
+from the formulation source.
+
+$(TYPEDFIELDS)
+"""
+struct Combinatorial{F, O <: ComputationOptions} <: AbstractFormulation
+ "Formulation used for each core problem."
+ inner::F
+ "Supplemental-output retention options owned by this traversal."
+ options::O
+
+ function Combinatorial(inner, options::ComputationOptions)
+ source = _combinatorial_source(inner)
+ normalized = computation_options(Combinatorial, options)
+ return new{typeof(source), typeof(normalized)}(source, normalized)
+ end
+end
+
+function computation_options(
+ ::Type{Combinatorial},
+ record::ComputationOptions
+)::ComputationOptions
+ options = record.data
+ unknown = filter(key -> key !== :retain_details, keys(options))
+ isempty(unknown) || throw(ArgumentError(
+ "unknown Combinatorial computation options: $(sort!(collect(unknown)))",
+ ))
+ normalized = merge((retain_details = false,), options)
+ normalized.retain_details isa Bool || throw(ArgumentError(
+ "Combinatorial retain_details must be Bool",
+ ))
+ return ComputationOptions(; retain_details = normalized.retain_details)
+end
+
+function _combinatorial_source(inner::AbstractFormulation)
+ return inner
+end
+
+function _combinatorial_source(inner::Gridspace{Target}) where {Target}
+ Target <: AbstractFormulation || throw(ArgumentError(
+ "Combinatorial Gridspace target must subtype AbstractFormulation; got $Target",
+ ))
+ has_uncertainty(inner) && throw(ArgumentError(
+ "Combinatorial formulation spaces must be deterministic",
+ ))
+ return inner
+end
+
+function _combinatorial_source(inner::AbstractGrid)
+ has_uncertainty(inner) && throw(ArgumentError(
+ "Combinatorial formulation grids must be deterministic",
+ ))
+ all(value -> value isa AbstractFormulation, inner) || throw(ArgumentError(
+ "Combinatorial formulation grids must contain completed AbstractFormulation values",
+ ))
+ return inner
+end
+
+function _combinatorial_source(inner)
+ throw(ArgumentError(
+ "Combinatorial requires a formulation, formulation Grid, or target-bearing formulation Gridspace; got $(typeof(inner))",
+ ))
+end
+
+function Combinatorial(inner; options::Union{NamedTuple,ComputationOptions} = ComputationOptions())
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ return Combinatorial(inner, options)
+end
+
+"""
+$(TYPEDEF)
+
+Pair a lazy parameter space with computation options for a higher-order
+computation. A completed scalar problem is normalized to a singleton
+target-bearing `Gridspace`. The problem itself need not implement iteration.
+
+$(TYPEDFIELDS)
+"""
+struct ParametricProblem{S, O <: ComputationOptions} <: AbstractProblemDefinition
+ "Lazy space of complete core problems."
+ space::S
+ "Options forwarded to each core computation for owner-specific normalization."
+ options::O
+
+ function ParametricProblem(space::S, options::O) where {S, O <: ComputationOptions}
+ return validate(new{S, O}(space, options))
+ end
+end
+
+function ParametricProblem(problem::AbstractProblemDefinition, options::ComputationOptions)
+ space = Gridspace{typeof(problem)}(identity, (Grid((problem,)),))
+ return ParametricProblem(space, options)
+end
+
+ParametricProblem(space) = ParametricProblem(space, ComputationOptions())
+
+function validate(problem::ParametricProblem)
+ problem.space isa Union{
+ AbstractProblemDefinition,
+ Gridspace{<:AbstractProblemDefinition}
+ } || throw(ArgumentError(
+ "ParametricProblem.space must be a completed problem or a Gridspace " *
+ "targeting AbstractProblemDefinition; received $(typeof(problem.space))"
+ ))
+ if problem.space isa AbstractProblemDefinition
+ validate(problem.space)
+ else
+ length(problem.space) > 0 || throw(ArgumentError(
+ "ParametricProblem.space must contain at least one problem point"
+ ))
+ end
+ return problem
+end
+
+"""
+$(TYPEDEF)
+
+Store core results over resolved problem and formulation axes.
+
+Linear indexing remains compatible with ordinary result-space iteration.
+Values use column-major `(problem, formulation)` order: the problem index
+varies fastest. Two-dimensional indexing accepts those indices directly.
+
+$(TYPEDFIELDS)
+"""
+struct ParametricResult{T, F, A <: NamedTuple, D <: ComputationDetails} <:
+ AbstractParametricResult{T}
+ "Higher-order formulation used for the computation."
+ formulation::F
+ "Core results in problem-index-fastest formulation order."
+ values::Vector{T}
+ "Problem and resolved-formulation sources aligned with `values`."
+ axes::A
+ "Typed supplemental output retained by the traversal."
+ details::D
+
+ function ParametricResult(
+ formulation::F,
+ values::Vector{T},
+ axes::A,
+ details::D
+ ) where {T, F, A <: NamedTuple, D <: ComputationDetails}
+ validate(T, ParametricResult)
+ if !isempty(axes)
+ keys(axes) == (:problems, :formulations) || throw(ArgumentError(
+ "ParametricResult axes must contain problems and formulations",
+ ))
+ length(axes.problems) * length(axes.formulations) == length(values) ||
+ throw(DimensionMismatch(
+ "ParametricResult axes must span every stored core result",
+ ))
+ end
+ isempty(details.data) || keys(details.data) == (:points,) ||
+ throw(ArgumentError(
+ "ParametricResult details must be empty or contain only points",
+ ))
+ isempty(details.data) || length(details.data.points) == length(values) ||
+ throw(DimensionMismatch(
+ "retained details must contain one entry per core result",
+ ))
+ return new{T, F, A, D}(formulation, values, axes, details)
+ end
+end
+
+function ParametricResult(formulation, values, details::ComputationDetails)
+ ParametricResult(formulation, values, (;), details)
+end
+ParametricResult(formulation, values) = ParametricResult(formulation, values, (;), ComputationDetails())
+
+Base.length(value::ParametricResult) = length(value.values)
+Base.size(value::ParametricResult) = (length(value),)
+Base.getindex(value::ParametricResult, index::Integer) = value.values[index]
+Base.iterate(value::ParametricResult, state...) = iterate(value.values, state...)
+Base.firstindex(value::ParametricResult) = firstindex(value.values)
+Base.lastindex(value::ParametricResult) = lastindex(value.values)
+
+"""
+Return one result selected by its problem and formulation indices.
+"""
+function Base.getindex(
+ value::ParametricResult,
+ problem_index::Integer,
+ formulation_index::Integer
+)
+ isempty(value.axes) && throw(ArgumentError(
+ "this ParametricResult does not retain problem/formulation axes",
+ ))
+ problem_count = length(value.axes.problems)
+ formulation_count = length(value.axes.formulations)
+ 1 <= problem_index <= problem_count || throw(BoundsError(
+ value,
+ (problem_index, formulation_index)
+ ))
+ 1 <= formulation_index <= formulation_count || throw(BoundsError(
+ value,
+ (problem_index, formulation_index)
+ ))
+ index = problem_index + (formulation_index - 1) * problem_count
+ return value.values[index]
+end
+
+details(value::ParametricResult) = value.details
+
+const _GRIDSPACE_TRANSPORT_DOCUMENTATION = "https://electa-git.github.io/LineCableModels.jl/dev/gridspace/" *
+ "#Transporting-completed-result-spaces"
+
+function _transport_error(::Type{Target}, source::AbstractResultSpace) where {Target}
+ target_name = nameof(Target)
+ source_name = nameof(typeof(source))
+ throw(ArgumentError(
+ "Gridspace transport from $source_name to problem $target_name " *
+ "is not defined. Define Gridspace{$target_name}(::$source_name) " *
+ "for that result family. See `?Gridspace` and " *
+ _GRIDSPACE_TRANSPORT_DOCUMENTATION,
+ ))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Transport every completed combinatorial result into one target-bearing
+downstream problem point while preserving source cardinality and order.
+"""
+function Gridspace{Target}(
+ source::ParametricResult{T, F}
+) where {Target, T, F <: Combinatorial}
+ return Gridspace{Target}(Target, (source,))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Reject result-to-problem transports whose result owner has not defined how to
+construct the requested target-bearing `Gridspace`.
+"""
+function Gridspace{Target}(source::AbstractResultSpace) where {Target}
+ return _transport_error(Target, source)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Expose the formulation, ordered values, resolved axes and computation details as
+a native record. Arrays and axes retain their identity. The method returns these retained objects without calculating or copying them.
+"""
+function Base.NamedTuple(value::ParametricResult)
+ return (formulation=value.formulation, values=value.values, axes=value.axes, details=value.details)
+end
diff --git a/src/parametricbuilder/system.jl b/src/parametricbuilder/system.jl
new file mode 100644
index 000000000..de7d1606b
--- /dev/null
+++ b/src/parametricbuilder/system.jl
@@ -0,0 +1,224 @@
+function build(
+ ::Type{DataModel.LineCableSystem},
+ designs,
+ placements;
+ connections = nothing,
+ environment = nothing,
+ system_id = "line-cable-system",
+ line_length = 1,
+ combine::Symbol = :product
+)
+ values = (
+ designs,
+ placements,
+ connections,
+ environment,
+ system_id,
+ line_length
+ )
+ caller = (selected...) -> build(DataModel.LineCableSystem, selected...)
+ return parameterize(DataModel.LineCableSystem, caller, values; combine)
+end
+
+function _placed_system(
+ placements,
+ environment,
+ system_id,
+ line_length
+)
+ placements isa Union{Tuple, AbstractVector} && !isempty(placements) || throw(
+ ArgumentError("placed cable declarations must be a nonempty collection")
+ )
+ declarations = Any[]
+ for placement in placements
+ if placement isa Union{Tuple, AbstractVector}
+ append!(declarations, placement)
+ else
+ push!(declarations, placement)
+ end
+ end
+ isempty(declarations) && throw(ArgumentError(
+ "placed cable declarations must be a nonempty collection"
+ ))
+ all(declarations) do placement
+ placement isa NamedTuple &&
+ keys(placement) == (:design, :pose, :connections) &&
+ placement.design isa DataModel.CableDesign &&
+ placement.pose isa DataModel.Pose2
+ end || throw(ArgumentError(
+ "each placed cable requires design, pose, and connections"
+ ))
+ return build(
+ DataModel.LineCableSystem,
+ getproperty.(declarations, :design),
+ getproperty.(declarations, :pose),
+ getproperty.(declarations, :connections),
+ environment,
+ system_id,
+ line_length
+ )
+end
+
+function build(
+ ::Type{DataModel.LineCableSystem},
+ placements;
+ environment = nothing,
+ system_id = "line-cable-system",
+ line_length = 1,
+ combine::Symbol = :product
+)
+ values = (placements, environment, system_id, line_length)
+ return parameterize(
+ DataModel.LineCableSystem, _placed_system, values; combine
+ )
+end
+
+function _line_problem(system, temperature, earth, frequencies)
+ return Engine.LineParametersProblem(
+ system;
+ temperature,
+ earth_props = earth,
+ frequencies
+ )
+end
+
+function Engine.LineParametersProblem(
+ system::Gridspace{DataModel.LineCableSystem};
+ temperature = 20,
+ earth_props,
+ frequencies = [50],
+ combine::Symbol = :product
+)
+ values = (system, temperature, earth_props, frequencies)
+ grids = map(values) do value
+ value isa Union{AbstractGrid, Gridspace} ? value : Grid((value,))
+ end
+ return Gridspace{Engine.LineParametersProblem}(_line_problem, grids; combine)
+end
+
+function Engine.LineParametersProblem(
+ system::Gridspace{DataModel.LineCableSystem},
+ earth_props::Union{AbstractGrid, Gridspace};
+ temperature = 20,
+ frequencies = [50],
+ combine::Symbol = :product
+)
+ return Engine.LineParametersProblem(
+ system;
+ temperature,
+ earth_props,
+ frequencies,
+ combine
+ )
+end
+
+function _placed_line_problem(
+ placements,
+ environment,
+ system_id,
+ line_length,
+ temperature,
+ earth_props,
+ frequencies
+)
+ system = _placed_system(placements, environment, system_id, line_length)
+ return Engine.LineParametersProblem(
+ system;
+ temperature,
+ earth_props,
+ frequencies
+ )
+end
+
+function Engine.LineParametersProblem(
+ placements::Union{Tuple, AbstractVector, Gridspace};
+ environment = nothing,
+ system_id = "line-cable-system",
+ line_length = 1,
+ temperature = 20,
+ earth_props,
+ frequencies = [50],
+ combine::Symbol = :product
+)
+ values = (
+ placements, environment, system_id, line_length, temperature,
+ earth_props, frequencies
+ )
+ return parameterize(
+ Engine.LineParametersProblem, _placed_line_problem, values; combine
+ )
+end
+
+function Engine.LineParametersProblem(
+ system::DataModel.LineCableSystem,
+ earth_props::Union{AbstractGrid, Gridspace};
+ temperature = 20,
+ frequencies = [50],
+ combine::Symbol = :product
+)
+ values = (system, temperature, earth_props, frequencies)
+ grids = map(values) do value
+ value isa Union{AbstractGrid, Gridspace} ? value : Grid((value,))
+ end
+ return Gridspace{Engine.LineParametersProblem}(_line_problem, grids; combine)
+end
+
+function Engine.LineParametersProblem(
+ designs,
+ placements,
+ connections,
+ environment,
+ system_id,
+ line_length,
+ temperature,
+ earth_props,
+ frequencies;
+ combine::Symbol = :product
+)
+ values = (
+ designs,
+ placements,
+ connections,
+ environment,
+ system_id,
+ line_length,
+ temperature,
+ earth_props,
+ frequencies
+ )
+ any(value -> value isa Union{AbstractGrid, Gridspace}, values) || throw(
+ MethodError(Engine.LineParametersProblem, values)
+ )
+ sources = map(values) do value
+ value isa Union{AbstractGrid, Gridspace} ? value : Grid((value,))
+ end
+ return Gridspace{Engine.LineParametersProblem}(
+ Engine.LineParametersProblem, sources; combine
+ )
+end
+
+function Engine.LineParametersProblem(
+ designs,
+ placements;
+ connections = nothing,
+ environment = nothing,
+ system_id = "line-cable-system",
+ line_length = 1,
+ temperature = 20,
+ earth_props,
+ frequencies = [50],
+ combine::Symbol = :product
+)
+ return Engine.LineParametersProblem(
+ designs,
+ placements,
+ connections,
+ environment,
+ system_id,
+ line_length,
+ temperature,
+ earth_props,
+ frequencies;
+ combine
+ )
+end
diff --git a/src/parametricbuilder/systembuilderspec.jl b/src/parametricbuilder/systembuilderspec.jl
deleted file mode 100644
index 8329437bd..000000000
--- a/src/parametricbuilder/systembuilderspec.jl
+++ /dev/null
@@ -1,166 +0,0 @@
-# Positions in the parametric system builder.
-#
-# Two flavours:
-# - PositionSpec : single anchor with dx/dy ranges (arbitrary layouts)
-# - PositionGroupSpec : defined in `trifoil.jl`, grouped formations
-#
-# Both subtype AbstractPositionSpec so SystemBuilderSpec can store a mixed vector.
-abstract type AbstractPositionSpec end
-
-include("positionspec.jl")
-include("groupspec.jl")
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 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{AbstractPositionSpec}
- 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{<:AbstractPositionSpec};
- 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::AbstractVector{<:AbstractPositionSpec};
- length = 1000.0, temperature = 20.0, earth::EarthSpec, f::AbstractVector{<:Real}) = SystemBuilderSpec(id, cbs, positions; length, temperature, earth, f)
-
-SystemBuilder(id::AbstractString, cbs::CableBuilderSpec,
- positions::AbstractPositionSpec;
- 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_earth(e::EarthSpec) = (
- (ρ, ε, μ, t)
- for ρ in _expand_pair(e.rho),
- ε in _expand_pair(e.eps_r),
- μ in _expand_pair(e.mu_r),
- t in _expand_pair(e.t)
-)
-
-# Choice count for single positions: size of the dx × dy grid
-function _position_choice_count(p::PositionSpec)
- nx = length(collect(_axis(p.x0, p.dx)))
- ny = length(collect(_axis(p.y0, p.dy)))
- return nx * ny
-end
-
-# Choice count for grouped positions: number of spacing samples
-function _position_choice_count(g::PositionGroupSpec)
- spec, pct = g.d
- return length(collect(_make_range(spec; pct = pct)))
-end
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 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(spec::SystemBuilderSpec)
- return Channel{LineParametersProblem}(32) do ch
- produced = 0
- try
- for des in spec.builder
- for L in _expand_pair(spec.length)
- # NB: _expand_position keeps grouped spacings atomic and
- # materializes after `des` (and its outer radius) are known.
- for choice in _expand_position(spec.positions, des)
- 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)) || occursin(
- "conductor resistivity must be positive",
- 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
- @debug "iterate SystemBuilderSpec finished" produced = produced upper_bound =
- cardinality(spec)
- end
- end
-end
-
-function build(spec::SystemBuilderSpec)
- problems = LineParametersProblem[]
- for prob in spec # uses Base.iterate(spec::SystemBuilderSpec)
- push!(problems, prob)
- end
- return problems
-end
diff --git a/src/parametricbuilder/textdisplay.jl b/src/parametricbuilder/textdisplay.jl
new file mode 100644
index 000000000..80548f0b2
--- /dev/null
+++ b/src/parametricbuilder/textdisplay.jl
@@ -0,0 +1,200 @@
+function _grid_item(value)
+ value isa Real && return TextDisplay.value(value)
+ return sprint(show, value; context = :compact => true, sizehint = 64)
+end
+
+function _display_grid_values(values)
+ count = length(values)
+ count == 0 && return "empty"
+ visible = min(count, 4)
+ items = join((_grid_item(values[index]) for index in 1:visible), ", ")
+ return count > visible ? "$items, … ($count values)" : items
+end
+
+TextDisplay.name(::Type{<:UncertainValue}) = "UncertainValue"
+function Base.summary(io::IO, ::UncertainValue)
+ print(io, "Uncertain value")
+end
+function Base.show(io::IO, value::UncertainValue)
+ print(io, "UncertainValue(", _grid_item(value.nominal), " ± ", _grid_item(value.sigma), ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", value::UncertainValue)
+ show(io, value)
+end
+
+TextDisplay.name(::Type{<:AbsoluteError}) = "AbsoluteError"
+function Base.summary(io::IO, error::AbsoluteError)
+ print(io, "Absolute error with $(length(error.vals)) values")
+end
+function Base.show(io::IO, error::AbsoluteError)
+ print(io, "AbsoluteError(", _display_grid_values(error.vals), ")")
+end
+Base.show(io::IO, ::MIME"text/plain", error::AbsoluteError) = show(io, error)
+
+function _grid_kind(::DeterministicGrid)
+ return "Grid"
+end
+function _grid_kind(::RelativeGrid)
+ return "Relative-error Grid"
+end
+function _grid_kind(::AbsoluteGrid)
+ return "Absolute-error Grid"
+end
+
+function _grid_nominals(grid::DeterministicGrid)
+ return grid.vals
+end
+function _grid_nominals(grid::Union{RelativeGrid, AbsoluteGrid})
+ return grid.vals
+end
+function _grid_errors(grid::RelativeGrid)
+ return "relative error $(_display_grid_values(grid.rel_err)) %"
+end
+function _grid_errors(grid::AbsoluteGrid)
+ return "absolute error $(_display_grid_values(grid.abs_err))"
+end
+
+TextDisplay.name(::Type{<:DeterministicGrid}) = "Grid"
+TextDisplay.name(::Type{<:RelativeGrid}) = "Relative-error Grid"
+TextDisplay.name(::Type{<:AbsoluteGrid}) = "Absolute-error Grid"
+function Base.summary(io::IO, grid::AbstractGrid)
+ print(io, _grid_kind(grid), " with ", length(grid), " points")
+end
+function Base.show(io::IO, grid::AbstractGrid)
+ print(io, _grid_kind(grid), "(", _display_grid_values(_grid_nominals(grid)))
+ grid isa DeterministicGrid || print(io, "; ", _grid_errors(grid))
+ print(io, ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", grid::AbstractGrid)
+ get(io, :compact, false) && return show(io, grid)
+ children = Any[(label = "values $(_display_grid_values(_grid_nominals(grid)))", noun = "values")]
+ grid isa DeterministicGrid || push!(children, (label = _grid_errors(grid), noun = "values"))
+ return TextDisplay.tree(io, "$(_grid_kind(grid)) · $(length(grid)) points", Tuple(children))
+end
+
+function _semantic_target(::Type{Target}) where {Target}
+ applicable(TextDisplay.name, Target) && return TextDisplay.name(Target)
+ return String(nameof(Target))
+end
+
+function _space_axis(source::Gridspace)
+ return sprint(show, source; context = :compact => true, sizehint = 96)
+end
+function _space_axis(source::AbstractGrid)
+ return _display_grid_values(_grid_nominals(source))
+end
+
+TextDisplay.name(::Type{<:Gridspace{Target}}) where {Target} =
+ string(_semantic_target(Target), " parameter space")
+function Base.summary(io::IO, space::Gridspace{Target}) where {Target}
+ print(io, _semantic_target(Target), " parameter space with ", length(space), " points")
+end
+function Base.show(io::IO, space::Gridspace{Target}) where {Target}
+ print(io, _semantic_target(Target), " parameter space · ", length(space), " points")
+end
+function Base.show(io::IO, ::MIME"text/plain", space::Gridspace{Target}) where {Target}
+ get(io, :compact, false) && return show(io, space)
+ varying = Tuple((index, source) for (index, source) in enumerate(space.grids)
+ if length(source) > 1)
+ fixed = length(space.grids) - length(varying)
+ children = Any[
+ (label = "axis $index $(_space_axis(source))", noun = "axes")
+ for (index, source) in varying
+ ]
+ fixed == 0 || push!(children, (
+ label = "$fixed fixed $(fixed == 1 ? "input" : "inputs")",
+ noun = "axes",
+ ))
+ target = _semantic_target(Target)
+ return TextDisplay.tree(
+ io,
+ "$target parameter space · $(length(varying)) varying axes · $(length(space)) points",
+ Tuple(children);
+ noun = "axes"
+ )
+end
+
+TextDisplay.name(::Type{<:Combinatorial}) = "Combinatorial"
+Base.summary(io::IO, ::Combinatorial) = print(io, "Combinatorial formulation")
+function Base.show(io::IO, formulation::Combinatorial)
+ print(io, "Combinatorial(")
+ show(IOContext(io, :compact => true), formulation.inner)
+ print(io, ")")
+end
+Base.show(io::IO, ::MIME"text/plain", formulation::Combinatorial) = show(io, formulation)
+
+TextDisplay.name(::Type{<:ParametricProblem}) = "ParametricProblem"
+Base.summary(io::IO, ::ParametricProblem) = print(io, "Parametric problem")
+function Base.show(io::IO, problem::ParametricProblem)
+ print(io, "ParametricProblem(")
+ show(IOContext(io, :compact => true), problem.space)
+ print(io, ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", problem::ParametricProblem)
+ get(io, :compact, false) && return show(io, problem)
+ return TextDisplay.tree(io, "ParametricProblem", (
+ (label = "space $(sprint(show, problem.space; context = :compact => true))", noun = "fields"),
+ (label = "options $(length(problem.options.data)) entries", noun = "fields"),
+ ))
+end
+
+TextDisplay.name(::Type{<:ParametricResult}) = "ParametricResult"
+function Base.summary(io::IO, result::ParametricResult)
+ print(io, "Parametric result with $(length(result)) values")
+end
+function Base.show(io::IO, result::ParametricResult)
+ print(io, "ParametricResult($(length(result)) values)")
+end
+function Base.show(io::IO, ::MIME"text/plain", result::ParametricResult)
+ get(io, :compact, false) && return show(io, result)
+ value_type = isempty(result.values) ? "none" : _semantic_target(eltype(result.values))
+ return TextDisplay.tree(io, "ParametricResult · $(length(result)) values", (
+ (label = "result type $value_type", noun = "fields"),
+ (label = "details $(length(result.details.data)) entries", noun = "fields"),
+ ))
+end
+
+TextDisplay.name(::Type{<:WireEstimate}) = "WireEstimate"
+function Base.summary(io::IO, estimate::WireEstimate)
+ print(io, "Wire estimate with $(length(estimate)) patterns")
+end
+function Base.show(io::IO, estimate::WireEstimate)
+ print(io, "WireEstimate(", estimate.status, "; patterns=", length(estimate), ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", estimate::WireEstimate)
+ get(io, :compact, false) && return show(io, estimate)
+ patterns = Tuple((
+ label = sprint(show, pattern; context = :compact => true, sizehint = 96),
+ noun = "patterns",
+ ) for pattern in estimate.patterns)
+ children = Any[
+ (label = "target $(TextDisplay.value(estimate.target_area))", noun = "fields"),
+ (label = "status $(estimate.status)", noun = "fields"),
+ (label = "patterns", children = patterns, noun = "patterns"),
+ ]
+ isempty(estimate.reasons) || push!(children, (
+ label = "reasons",
+ children = Tuple((label = reason, noun = "reasons") for reason in estimate.reasons),
+ noun = "reasons",
+ ))
+ return TextDisplay.tree(io, "Wire estimate", Tuple(children))
+end
+
+function Base.summary(io::IO, pattern::WirePatterns.HexaPattern)
+ print(io, "Strand pattern with $(pattern.wires) wires")
+end
+function Base.show(io::IO, pattern::WirePatterns.HexaPattern)
+ print(io, "StrandPattern(layers=", pattern.layers, ", wires=", pattern.wires,
+ ", d=", TextDisplay.engineering(pattern.wire_diameter, :meter), ")")
+end
+Base.show(io::IO, ::MIME"text/plain", pattern::WirePatterns.HexaPattern) = show(io, pattern)
+
+function Base.summary(io::IO, pattern::WirePatterns.ScreenPattern)
+ print(io, "Screen pattern with $(pattern.wires) wires")
+end
+function Base.show(io::IO, pattern::WirePatterns.ScreenPattern)
+ print(io, "ScreenPattern(wires=", pattern.wires,
+ ", d=", TextDisplay.engineering(pattern.wire_diameter, :meter),
+ ", coverage=", TextDisplay.value(pattern.coverage), " %)")
+end
+Base.show(io::IO, ::MIME"text/plain", pattern::WirePatterns.ScreenPattern) = show(io, pattern)
diff --git a/src/parametricbuilder/traversal.jl b/src/parametricbuilder/traversal.jl
new file mode 100644
index 000000000..408430610
--- /dev/null
+++ b/src/parametricbuilder/traversal.jl
@@ -0,0 +1,324 @@
+function _formulations(inner::AbstractFormulation)
+ return typeof(inner)[inner]
+end
+
+function _formulations(source::Gridspace{Target}) where {Target}
+ formulations = Vector{Target}(undef, length(source))
+ for (index, point) in enumerate(points(source))
+ formulation = materialize(point)
+ formulation isa AbstractFormulation || throw(ArgumentError(
+ "formulation Gridspace produced $(typeof(formulation))",
+ ))
+ formulations[index] = formulation
+ end
+ return formulations
+end
+
+function _formulations(source::AbstractGrid)
+ isempty(source) && return AbstractFormulation[]
+ collected = collect(source)
+ formulation_type = foldl(
+ typejoin,
+ (typeof(formulation) for formulation in collected)
+ )
+ formulation_type <: AbstractFormulation || throw(ArgumentError(
+ "formulation Grid must contain completed AbstractFormulation values",
+ ))
+ formulations = Vector{formulation_type}(undef, length(collected))
+ for (index, formulation) in enumerate(collected)
+ formulation isa AbstractFormulation || throw(ArgumentError(
+ "formulation Grid produced $(typeof(formulation))",
+ ))
+ formulations[index] = formulation
+ end
+ return formulations
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate established scalar problem-formulation dispatch for every completed
+formulation in a collection. Owners may provide a more specific method to
+share immutable lowering work. Every result must have one consistent concrete
+type.
+"""
+function compute(
+ problem::AbstractProblemDefinition,
+ formulations::AbstractVector{<:AbstractFormulation};
+ options::Union{NamedTuple,ComputationOptions} = ComputationOptions()
+)
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ isempty(formulations) && throw(ArgumentError(
+ "a formulation collection must contain at least one formulation",
+ ))
+ first_result = compute(problem, first(formulations); options)
+ validate(typeof(first_result), AbstractResultSpace)
+ values = Vector{typeof(first_result)}(undef, length(formulations))
+ values[1] = first_result
+ for index in 2:length(formulations)
+ value = compute(problem, formulations[index]; options)
+ typeof(value) === eltype(values) || throw(ArgumentError(
+ "batched computation produced inconsistent core result types",
+ ))
+ values[index] = value
+ end
+ return values
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Resolve the formulation source once, materialize every problem point once,
+and evaluate their Cartesian product. Optional details are aligned with the
+same problem-index-fastest storage order.
+
+# Returns
+
+- A named tuple containing `values`, `(problems, formulations)` axes, and
+ `ComputationDetails`, empty or containing `(points=records,)`.
+"""
+function traverse(problem::ParametricProblem, formulation)
+ progress = problem.space isa Gridspace{<:Engine.LineParametersProblem} && verbosity(problem.options, :progress) > 0
+ started = progress ? time_ns() : UInt64(0)
+ previous = started
+ last_log = started
+ average_seconds = 0.0
+ child_options = progress ? ComputationOptions(merge(problem.options.data,
+ (verbosity=merge(problem.options.data.verbosity, (progress=0,)),))) : problem.options
+ source_id = Commons.gridpoint_id().source_id
+ point_count = length(problem.space)
+ point_count > 0 || throw(ArgumentError(
+ "higher-order problem space must contain at least one core problem",
+ ))
+
+ formulations = _formulations(formulation.inner)
+ formulation_count = length(formulations)
+ formulation_count > 0 || throw(ArgumentError(
+ "higher-order formulation space must contain at least one formulation",
+ ))
+
+ point_source = points(problem.space)
+ first_item = iterate(point_source)
+ first_item === nothing && throw(DimensionMismatch(
+ "problem-space iteration ended before its declared cardinality",
+ ))
+ first_point, state = first_item
+ first_problem = materialize(first_point)
+ first_batch = [Engine.retain_gridpoint(value,
+ Commons.gridpoint_id(;source_id,problem_index=1,formulation_index=index))
+ for (index,value) in enumerate(compute(first_problem, formulations; options = child_options))]
+ length(first_batch) == formulation_count || throw(DimensionMismatch(
+ "batched computation did not return one result per formulation",
+ ))
+ first_result = first(first_batch)
+ validate(typeof(first_result), ParametricResult)
+ values = Vector{typeof(first_result)}(
+ undef,
+ point_count * formulation_count
+ )
+ @inbounds for formulation_index in 1:formulation_count
+ value = first_batch[formulation_index]
+ typeof(value) === eltype(values) || throw(ArgumentError(
+ "batched computation produced inconsistent core result types",
+ ))
+ values[1 + (formulation_index - 1) * point_count] = value
+ end
+
+ retained = if formulation.options.data.retain_details
+ first_record = computation_details(
+ typeof(first(formulations)),
+ first_result
+ )
+ records = Vector{typeof(first_record)}(
+ undef,
+ point_count * formulation_count
+ )
+ @inbounds for formulation_index in 1:formulation_count
+ record = computation_details(
+ typeof(formulations[formulation_index]),
+ first_batch[formulation_index]
+ )
+ typeof(record) === eltype(records) || throw(ArgumentError(
+ "batched computation produced inconsistent details record types",
+ ))
+ records[1 + (formulation_index - 1) * point_count] = record
+ end
+ records
+ else
+ nothing
+ end
+
+ if progress
+ now = time_ns()
+ average_seconds = (now - previous) * 1e-9 / formulation_count
+ previous = now
+ if now - last_log >= 5_000_000_000
+ @info "Parametric progress" _group=:progress problems_completed=1 problems=point_count completed=formulation_count total=point_count*formulation_count elapsed_seconds=(now-started)*1e-9 eta_hours=(point_count-1)*formulation_count*average_seconds/3600
+ last_log = now
+ end
+ end
+
+ for index in 2:point_count
+ item = iterate(point_source, state)
+ item === nothing && throw(DimensionMismatch(
+ "problem-space iteration ended before its declared cardinality",
+ ))
+ point, state = item
+ resolved_problem = materialize(point)
+ batch = [Engine.retain_gridpoint(value,
+ Commons.gridpoint_id(;source_id,problem_index=index,formulation_index=fi))
+ for (fi,value) in enumerate(compute(resolved_problem, formulations; options = child_options))]
+ length(batch) == formulation_count || throw(DimensionMismatch(
+ "batched computation did not return one result per formulation",
+ ))
+ @inbounds for formulation_index in 1:formulation_count
+ core_result = batch[formulation_index]
+ typeof(core_result) === eltype(values) || throw(ArgumentError(
+ "higher-order computation produced inconsistent core result types",
+ ))
+ result_index = index + (formulation_index - 1) * point_count
+ values[result_index] = core_result
+
+ if retained !== nothing
+ record = computation_details(
+ typeof(formulations[formulation_index]),
+ core_result
+ )
+ typeof(record) === eltype(retained) || throw(ArgumentError(
+ "higher-order computation produced inconsistent details record types",
+ ))
+ retained[result_index] = record
+ end
+ end
+ if progress
+ now = time_ns()
+ interval = (now - previous) * 1e-9 / formulation_count
+ average_seconds = 0.2 * interval + 0.8 * average_seconds
+ previous = now
+ if now - last_log >= 5_000_000_000
+ @info "Parametric progress" _group=:progress problems_completed=index problems=point_count completed=index*formulation_count total=point_count*formulation_count elapsed_seconds=(now-started)*1e-9 eta_hours=(point_count-index)*formulation_count*average_seconds/3600
+ last_log = now
+ end
+ end
+ end
+ iterate(point_source, state) === nothing || throw(DimensionMismatch(
+ "problem-space iteration exceeded its declared cardinality",
+ ))
+
+ retained_details = ComputationDetails(retained === nothing ? (;) : (points = retained,))
+ axes = (
+ problems = problem.space,
+ formulations = formulations
+ )
+ return (; values, details = retained_details, axes)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Compute one target-bearing scalar grid point. Core workflows may add a more
+specific lowering method. The default method materializes the selected point
+and computes its result.
+"""
+function compute(
+ point::Gridpoint{Target},
+ formulation;
+ options::Union{NamedTuple,ComputationOptions} = ComputationOptions()
+) where {Target <: AbstractProblemDefinition}
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ problem = materialize(point)::Target
+ return compute(problem, formulation; options)
+end
+
+function compute(problem::ParametricProblem, formulation::Combinatorial;
+ modal=nothing, modal_options::Union{NamedTuple,ComputationOptions}=ComputationOptions())
+ modal===nothing || return compute(problem,formulation,
+ LineCableModels.ModalAnalysisFormulation(modal);modal_options)
+ isempty(modal_options isa NamedTuple ? modal_options : modal_options.data) ||
+ throw(ArgumentError("modal_options require a modal formulation"))
+ levels = verbosity(get(problem.options.data, :verbosity, (default=0,)))
+ progress = problem.space isa Gridspace{<:Engine.LineParametersProblem} && get(levels, :progress, levels.default) > 0
+ logger = haskey(problem.options.data, :verbosity) ? VerbosityLogger(Logging.current_logger(), levels) : Logging.current_logger()
+ return Logging.with_logger(logger) do
+ started = progress ? time_ns() : UInt64(0)
+ progress && @info "Parametric computation started" _group=:progress problems=length(problem.space)
+ traversed = traverse(problem, formulation)
+ result = ParametricResult(formulation, traversed.values, traversed.axes, traversed.details)
+ progress && @info "Parametric computation completed successfully" _group=:progress completed=length(result) total=length(result) elapsed_seconds=(time_ns()-started)*1e-9
+ result
+ end
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate a deterministic formulation `Gridspace` with default
+[`Combinatorial`](@ref) settings. A scalar problem forms a singleton problem
+axis. A problem `Gridspace` forms a Cartesian product with the formulations.
+
+# Keywords
+
+- `options=(;)`: options supplied to each core computation.
+
+# Returns
+
+- A [`ParametricResult`](@ref) indexed by problem and formulation, with no
+ retained traversal details. Select `Combinatorial` explicitly to customize
+ its settings.
+"""
+function compute(
+ problem::Union{AbstractProblemDefinition, Gridspace{<:AbstractProblemDefinition}},
+ formulations::Gridspace{<:AbstractFormulation};
+ options::Union{NamedTuple,ComputationOptions} = ComputationOptions(),
+ modal=nothing, modal_options::Union{NamedTuple,ComputationOptions}=ComputationOptions()
+)
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ modal===nothing || return compute(problem,formulations,
+ LineCableModels.ModalAnalysisFormulation(modal);options,modal_options)
+ isempty(modal_options isa NamedTuple ? modal_options : modal_options.data) ||
+ throw(ArgumentError("modal_options require a modal formulation"))
+ return compute(ParametricProblem(problem, options), Combinatorial(formulations))
+end
+
+function compute(problem::Gridspace{<:AbstractProblemDefinition},
+ formulation::AbstractFormulation;
+ options::Union{NamedTuple,ComputationOptions}=ComputationOptions(),
+ modal=nothing, modal_options::Union{NamedTuple,ComputationOptions}=ComputationOptions())
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ modal===nothing || return compute(problem,formulation,
+ LineCableModels.ModalAnalysisFormulation(modal);options,modal_options)
+ isempty(modal_options isa NamedTuple ? modal_options : modal_options.data) ||
+ throw(ArgumentError("modal_options require a modal formulation"))
+ return compute(ParametricProblem(problem, options), Combinatorial(formulation))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Evaluate a deterministic formulation `Gridspace` with default
+[`Combinatorial`](@ref) settings, retaining the existing `ParametricProblem`
+and its core computation options. Return a [`ParametricResult`](@ref).
+"""
+function compute(
+ problem::ParametricProblem,
+ formulations::Gridspace{<:AbstractFormulation};
+ modal=nothing, modal_options::Union{NamedTuple,ComputationOptions}=ComputationOptions()
+)
+ modal===nothing || return compute(problem,formulations,
+ LineCableModels.ModalAnalysisFormulation(modal);modal_options)
+ isempty(modal_options isa NamedTuple ? modal_options : modal_options.data) ||
+ throw(ArgumentError("modal_options require a modal formulation"))
+ return compute(problem, Combinatorial(formulations))
+end
+
+# A third positional argument composes a parametric study with modal analysis of
+# each completed line-parameter result.
+function compute(problem::ParametricProblem,
+ formulation::Union{Combinatorial,Gridspace{<:AbstractFormulation}},
+ modal::Union{LineCableModels.ModalAnalysisFormulation,
+ Gridspace{<:LineCableModels.ModalAnalysisFormulation}};
+ modal_options::Union{NamedTuple,ComputationOptions}=ComputationOptions())
+ phase=compute(problem,formulation)
+ return compute(Gridspace{LineCableModels.ModalAnalysisProblem}(phase),modal;
+ options=modal_options)
+end
diff --git a/src/parametricbuilder/wirepatterns/WirePatterns.jl b/src/parametricbuilder/wirepatterns/WirePatterns.jl
index b9f935017..ead83adc3 100644
--- a/src/parametricbuilder/wirepatterns/WirePatterns.jl
+++ b/src/parametricbuilder/wirepatterns/WirePatterns.jl
@@ -1,344 +1,26 @@
-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)
+ LineCableModels.ParametricBuilder.WirePatterns
-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).
+Estimate deterministic wire patterns and retain ranked patterns with their
+geometric packing limits.
"""
-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)])
- if best_match.wires > 271
- @warn "Best match stranded pattern with a very high wire count ($(best_match.wires) wires). Consider revising the target cross-section or choosing a different wire configuration."
- end
-
- # 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
+module WirePatterns
- return (; best_match, min_layers, min_diam)
-end
+using DocStringExtensions: TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
-# ────────────────────────────────────────────────────────────────────────────
-# Screen (single wire array) patterns
-# ────────────────────────────────────────────────────────────────────────────
+import ...LineCableModels: nominal
"""
- 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.
+Return the maximum wire count admitted by one estimate geometry.
"""
-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)])
+function maxfill end
- return (; min_wires, min_diam, best_match)
-end
+export WireEstimate, estimate_stranding, estimate_screen
+public HexaPattern, ScreenPattern
+include("types.jl")
+include("gauges.jl")
+include("stranded.jl")
+include("screened.jl")
-end # module
+end # module WirePatterns
diff --git a/src/parametricbuilder/wirepatterns/gauges.jl b/src/parametricbuilder/wirepatterns/gauges.jl
new file mode 100644
index 000000000..37c7da88c
--- /dev/null
+++ b/src/parametricbuilder/wirepatterns/gauges.jl
@@ -0,0 +1,77 @@
+_wire_area(diameter::Real) = (one(diameter) * pi / 4) * diameter^2
+
+const _AWG_BASE = 92
+const _D0_MM = 0.127
+const _AREA0_MM2 = 0.012668
+
+function awg_to_d_mm(number::Real)
+ oftype(float(number), _D0_MM) *
+ oftype(float(number), _AWG_BASE) ^
+ ((oftype(float(number), 36) - number) / oftype(float(number), 39))
+end
+function awg_to_area_mm2(number::Real)
+ oftype(float(number), _AREA0_MM2) *
+ oftype(float(number), _AWG_BASE) ^
+ ((oftype(float(number), 36) - number) / oftype(float(number), 19.5))
+end
+
+"""Return the AWG number corresponding to `diameter` \\[mm\\]."""
+function d_mm_to_awg(diameter::Real)
+ oftype(float(diameter), 36) -
+ oftype(float(diameter), 39) *
+ log(diameter / oftype(float(diameter), _D0_MM)) /
+ log(oftype(float(diameter), _AWG_BASE))
+end
+"""Return the AWG number corresponding to solid metal `area` \\[mm²\\]."""
+function area_mm2_to_awg(area::Real)
+ oftype(float(area), 36) -
+ oftype(float(area), 19.5) *
+ log(area / oftype(float(area), _AREA0_MM2)) /
+ log(oftype(float(area), _AWG_BASE))
+end
+
+function awg_label(number::Integer)
+ number == -3 && return "0000 (4/0)"
+ number == -2 && return "000 (3/0)"
+ number == -1 && return "00 (2/0)"
+ number == 0 && return "0 (1/0)"
+ return string(number)
+end
+
+function awg_sizes(::Type{T}, awg_min::Integer = -3, awg_max::Integer = 40) where {T <: Real}
+ awg_min <= awg_max || throw(ArgumentError("awg_min must not exceed awg_max"))
+ return [(awg_label(number), convert(T, awg_to_d_mm(number)) / T(1000))
+ for number in awg_min:awg_max]
+end
+
+awg_sizes(awg_min::Integer = -3, awg_max::Integer = 40) = awg_sizes(Float64, awg_min, awg_max)
+
+"""
+Apply a fill factor to solid area to approximate stranded metallic area.
+"""
+function stranded_area_mm2(number::Real; fill_factor::Real = 0.94)
+ factor, area = promote(float(fill_factor), float(awg_to_area_mm2(number)))
+ return factor * area
+end
+
+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)
+]
+
+_hex_wires(layers::Int) = 1 + 3layers * (layers - 1)
+
+"""Return wire-count bounds for the requested metal `target_area` \\[mm²\\]."""
+function _allowed_wires(target_area::Real)
+ for (threshold, minimum, maximum) in _WIRE_RULES
+ target_area <= threshold && return minimum, maximum
+ end
+ return 53, nothing
+end
+
+function _allowed_wires(wires::Int, minimum::Int, maximum::Union{Int, Nothing})
+ return maximum === nothing ? wires >= minimum : minimum <= wires <= maximum
+end
diff --git a/src/parametricbuilder/wirepatterns/screened.jl b/src/parametricbuilder/wirepatterns/screened.jl
new file mode 100644
index 000000000..94e94246f
--- /dev/null
+++ b/src/parametricbuilder/wirepatterns/screened.jl
@@ -0,0 +1,183 @@
+function ScreenPattern(
+ wires::Int,
+ diameter::T,
+ lay_diameter::T,
+ sine_angle::T,
+ awg::String
+) where {T <: Real}
+ area = wires * _wire_area(diameter)
+ coverage = T(100) * wires * diameter /
+ (T(pi) * lay_diameter * sine_angle)
+ return ScreenPattern(
+ wires, diameter, lay_diameter, (lay_diameter + diameter) / T(2),
+ area, coverage, awg
+ )
+end
+
+function _screen_feasible(
+ pattern::ScreenPattern,
+ target_area,
+ coverage_min,
+ coverage_max,
+ overshoot_max
+)
+ enough_area = pattern.total_area >= target_area
+ coverage_ok = coverage_min <= pattern.coverage <= coverage_max
+ T = typeof(target_area)
+ overshoot = T(100) * (pattern.total_area / target_area - one(T))
+ overshoot_ok = !isfinite(overshoot_max) || overshoot <= overshoot_max
+ return enough_area && coverage_ok && overshoot_ok
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Estimate single-layer wire-screen patterns.
+
+# Arguments
+
+- `target_area`: required metallic cross-section \\[mm²\\].
+- `lay_diameter`: diameter beneath the wire layer \\[mm\\].
+
+# Keywords
+
+- `lay_angle=15`: wire lay angle \\[degrees\\], with positive sine.
+- `coverage_min=85`, `coverage_max=100`: circumferential coverage bounds \\[%\\].
+- `gap_frac=0`: additional wire clearance as a fraction of wire diameter.
+- `min_wires=6`: minimum wire count.
+- `extra_span=8`: additional wire counts examined above the required count.
+- `awg_min=-3`, `awg_max=40`: inclusive AWG-number limits.
+- `max_area_overshoot=10`: maximum excess metallic area relative to the target \\[%\\].
+- `wire_diameters=[]`: additional wire diameters \\[mm\\].
+
+# Returns
+
+A [`WireEstimate`](@ref) whose stored lengths and areas use \\[m\\] and \\[m²\\].
+
+Geometrically valid patterns must meet the requested area, coverage, and
+overshoot bounds to make the result feasible. Otherwise the returned
+[`WireEstimate`](@ref) contains ranked best-effort patterns and reasons.
+"""
+function estimate_screen(
+ target_area::Real,
+ lay_diameter::Real;
+ lay_angle::Real = 15,
+ coverage_min::Real = 85,
+ gap_frac::Real = 0,
+ min_wires::Integer = 6,
+ extra_span::Integer = 8,
+ awg_min::Integer = -3,
+ awg_max::Integer = 40,
+ coverage_max::Real = 100,
+ max_area_overshoot::Real = 10,
+ wire_diameters::AbstractVector{<:Real} = Float64[]
+)
+ target_area > zero(target_area) || throw(DomainError(
+ target_area, "required cross-section must be positive"
+ ))
+ lay_diameter > zero(lay_diameter) || throw(DomainError(
+ lay_diameter, "laying diameter must be positive"
+ ))
+ zero(coverage_min) < coverage_min <= 100 || throw(DomainError(
+ coverage_min, "minimum coverage must be in (0, 100]"
+ ))
+ coverage_max >= coverage_min || throw(DomainError(
+ coverage_max, "maximum coverage must not be below its minimum"
+ ))
+ max_area_overshoot >= zero(max_area_overshoot) || throw(DomainError(
+ max_area_overshoot, "maximum overshoot must be nonnegative"
+ ))
+ gap_frac >= zero(gap_frac) || throw(DomainError(
+ gap_frac, "gap fraction must be nonnegative"
+ ))
+ min_wires >= 1 || throw(DomainError(min_wires, "minimum wires must be positive"))
+ extra_span >= 0 || throw(DomainError(extra_span, "extra span must be nonnegative"))
+ awg_min <= awg_max || throw(ArgumentError("awg_min must not exceed awg_max"))
+ all(>(0), wire_diameters) || throw(DomainError(
+ wire_diameters, "custom wire diameters must be positive"
+ ))
+
+ custom_types = isempty(wire_diameters) ? () : (eltype(wire_diameters),)
+ T = promote_type(
+ typeof(float(target_area)), typeof(float(lay_diameter)),
+ typeof(float(lay_angle)), typeof(float(coverage_min)),
+ typeof(float(coverage_max)), typeof(float(max_area_overshoot)),
+ typeof(float(gap_frac)), custom_types...
+ )
+ target_area = convert(T, target_area) * T(1e-6)
+ lay_diameter = convert(T, lay_diameter) * T(1e-3)
+ angle = deg2rad(convert(T, lay_angle))
+ sine_angle = sin(angle)
+ sine_angle > zero(T) || throw(DomainError(
+ lay_angle, "lay angle must have a positive sine"
+ ))
+ coverage_min = convert(T, coverage_min)
+ coverage_max = convert(T, coverage_max)
+ overshoot_max = convert(T, max_area_overshoot)
+ gap = convert(T, gap_frac)
+
+ sizes = awg_sizes(T, awg_min, awg_max)
+ append!(sizes,
+ [("custom($(round(diameter; digits=3)) mm)", convert(T, diameter) / T(1000))
+ for diameter in wire_diameters])
+
+ geometric = ScreenPattern{T}[]
+ for (awg, diameter) in sizes
+ area = _wire_area(diameter)
+ required_by_area = ceil(Int, target_area / area)
+ required_by_coverage = ceil(
+ Int, coverage_min * T(pi) * lay_diameter * sine_angle /
+ (T(100) * diameter)
+ )
+ required = max(Int(min_wires), required_by_area, required_by_coverage)
+ lay_radius = (lay_diameter + diameter) / T(2)
+ maximum_wires = maxfill(
+ ScreenPattern, lay_radius, diameter / T(2); gap_frac = gap
+ )
+ upper = min(maximum_wires, required + Int(extra_span))
+ if upper >= min_wires
+ append!(geometric,
+ [ScreenPattern(wires, diameter, lay_diameter, sine_angle, awg)
+ for wires in Int(min_wires):upper])
+ elseif maximum_wires > 0
+ push!(geometric, ScreenPattern(
+ maximum_wires, diameter, lay_diameter, sine_angle, awg
+ ))
+ end
+ end
+ isempty(geometric) && throw(ArgumentError(
+ "the supplied diameters cannot form even one wire on this laying radius",
+ ))
+
+ feasible_patterns = filter(
+ pattern -> _screen_feasible(
+ pattern, target_area, coverage_min, coverage_max, overshoot_max
+ ),
+ geometric
+ )
+ feasible = !isempty(feasible_patterns)
+ patterns = feasible ? feasible_patterns : geometric
+ sort!(patterns;
+ by = pattern -> (
+ abs(pattern.total_area - target_area), pattern.wires,
+ pattern.wire_diameter
+ ))
+
+ reasons = String[]
+ if !feasible
+ maximum_area = Base.maximum(pattern.total_area for pattern in geometric)
+ maximum_coverage = Base.maximum(pattern.coverage for pattern in geometric)
+ maximum_area < target_area && push!(
+ reasons, "available single-layer patterns do not reach the requested area"
+ )
+ maximum_coverage < coverage_min && push!(
+ reasons, "available single-layer patterns do not reach minimum coverage"
+ )
+ isempty(reasons) && push!(
+ reasons, "coverage or overshoot limits reject every geometric pattern"
+ )
+ end
+ return WireEstimate(
+ target_area, patterns, feasible, feasible ? :feasible : :infeasible, reasons
+ )
+end
diff --git a/src/parametricbuilder/wirepatterns/stranded.jl b/src/parametricbuilder/wirepatterns/stranded.jl
new file mode 100644
index 000000000..6ed4bb4c9
--- /dev/null
+++ b/src/parametricbuilder/wirepatterns/stranded.jl
@@ -0,0 +1,94 @@
+"""
+$(TYPEDSIGNATURES)
+
+Estimate hexagonally packed strand patterns for a metallic cross-section.
+
+# Arguments
+
+- `target_area`: required cross-sectional area of metal \\[mm²\\]. Stored areas use \\[m²\\].
+
+# Keywords
+
+- `awg_min=-3`, `awg_max=40`: inclusive AWG-number limits.
+
+# Returns
+
+The returned [`WireEstimate`](@ref) retains ranked patterns, including those
+below the target area. When no pattern reaches that area, the result has
+`status == :infeasible`.
+"""
+function estimate_stranding(target_area::Real; awg_min::Integer = -3, awg_max::Integer = 40)
+ target_area > zero(target_area) || throw(DomainError(
+ target_area, "target cross-section must be positive"
+ ))
+ awg_min <= awg_max || throw(ArgumentError("awg_min must not exceed awg_max"))
+
+ target_value = float(target_area)
+ T = typeof(target_value)
+ target_area = target_value * T(1e-6)
+ minimum, maximum_wires = _allowed_wires(target_value)
+ patterns = HexaPattern{T}[]
+
+ for (awg, diameter) in awg_sizes(T, awg_min, awg_max)
+ area = _wire_area(diameter)
+ for layers in 1:300
+ wires = _hex_wires(layers)
+ _allowed_wires(wires, minimum, maximum_wires) && push!(
+ patterns,
+ HexaPattern(layers, wires, diameter, wires * area, awg)
+ )
+ maximum_wires !== nothing && wires > maximum_wires && break
+ end
+ end
+ isempty(patterns) && throw(ArgumentError(
+ "the AWG range and permitted wire counts produced no patterns",
+ ))
+
+ sort!(patterns;
+ by = pattern -> (
+ abs(pattern.total_area - target_area), pattern.wire_diameter,
+ pattern.layers
+ ))
+ feasible = any(pattern -> pattern.total_area >= target_area, patterns)
+ reasons = feasible ? String[] :
+ [
+ "no permitted strand pattern reaches the requested metallic area",
+ ]
+ estimate = WireEstimate(
+ target_area, patterns, feasible, feasible ? :feasible : :infeasible, reasons
+ )
+ estimate[:closest_area].wires > 271 &&
+ @warn("The closest stranded pattern exceeds 271 wires.",
+ wires=estimate[:closest_area].wires,)
+ return estimate
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Return the maximum number of screen wires that fit on `lay_radius`.
+
+`wire_radius` and `lay_radius` are center-to-center geometric radii in the same
+unit. `gap_frac` adds a fractional clearance between adjacent wires.
+"""
+function maxfill(
+ ::Type{ScreenPattern},
+ lay_radius::Real,
+ wire_radius::Real;
+ gap_frac::Real = 0
+)
+ lay_radius > zero(lay_radius) || throw(DomainError(
+ lay_radius, "lay radius must be positive"
+ ))
+ wire_radius > zero(wire_radius) || throw(DomainError(
+ wire_radius, "wire radius must be positive"
+ ))
+ gap_frac >= zero(gap_frac) || throw(DomainError(
+ gap_frac, "gap fraction must be nonnegative"
+ ))
+ ratio = wire_radius * (one(gap_frac) + gap_frac) / lay_radius
+ nominal_ratio = float(nominal(ratio))
+ zero(nominal_ratio) < nominal_ratio < one(nominal_ratio) || return 0
+ count = pi / asin(nominal_ratio)
+ return max(0, floor(Int, count + 8eps(count)))
+end
diff --git a/src/parametricbuilder/wirepatterns/types.jl b/src/parametricbuilder/wirepatterns/types.jl
new file mode 100644
index 000000000..e3347985e
--- /dev/null
+++ b/src/parametricbuilder/wirepatterns/types.jl
@@ -0,0 +1,123 @@
+"""
+$(TYPEDEF)
+
+A concentric, hexagonally packed strand pattern.
+
+$(TYPEDFIELDS)
+"""
+struct HexaPattern{T <: Real}
+ "Number of concentric wire layers, including the central wire."
+ layers::Int
+ "Total wire count."
+ wires::Int
+ "Diameter of each wire \\[m\\]."
+ wire_diameter::T
+ "Total metallic cross-section \\[m²\\]."
+ total_area::T
+ "AWG size label."
+ awg::String
+end
+
+"""
+$(TYPEDEF)
+
+A single-layer wire-screen pattern.
+
+$(TYPEDFIELDS)
+"""
+struct ScreenPattern{T <: Real}
+ "Wire count."
+ wires::Int
+ "Diameter of each wire \\[m\\]."
+ wire_diameter::T
+ "Diameter beneath the wire layer \\[m\\]."
+ lay_diameter::T
+ "Radius to the wire centers \\[m\\]."
+ radius::T
+ "Total metallic cross-section \\[m²\\]."
+ total_area::T
+ "Circumferential coverage \\[%\\]."
+ coverage::T
+ "AWG size or supplied-diameter label."
+ awg::String
+end
+
+"""
+$(TYPEDEF)
+
+Store ranked patterns from a wire-pattern search.
+
+For wire stranding, `feasible` means at least one pattern reaches the target metal
+area. The ranked list can also contain undersized patterns. For screening, it
+means at least one pattern satisfies the area, coverage and overshoot bounds,
+and only those feasible patterns are retained. When none is feasible, the
+geometrically valid alternatives remain ranked and `reasons` describes the limits.
+Use `estimate[:closest_area]`, `estimate[:fewest_layers]`, `estimate[:fewest_wires]`, or
+`estimate[:smallest_diameter]` to select a pattern.
+
+$(TYPEDFIELDS)
+"""
+struct WireEstimate{T <: Real, P}
+ "Requested metallic cross-section \\[m²\\]."
+ target_area::T
+ "Ranked wire patterns."
+ patterns::Vector{P}
+ "Whether at least one pattern meets the estimator's feasibility conditions."
+ feasible::Bool
+ "Feasibility label, either `:feasible` or `:infeasible`."
+ status::Symbol
+ "Explanations for unmet constraints."
+ reasons::Vector{String}
+
+ function WireEstimate(
+ target_area::T,
+ patterns::Vector{P},
+ feasible::Bool,
+ status::Symbol,
+ reasons::Vector{String}
+ ) where {T <: Real, P}
+ status in (:feasible, :infeasible) || throw(ArgumentError(
+ "wire-estimate status must be :feasible or :infeasible",
+ ))
+ feasible == (status === :feasible) || throw(ArgumentError(
+ "wire-estimate feasibility and status disagree",
+ ))
+ isempty(patterns) && throw(ArgumentError(
+ "a wire estimate must retain at least one pattern",
+ ))
+ return new{T, P}(target_area, patterns, feasible, status, reasons)
+ end
+end
+
+Base.length(estimate::WireEstimate) = length(estimate.patterns)
+Base.iterate(estimate::WireEstimate, state...) = iterate(estimate.patterns, state...)
+
+_area(pattern::Union{HexaPattern, ScreenPattern}) = pattern.total_area
+_diameter(pattern::Union{HexaPattern, ScreenPattern}) = pattern.wire_diameter
+_selected(estimate::WireEstimate, key) = argmin(key, estimate.patterns)
+
+Base.getindex(estimate::WireEstimate, selector::Symbol) = estimate[Val(selector)]
+
+function Base.getindex(estimate::WireEstimate, ::Val{:closest_area})
+ _selected(estimate, pattern -> (abs(_area(pattern) - estimate.target_area),
+ _diameter(pattern)))
+end
+function Base.getindex(estimate::WireEstimate{<:Real, <:HexaPattern}, ::Val{:fewest_layers})
+ _selected(estimate, pattern -> (pattern.layers,
+ abs(_area(pattern) - estimate.target_area), _diameter(pattern)))
+end
+function Base.getindex(estimate::WireEstimate, ::Val{:fewest_wires})
+ _selected(estimate, pattern -> (pattern.wires,
+ abs(_area(pattern) - estimate.target_area), _diameter(pattern)))
+end
+function Base.getindex(estimate::WireEstimate, ::Val{:smallest_diameter})
+ _selected(estimate, pattern -> (_diameter(pattern),
+ abs(_area(pattern) - estimate.target_area), pattern.wires))
+end
+
+function Base.getindex(::WireEstimate, ::Val{selector}) where {selector}
+ throw(ArgumentError(
+ "unknown wire-estimate selector :$selector; use :closest_area, :fewest_layers, " *
+ ":fewest_wires, or :smallest_diameter",
+ ))
+end
diff --git a/src/performance.jl b/src/performance.jl
new file mode 100644
index 000000000..76e05b746
--- /dev/null
+++ b/src/performance.jl
@@ -0,0 +1,83 @@
+"""
+$(TYPEDSIGNATURES)
+
+Repeat a caller-supplied computation and collect its retained scan measurements.
+The callable must request `timing=true` through ordinary computation options.
+No additional stopwatch surrounds the callable.
+
+# Arguments
+
+- `f`: zero-argument callable returning line parameters, an ordinary batch,
+ a parametric or linear-error result, or a Monte Carlo result.
+
+# Keywords
+
+- `samples=1`: positive number of measured repetitions. Boolean values are rejected.
+- `warmup=0`: nonnegative number of discarded repetitions. Boolean values are rejected.
+
+# Returns
+
+- `(; result, timings)`: the last scientific result and detached timing payloads
+ for each measured repetition. Batches and parametric and linear-error results keep
+ their value order. Monte Carlo measurements keep population and trial order.
+ Reused results retain empty timing records.
+
+# Notes
+
+Owned scans retain wall, GC, compilation, and recompilation durations in seconds,
+and Julia allocation volume in bytes. Native scans retain caller wall time and
+their backend measurements in seconds. These describe complete frequency scans,
+excluding shared input construction, measurement attachment, and callbacks.
+The callable controls verbosity, seeds, callbacks, and reuse. Options remain unchanged.
+
+# Errors
+
+- `ArgumentError`: invalid counts, unsupported results or absent requested timing.
+- Exceptions from `f` propagate immediately without retry.
+
+# Examples
+
+```julia
+measurement = LineCableModels.benchmark(; samples=3, warmup=1) do
+ compute(problem, formulation; options=(timing=true, verbosity=(default=0,)))
+end
+```
+"""
+function benchmark(f; samples = 1, warmup = 0)
+ samples isa Integer && !(samples isa Bool) && 0 < samples <= typemax(Int) ||
+ throw(ArgumentError("samples must be a positive integer representable as Int"))
+ warmup isa Integer && !(warmup isa Bool) && 0 <= warmup <= typemax(Int) ||
+ throw(ArgumentError("warmup must be a nonnegative integer representable as Int"))
+ for _ in 1:warmup
+ f()
+ end
+ # Projection consumes completed outputs only. It never visits input spaces.
+ function measurement(value)
+ if value isa Union{LineParameters, MonteCarloResult}
+ haskey(details(value).data, :timing) || throw(ArgumentError(
+ "benchmark requires timing=true in the computation options"))
+ recorded = details(value).data.timing
+ return value isa MonteCarloResult ?
+ [NamedTuple[deepcopy(record) for record in population]
+ for population in recorded] : deepcopy(recorded)
+ elseif value isa
+ Union{AbstractVector{<:LineParameters}, ParametricResult, LinearErrorResult}
+ isempty(value) &&
+ throw(ArgumentError("benchmark requires nonempty timing=true results"))
+ return NamedTuple[measurement(core) for core in value]
+ end
+ throw(ArgumentError("benchmark requires supported line-parameter results with timing=true; got $(typeof(value))"))
+ end
+ result = f()
+ first_timing = measurement(result)
+ timings = first_timing isa NamedTuple ? Vector{NamedTuple}(undef, samples) :
+ Vector{typeof(first_timing)}(undef, samples)
+ timings[1] = first_timing
+ for index in 2:samples
+ # Drop the previous large result before starting another repetition.
+ result = nothing
+ result = f()
+ timings[index] = measurement(result)
+ end
+ return (; result, timings)
+end
diff --git a/src/plotbuilder/PlotBuilder.jl b/src/plotbuilder/PlotBuilder.jl
index f09b89755..118b8a8f0 100644
--- a/src/plotbuilder/PlotBuilder.jl
+++ b/src/plotbuilder/PlotBuilder.jl
@@ -1,62 +1,20 @@
-module PlotBuilder
-
-using Base: @kwdef
-
-import ..UnitHandler: Units, QuantityTag, get_label, get_symbol, display_unit, scale_factor
-import ..Commons: PhaseDomain, ModalDomain, domain
-
-export make_render, RenderSpec
-
-# Submodule `BackendHandler`
-include("backendhandler/BackendHandler.jl")
-using .BackendHandler
-
-# Submodule `PlotUIComponents` - WILL BE DEPRECATED SOON
-include("plotuicomponents/PlotUIComponents.jl")
-using .PlotUIComponents
-
-include("types.jl")
-include("traits.jl")
-include("axisspec.jl")
-include("parse.jl")
-include("seriesspec.jl")
-include("viewspec.jl")
-include("pagespec.jl")
-
-# Submodule `UIComponents`
-include("uicomponents/UIComponents.jl")
-
-include("plotspecs.jl")
-
-
-
"""
- make_render(::Type{S}, obj; kwargs...) where {S<:AbstractPlotSpec}
+ PlotBuilder
-High-level API: from domain object + keyword arguments to a RenderSpec.
-
-Checks that the object type is compatible with `dispatch_on(S)` and then
-runs:
-
- parse_kwargs(S, obj; kwargs...) → raw
- resolve_input(S, raw) → nt
- make_pages(S, nt) → figs
-
-`make_pages` returns a vector of PageSpec values; `make_render`
-wraps them into a RenderSpec that the UI layer will later assemble into
-actual windows/layouts.
+Provide plotting functions and live figure handles for the Makie extension.
+Load a Makie backend to draw figures. Returned handles expose the Makie objects
+for further editing.
"""
+module PlotBuilder
-function make_render(::Type{S}, obj; kwargs...) where {S <: AbstractPlotSpec}
- Tdispatch = dispatch_on(S)
- obj isa Tdispatch ||
- Base.error("Spec $(S) cannot dispatch on $(typeof(obj)); expected $(Tdispatch)")
+using DocStringExtensions: TYPEDEF, TYPEDFIELDS
- raw = parse_kwargs(S, obj; kwargs...)
- norm = resolve_input(S, raw)
- pags = make_pages(S, norm) # ::Vector{PageSpec}
+export UIPlot, plot, preview, show_material_scale, export_svg
+export figurelegend!, panellegend!, figuretitle!, paneltitle!
+export figurecolorbars!, axisscale!, resetview!, addwidget!, removewidget!
+export plotwindow, materialcolors, materialscale!
- return RenderSpec(S, pags)
-end
+include("handle.jl")
+include("interfaces.jl")
end # module PlotBuilder
diff --git a/src/plotbuilder/axisspec.jl b/src/plotbuilder/axisspec.jl
deleted file mode 100644
index ef507132f..000000000
--- a/src/plotbuilder/axisspec.jl
+++ /dev/null
@@ -1,83 +0,0 @@
-
-"""
- make_axes(::Type{S}, nt::NamedTuple) where {S<:AbstractPlotSpec}
-
-Build axes for spec `S` using quantity tags stored in `nt` as
-fields `x_quantity`, `y_quantity`, `z_quantity` (when applicable).
-
-Returns:
- (xaxis = AxisSpec or nothing,
- yaxis = AxisSpec or nothing,
- zaxis = AxisSpec or nothing)
-"""
-function make_axes(::Type{S}, nt::NamedTuple) where {S <: AbstractPlotSpec}
- dims = geom_axes(S)
-
- xaxis = nothing
- yaxis = nothing
- zaxis = nothing
-
- for dim in dims
- qfield = Symbol(dim, :_quantity) # :x_quantity, :y_quantity, :z_quantity
- q = getproperty(nt, qfield) # expected to be a QuantityTag
-
- u = axis_unit(S, q, dim)
- ax = make_axis(S, dim, q, u)
-
- if dim === :x
- xaxis = ax
- elseif dim === :y
- yaxis = ax
- elseif dim === :z
- zaxis = ax
- else
- Base.error("Unsupported axis dim $(dim) in geom_axes for $(S)")
- end
- end
-
- return (xaxis = xaxis, yaxis = yaxis, zaxis = zaxis)
-end
-
-"""
-Build the final axis label from quantity + units.
-
-Default pattern: "Label [symbol]" if units are non-empty.
-Uses `get_label(q::QuantityTag)` and `get_label(u::Units)` from UnitHandler.
-"""
-function axis_label(q::QuantityTag, u::Units)
- base = get_label(q) # human-readable quantity name
- usym = get_label(u) # unit symbol string, e.g. "Ω/km"
- return isempty(usym) ? base : string(base, " [", usym, "]")
-end
-
-"""
-Build a AxisSpec for spec `S`, axis dim `dim`, quantity `q`, units `u`.
-"""
-function make_axis(::Type{S},
- dim::Symbol,
- q::QuantityTag,
- u::Units) where {S <: AbstractPlotSpec}
-
- lab = axis_label(q, u)
- dims = enable_logscale(S)
- sc = dim in dims ? :log10 : :linear
- return AxisSpec(dim, q, u, lab, sc)
-end
-
-# High-level builder: dim only (static semantics)
-function make_axis(::Type{S}, ::Val{dim}) where {S <: AbstractPlotSpec, dim}
- q = axis_quantity(S, Val(dim))
- u = axis_unit(S, q, dim)
- return make_axis(S, dim, q, u)
-end
-
-# High-level builder: dim + semantic code (e.g. :R/:L/:C/:G)
-function make_axis(
- ::Type{S},
- ::Val{dim},
- ::Val{qty},
-) where {S <: AbstractPlotSpec, dim, qty}
- q = axis_quantity(S, Val(dim), Val(qty))
- u = axis_unit(S, q, dim)
- return make_axis(S, dim, q, u)
-end
diff --git a/src/plotbuilder/backendhandler/BackendHandler.jl b/src/plotbuilder/backendhandler/BackendHandler.jl
deleted file mode 100644
index 3d9765f7e..000000000
--- a/src/plotbuilder/backendhandler/BackendHandler.jl
+++ /dev/null
@@ -1,241 +0,0 @@
-"""
-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!,
- ensure_backend!, current_backend_symbol, renderfig, with_backend, make_screen
-
-# ---------------------------------------------------------------------------
-# 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
-
-"""
- with_backend(f, backend; force=false)
-
-Temporarily activate `backend`, run `f()`, then restore the previous backend.
-
-Intended for export workflows (switch to CairoMakie, render/save, restore).
-Restoration is best-effort: if the previous backend can’t be determined, it’s skipped.
-"""
-function with_backend(f::Function, backend::Symbol; force::Bool = false)
- prev = current_backend_symbol()
- prev_ok =
- prev in (:cairo, :gl, :wgl) ? prev :
- (_active_backend[] in (:cairo, :gl, :wgl) ? _active_backend[] : :none)
-
- ensure_backend!(backend)
-
- try
- return f()
- finally
- if prev_ok != :none && prev_ok != current_backend_symbol()
- try
- set_backend!(prev_ok; force = force)
- catch e
- @warn "Failed to restore backend $(prev_ok)" exception=(
- e,
- catch_backtrace(),
- )
- end
- end
- end
-end
-
-"""
- make_screen(title; backend=current_backend_symbol(), kwargs...) -> screen | nothing
-
-Create a backend-specific screen/window handle for interactive display.
-
-Currently:
-- `:gl` returns `GLMakie.Screen(; title=...)` (loaded lazily).
-- other backends return `nothing`.
-"""
-function make_screen(title::AbstractString;
- backend::Symbol = current_backend_symbol(),
- kwargs...,
-)
- backend == :gl || return nothing
- mod = Base.require(_pkgid(:gl))
- ctor = getproperty(mod, :Screen)
- return Base.invokelatest(ctor; title = String(title), kwargs...)
-end
-
-"""
- make_screen(backend, title; kwargs...) -> screen | nothing
-
-Convenience overload.
-"""
-make_screen(backend::Symbol, title::AbstractString; kwargs...) =
- make_screen(title; backend = backend, kwargs...)
-
-
-
-"""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)
-
-# Backend activation is deliberately lazy. Activating CairoMakie from `__init__`
-# evaluates into Makie's module while packages are restoring on Julia 1.12,
-# which breaks incremental compilation. Public rendering entry points already
-# call `ensure_backend!` before using a backend.
-
-end # module
diff --git a/src/plotbuilder/handle.jl b/src/plotbuilder/handle.jl
new file mode 100644
index 000000000..0874df98d
--- /dev/null
+++ b/src/plotbuilder/handle.jl
@@ -0,0 +1,81 @@
+"""
+$(TYPEDEF)
+
+Return the small handle assembled around a native Makie figure by a
+LineCableModels plotting call.
+
+`figure`, `title`, every element of `axes`, every value in `controls`, `legend`,
+every value in `panel_legends`, and every element of `colorbars` are the live
+Makie objects used for display. They remain owned by the caller: mutating them
+or adding native Makie plots changes the displayed figure and the state saved
+by [`export_svg`](@ref).
+
+The remaining fields are private addon state and export defaults.
+
+$(TYPEDFIELDS)
+"""
+mutable struct UIPlot{F, C}
+ "Native Makie figure owned by the caller."
+ figure::F
+ "Figure-wide native Makie title label, or `nothing`."
+ title::Any
+ "Native Makie axes in display order."
+ axes::Vector{Any}
+ "Native Makie controls keyed by their public purpose."
+ controls::C
+ "Native Makie legend, or `nothing`."
+ legend::Any
+ "Panel-scoped native Makie legends keyed by logical panel identity."
+ panel_legends::Dict{Any, Any}
+ "Native Makie colorbars in display order."
+ colorbars::Vector{Any}
+ "Status observable shared with the native shell and managed callbacks."
+ status::Any
+ "Private Makie objects and callback subscriptions, with presentation state."
+ plot_state::Any
+ "Default base filename used by [`export_svg`](@ref)."
+ export_name::String
+ "Default SVG theme."
+ export_theme::Symbol
+ "Whether toolbar exports should be opened after writing."
+ open_export::Bool
+end
+
+function UIPlot(
+ figure,
+ axes;
+ title = nothing,
+ controls = Dict{Symbol, Any}(),
+ legend = nothing,
+ panel_legends = Dict{Any, Any}(),
+ colorbars = (),
+ status = nothing,
+ plot_state = nothing,
+ export_name::AbstractString = "linecablemodels_plot",
+ export_theme::Symbol = :default,
+ open_export::Bool = true
+)
+ export_theme in (:default, :publication) || throw(ArgumentError(
+ "export_theme must be :default or :publication",
+ ))
+ isempty(strip(export_name)) && throw(ArgumentError("export_name cannot be empty"))
+ panel_legends isa AbstractDict || throw(ArgumentError(
+ "panel_legends must be a dictionary keyed by logical panel identity",
+ ))
+ return UIPlot(
+ figure,
+ title,
+ Any[axes...],
+ controls,
+ legend,
+ Dict{Any, Any}(panel_legends),
+ Any[colorbars...],
+ status,
+ plot_state,
+ String(export_name),
+ export_theme,
+ open_export
+ )
+end
+
+Base.summary(io::IO, ::UIPlot) = print(io, "Native Makie plot")
diff --git a/src/plotbuilder/interfaces.jl b/src/plotbuilder/interfaces.jl
new file mode 100644
index 000000000..f6c942733
--- /dev/null
+++ b/src/plotbuilder/interfaces.jl
@@ -0,0 +1,402 @@
+"""
+ plot(observed::ObservedResult, selection=nothing; ydata=nothing, kwargs...)
+ plot(observed::AbstractVector{<:ObservedResult}, selection=nothing; ydata=nothing, kwargs...)
+ plot(completed_result, selection=nothing; ydata=nothing, kwargs...)
+
+Present retained scientific quantities with a loaded Makie backend. Completed
+numerical results are conveniences: they construct `ObservedResult` objects and
+call the same public observed-input method. `PlotBuilder.plot` and
+`LineCableModels.plot` are the same function.
+
+`selection` and `ydata` are alternative spellings of the same request. Supplying
+both is an error. Requests retain `@observe` syntax and original coordinates.
+An existing observation supplies values, units, descriptions, uncertainty, and
+scientific groups.
+
+# Selection and acquisition
+
+Raw conveniences accept line, series, shunt, cable-constant, parametric, and UQ
+results, ordinary supported tuples or vectors, named collections, and report
+artifacts. Standalone series and shunt inputs also accept a frequency vector before
+the selection. Raw references become separate atomic observations. Report
+artifacts forward their observed results and their observed reference.
+
+Raw-only acquisition keywords are `clip`, `atol`, and `frequencies`. Single
+primary requests use the observation owner's pair completion. Statistical
+products do not trigger it. Observed inputs reject new clipping decisions,
+thresholds, or replacement sample coordinates.
+
+`units`, `length_unit`, `quantity_units`, and `frequency_unit` also apply to
+retained inputs, including reports. The existing `ObservedResult(existing; ...)`
+operation re-expresses compatible units once before drawing. Omitted options
+preserve recorded units, masks, errors, timings, and uncertainty dependencies.
+`freq_unit` is a spelling of `frequency_unit`. Supplying both is an error.
+`plot(report; ydata=(R,), length_unit=:base)` displays its retained
+result and reference curves per meter without rebuilding the report.
+
+`problem` and `formulations` select original recorded identities. `band` selects
+saved comparison samples through the observation owner, retaining each trace's
+own coordinates and reference association.
+
+`overlay=:auto` chooses the overlaid dimension after filtering and before
+equivalent-result grouping. One modal vector uses selected modes as curves
+when `layout` is omitted or `(1,1)`. A larger explicit layout or several
+gridpoints uses mode panels and gridpoint curves. Matrices use coefficient
+panels and gridpoint curves. `overlay=:gridpoints` always uses selected
+coordinates as panels. `overlay=:coordinates` uses one panel per selected
+gridpoint, with physical coordinates as curves. `overlay=:rows` requires matrix
+quantities: selected columns become panels and selected rows become curves.
+For Tv/Ti, panel titles identify modes and legends identify conductors. Each
+gridpoint, including a reference or an equivalent result, gets separate figures.
+Columns flow left to right, then top to bottom, restarting pagination for each
+point. Figure titles use compact gridpoint descriptions. A reference counts as a
+selected gridpoint. Positional `series_labels` and `series_attributes` address
+the overlaid dimension. Mixed figure families with incompatible positional
+lengths require separate calls.
+
+# Layout and native presentation
+
+- `layout=nothing` resolves nominal panel capacity for each figure family:
+ its selected matrix row and column span, or a near-square flow arrangement.
+ An explicit `(rows, columns)` supplies a positive nominal capacity. With matrix
+ gridpoint overlays, automatic pages start at their selected minimum coordinate.
+ Explicit layouts anchor block membership at original coordinate `(1,1)`.
+ Empty exterior tracks are removed. Internal selection holes and original
+ coefficient identities remain.
+- Each quantity and statistical meaning has separate figure families. With matrix
+ gridpoint overlays, a full 3×3 matrix at `layout=(2,2)` has four pages with
+ extents `(2,2)`, `(2,1)`, `(1,2)`, `(1,1)` per quantity. `layout=(1,1)` produces
+ nine pages per quantity.
+ Explicit diagonal products paginate compactly with original `(i,i)` identities.
+ With `overlay=:rows`, automatic capacity uses the selected column count per
+ gridpoint. An explicit layout is the shared block capacity for every point.
+ Each page contains columns from one point. Panel addresses
+ are `(original_point_position, original_column)`, with a reference appended
+ after the input points. Row styles retain original row identities across pages
+ and points. Positional labels and attributes follow the selected row order.
+- `fig_size` is the initial reference size of the complete nominal capacity.
+ `figure=(size=...,)` takes precedence. Managed figures fit their decorated
+ occupied content. Residual pages retain the same initial data-frame dimensions,
+ including when no full-capacity page is emitted. Native resizing remains local.
+- Assembly products use categorical points and uncertainty intervals, with a
+ first-seen union of recorded assembly names. Scalars/vectors without physical
+ coordinates use honest element-index views. Full modal matrices retain every
+ selected coefficient, including zero and residual off-diagonals.
+- `series_labels`, `reference`, and `series_attributes` control the overlaid
+ trace identity and native appearance. Attributes accept one NamedTuple or an
+ aligned tuple or vector.
+ Result slots are assigned before filtering and adding a reference. References default to black solid curves and hollow circles.
+- `errorbar_sampling` defaults to `:staggered` for multiple displayed series and
+ `:all` for one. Full uncertainty support controls limits. Explicit curve
+ markers and categorical or scalar points use every original sample.
+- `xscale`, `yscale`, `xlabel`, `ylabel`, native limits, ticks and formatters, `axis=(;)`,
+ and `figure=(;)` configure native objects. Constructor groups beat shared
+ attributes. Per-series overrides beat shared series settings. Later native
+ edits retain authority. Unknown attributes fail with a diagnostic.
+- Frequency X defaults to adaptive `:log10`. Other numeric dimensions default
+ to linear. Adaptive log uses stable signed log when visible support or bounds
+ contain zero or negative values. Its reference magnitude is the smallest finite
+ nonzero magnitude in eligible samples, enabled uncertainty endpoints and
+ explicit bounds, in displayed units (1 if none exist). The reference remains fixed during zooming. Reapplying `:log10` selects it from current data.
+ The native `log10` function remains strict.
+ Categorical axes retain native conversion and have no X-log toggle.
+- Titles use `title`, `title_prefix`, `figure_title`, `title_attributes`, and
+ `panel_titles`. Positional panel titles bind before pagination. Dictionary and
+ function selections retain original panel identities.
+- Legends use `legend_position`, `legend_title`, `legend_attributes`,
+ `legend_cap`, and `panel_legends`. Multiple/explicitly labelled result
+ series default to a bottom legend. One unlabelled series has none.
+ `legend_cap=0.5` accepts a finite real number excluding Boolean values in `(0,1]`.
+ It caps top and bottom legend height or side legend width relative to the
+ associated data area. The other dimension also fits within that area.
+ An inside legend uses the height cap. Excess entries are replaced by `(...)`
+ and restored when space returns. Every curve remains plotted. If even the
+ ellipsis and title cannot fit, the legend is hidden until space returns.
+- `colorbar_position`, `colorbar_group_attributes`, `colorbar_attributes`, and
+ `guide_gap=8` place existing scale content.
+ Native `halign`/`valign` supply symbolic or fractional alignment.
+- `guide_spacing=12` sets minimum spacing between neighboring complete guides
+ in logical pixels. A `(rowgap=..., colgap=...)` NamedTuple controls each
+ direction. Omitted constructor components default to 12. This is independent
+ of plot-to-guide `guide_gap`, figure padding, and legend-entry spacing.
+- `colorbar_group_attributes=(layout=nothing, rowgap=nothing, colgap=nothing)`
+ controls the scale group's cells and minimum internal gaps. Explicit positive
+ integer capacity fills row-major. Unused tracks are omitted. Automatic layout
+ is one row at top and bottom and one column at left and right, explicit guide slots,
+ or standalone main content. Gaps inherit `guide_spacing` when omitted or
+ `nothing`. `colorbar_attributes.vertical` orients each bar independently.
+- `backend`, `display_plot=true`, `controls=true`, `widgets=()`,
+ `export_theme=:default`, and `open_export=true` control display and UI behavior.
+ Widget callables receive the final live handle once per figure.
+ [`axisscale!`](@ref) and [`resetview!`](@ref) are also available with controls hidden.
+
+Numerical axes share size-aware ticks, engineering multipliers, and relative
+near-constant padding. Scale changes preflight the complete page and preserve
+orthogonal views and configured bounds. Rendering uses the values, uncertainty,
+and availability recorded by the observation owner.
+Changes to guides, titles or widgets refit the affected outer window around its current
+frames. Closing a display window leaves its retained handle reusable.
+
+# Returns
+
+One [`UIPlot`](@ref), or an ordinary `Vector{UIPlot}` for multiple figures. Its
+figure, axes, guides, controls, and status remain live native objects.
+
+# Examples
+
+```julia
+using LineCableModels
+using LineCableModels: plot
+using CairoMakie
+frequency = [1.0, 10.0, 100.0]
+impedance = reshape(complex.([1.0, 2.0, 3.0], [2.0, 3.0, 4.0]), 1, 1, :)
+raw = LineParameters(impedance, impedance .* 1e-6, frequency)
+r_request = @observe R[:, :, :]
+pair = (@observe(R[:, :, :]), @observe(L[:, :, :]))
+a = plot(raw; ydata=(r_request,), clip=true, length_unit=:kilo)
+o = ObservedResult(raw, (r_request,); complete_pairs=true,
+ clip=true, length_unit=:kilo)
+b = plot(o; ydata=(r_request,), length_unit=:base)
+c = plot(raw; ydata=pair, layout=(1,1))
+d = plot(ObservedResult(raw, pair); ydata=pair, layout=(1,1))
+```
+
+`c` and `d` each contain separate R and L figures.
+"""
+function plot end
+
+function plot(args...; kwargs...)
+ throw(ArgumentError(
+ "Plotting is optional. Load CairoMakie, GLMakie, or WGLMakie before calling plot.",
+ ))
+end
+
+"""
+ preview(source; kwargs...)
+
+Preview a cable design, a collection of cable designs, or a cable system with
+a loaded Makie backend.
+
+# Keywords
+
+- `display_dielectric_pattern=true`: fill insulating regions with sparse
+ diagonal marks over their material color. Applies to all three preview routes.
+- `earth_model=nothing`: static earth model for a system preview. Horizontal
+ strata retain their physical depths while their visible coverage follows the
+ axis view. Vertical strata are not rendered.
+- `display_surface_gradient=true`: for a system with horizontal earth, add a light blue sky. Its color is strongest at the upper axis limit and fades toward the white or transparent background at the earth surface `z=0` \\[m\\]. The fade stretches
+ with the view and is hidden when the view lies entirely underground. This decoration is independent of material properties.
+- `zoom_factor=nothing`: initial system-view span multiplier. The reset
+ control restores that initial view.
+- `colorbar_position=:bottom`: place the material scales in one horizontal
+ strip below the preview. Horizontal bars keep their property labels on the
+ left, independently of group placement.
+- `guide_gap=(8,8,24,8)`: clearance in logical pixels between plot decorations
+ and guides, ordered left, right, bottom, top. A scalar sets all four sides.
+- `colorbar_group_attributes`: group `layout`, `rowgap`, and `colgap`, with
+ native group alignment and explicit outer `margin`. Bar orientation and
+ dimensions belong to `colorbar_attributes`. See [`plot`](@ref) for the shared
+ placement rules and sibling spacing through `guide_spacing=12`.
+
+# Returns
+
+- One [`UIPlot`](@ref), or an ordinary vector when a collection exceeds `layout`
+ capacity. Collection panels retain original integer indices. Material ranges
+ are shared across pages. `size` supplies the initial reference dimensions.
+ `figure.size` takes precedence. The finished window fits the decorated panels
+ while preserving physical aspect, limits, and each panel's established frame.
+ Heterogeneous panels may retain necessary internal row and column space.
+
+# Notes
+
+Material colors retain nominal physical properties. Magnetic tint progresses
+from indigo to magenta on a logarithmic relative-permeability range. Earth uses
+its own logarithmic resistivity palette. Dielectric marks use native Makie
+pattern tiles, including in SVG/PDF exports.
+"""
+function preview end
+
+function preview(args...; kwargs...)
+ throw(ArgumentError(
+ "Plotting is optional. Load CairoMakie, GLMakie, or WGLMakie before calling preview.",
+ ))
+end
+
+"""
+ show_material_scale(; kwargs...)
+
+Display the three independently defined material color schemes as a compact
+reference figure. Use [`materialscale!`](@ref) to place any selected scheme in a
+caller-owned Makie layout. Main-content placement defaults to one column,
+independently of bar orientation. The shared `colorbar_group_attributes` and
+`guide_spacing` options also apply here.
+"""
+function show_material_scale end
+
+function show_material_scale(args...; kwargs...)
+ throw(ArgumentError(
+ "Plotting is optional. Load CairoMakie, GLMakie, or WGLMakie before calling show_material_scale.",
+ ))
+end
+
+"""
+ export_svg(plot::UIPlot; path=nothing, theme=nothing, open_file=nothing)
+
+Save the current live Makie figure in `plot` as SVG through CairoMakie and
+return the absolute output path. `theme` may be `:default` or `:publication`.
+The SVG retains the current zoom and pan without resetting the interactive view.
+
+Load CairoMakie explicitly before exporting. For an interactive GLMakie window
+with SVG export, import both backends and select `backend=:gl` when plotting.
+The SVG button is created only when CairoMakie is already loaded. Loading it
+later enables this function on existing plots. Recreate a plot to add its button.
+Export preserves the active backend and restores the live figure state. The toolbar displays file errors in the window's status row. Direct calls throw the
+corresponding exception. An unloaded CairoMakie renderer raises `ArgumentError`
+before filesystem or figure changes.
+"""
+function export_svg end
+
+"""
+ figurelegend!(plot::UIPlot; position, title, max_fraction, legend_labels, kwargs...)
+
+Update the figure legend from the shell's native series groups. Omitted options
+preserve current state. `position=nothing` detaches and hides the guide. Native
+`halign`, `valign`, margins, and style attributes remain editable. Removal and
+restoration retain native styles and series visibility. `max_fraction` uses
+the same `(0,1]` size limit as `plot(...; legend_cap=0.5)` and defaults
+to `0.5` when creating a guide. Figure legends use the combined panel footprint.
+
+`guide_spacing` updates the shared figure-wide minimum sibling spacing. A scalar
+sets both directions. A partial `(rowgap=..., colgap=...)` update preserves the
+other current component. Values are finite, nonnegative, non-Boolean real
+numbers in logical pixels. Existing panel legends inherit this setting.
+"""
+function figurelegend! end
+
+"""
+ panellegend!(plot::UIPlot, panel; kwargs...)
+
+Create or replace a native Makie legend scoped to one logical plot panel.
+`panel` may be the stable panel identity returned by a recipe or its compatible
+grid position. `max_fraction` bounds this legend against its own panel data
+area. It inherits figure-wide `guide_spacing`. No per-panel spacing
+override is accepted.
+"""
+function panellegend! end
+
+"""
+ figuretitle!(plot::UIPlot, title; kwargs...)
+
+Create, replace, or remove the figure-wide native Makie title. Pass `nothing`
+to remove it.
+"""
+function figuretitle! end
+
+"""
+ paneltitle!(plot::UIPlot, panel, title)
+
+Set the native axis title for one logical plot panel. Pass `nothing` to clear
+it.
+"""
+function paneltitle! end
+
+"""
+ plotwindow(callback; title, figure_title=nothing, size=(800, 400), kwargs...)
+
+Build the standard Makie shell, pass its caller-owned content `GridLayout` to
+`callback`, and return a [`UIPlot`](@ref). The callback uses ordinary Makie and
+is not constrained by a renderer-independent plot specification.
+Numeric axes share scale, reset and export controls. Native `axis=(...)`
+attributes explicitly override callback-created axes. Otherwise their native
+construction settings are retained. Shared series and `figure=(...)` attributes
+follow the same rules as [`plot`](@ref).
+"""
+function plotwindow end
+
+"""
+ materialcolors(property, [range]; alpha=1.0)
+
+Construct one reusable material color scheme. Palette selection is separate
+from [`materialscale!`](@ref), which only renders a supplied scheme.
+"""
+function materialcolors end
+
+"""
+ materialscale!(position, scheme; kwargs...)
+
+Place one native Makie color scale at `position`. `scheme` supplies one label,
+colormap, limits, and tick definition.
+"""
+function materialscale! end
+
+"""
+ axisscale!(p::UIPlot, dimension::Symbol, scale; panel=nothing)
+
+Set one displayed coordinate scale on all eligible numeric axes, or on the
+original identity selected by `panel`. `:linear` selects the identity scale.
+`:log10` adapts to signed support, while the native `log10` function requires
+strictly positive support. `:pseudolog10` explicitly selects stable signed log.
+Adaptive signed log maps `v` to `sign(v)*log10(1+abs(v)/s)`, where `s` is the
+smallest finite nonzero magnitude in eligible visible samples, enabled
+uncertainty endpoints and explicit bounds, in displayed units. `s=1` when
+none exist. Explicit `:pseudolog10` retains `s=1`. Tick labels remain physical
+values. Reapplying `:log10` updates `s`. Zooming and resetting limits retain it.
+Selecting `:linear` restores the identity mapping without changing data or
+observation clipping.
+Preflight covers the complete selection before mutation and preserves the
+orthogonal view and configured limits. Return `p`.
+"""
+function axisscale! end
+
+"""
+ resetview!(p::UIPlot; panel=nothing, x::Bool=true, y::Bool=true)
+
+Refit selected automatic view dimensions to current visible support, including
+full enabled uncertainty intervals. Preserve configured full or partial limits.
+`panel=nothing` selects every panel. Another value selects its original identity.
+The Boolean keywords `x=true` and `y=true` select dimensions. Return `p`.
+"""
+function resetview! end
+
+"""
+ addwidget!(builder, p::UIPlot, key::Symbol; event=nothing, callback=nothing, success=nothing)
+
+Construct one custom native control with `builder(p, slot)` and register it
+under a unique nonstandard Symbol `key`. Supply both `event` and `callback`, or
+neither. `event(control)` returns the event observable. Actual notifications call
+`callback(p, value)` once and may set `success` on `p.status`. Return the native
+control. Figures constructed with `controls=false` reject widget additions.
+"""
+function addwidget! end
+
+"""
+ removewidget!(p::UIPlot, key::Symbol)
+
+Remove a custom widget, its native subtree, and its owned event subscriptions.
+Release and repack its toolbar slot without changing other controls. Standard
+and unknown keys are rejected. Return `p`.
+"""
+function removewidget! end
+
+"""
+ figurecolorbars!(p::UIPlot; position, group_attributes, native_bar_attributes...)
+
+Update the placement and native attributes of the figure's retained color scales.
+Omitted arguments preserve current state. `position=nothing` removes displayed
+scales while retaining their configuration. `group_attributes` merges by field
+with current settings: `layout=(rows,columns)` fills complete scale items
+row-major, and `layout=nothing` restores placement-based arrangement. Explicit
+layout survives side and bar-orientation changes. `rowgap`/`colgap` are minimum
+separations between complete item rows and columns. `nothing` restores inheritance
+from the shared `guide_spacing`. Alignment and explicit outer margins retain
+their native meanings. One item has zero exterior spacing.
+
+`guide_spacing` updates the same figure-wide setting as [`figurelegend!`](@ref).
+Native `vertical`, `width`, `height`, labels and ticks configure individual bars.
+Layout changes retain the native Colorbar objects and edits. Hidden items retain
+their handles and supplied order without reserving empty group tracks. Invalid
+prospective settings fail before changing the displayed arrangement.
+
+Return the current native colorbar collection.
+"""
+function figurecolorbars! end
diff --git a/src/plotbuilder/pagespec.jl b/src/plotbuilder/pagespec.jl
deleted file mode 100644
index 29f95dc8d..000000000
--- a/src/plotbuilder/pagespec.jl
+++ /dev/null
@@ -1,459 +0,0 @@
-"""
- make_pages(::Type{S}, nt, views) where {S<:AbstractPlotSpec}
-
-Packs ViewSpec values into PageSpec payloads.
-
-Default behavior:
-- a single PageSpec is created for this spec call,
-- `layout` is chosen based on `figure_layout(S)` trait.
-
-Specs that want multiple OS windows or more complex layout policies may
-override this method.
-"""
-function make_pages(
- ::Type{S},
- nt::NamedTuple,
- views::Vector{ViewSpec},
-) where {S <: AbstractPlotSpec}
- layout = figure_layout(S) # :windows, :grid, ...
-
- # Do not materialize plots if length == 1.
- # Upstream grouping/materialization stays intact, but the final PageSpec[] is empty.
- if !isempty(views)
- p1 = first(views)
- if !isempty(p1.series)
- ds1 = first(p1.series)
-
- ds1.xdata === nothing && Base.error(
- "Broken plot payload for spec $(S): xdata is `nothing`.",
- )
-
- n = length(ds1.xdata)
- if n <= 1
- @warn "Skipping plot for spec $(S): sample length is $(n) (need ≥ 2)."
- return PageSpec[]
- end
- end
- end
-
- fig_kwargs = nt.renderer
- figsize = default_figsize(S)
- fig_title = default_title(S, nt)
-
- fig = PageSpec(fig_title, figsize, layout, views, fig_kwargs)
- return PageSpec[fig]
-end
-
-# Determine if, for spec S and resolved input nt, the leaf seen by axis_slice /
-# make_series behaves as "scalar" (numeric/complex) or as a NamedTuple that
-# should be exploded at the figure / view level.
-function is_leaf(::Type{S}, nt::NamedTuple) where {S <: AbstractPlotSpec}
- dims_raw = geom_axes(S)
- dims = dims_raw isa Tuple ? dims_raw : (dims_raw,)
-
- for dim in dims
- kfield = select_field(S, Val(dim))
- kfield === nothing && continue
-
- if haskey(nt, kfield)
- # A concrete field Symbol is pinned in the resolved input; axis_slice
- # will unwrap the NamedTuple and return numeric data.
- return true
- else
- # Spec uses select_field on this axis but no field was chosen yet;
- # axis_slice will return a vector of NamedTuples.
- return false
- end
- end
-
- # No axis uses select_field at all → grammar never unwraps fields.
- return true
-end
-
-# Decide high-level figure mode given spec S and resolved input nt.
-function resolve_group_mode(::Type{S}, nt::NamedTuple) where {S <: AbstractPlotSpec}
-
- trait_mode = grouping_mode(S) # Symbol
- if trait_mode !== :auto
- return trait_mode
- end
-
- idx_raw = index_keys(S)
- idx = idx_raw isa Tuple ? idx_raw : (idx_raw,)
-
- # Matrix-like selector indices (pure selectors, never ranged)
- has_i = :i in idx
- has_j = :j in idx
- has_matrix = has_i || has_j
-
- i_defined = has_i && haskey(nt, :i)
- j_defined = has_j && haskey(nt, :j)
-
-
- if is_leaf(S, nt)
- if !has_matrix
- return :single
- end
-
- all_pinned = (!has_i || i_defined) && (!has_j || j_defined)
- if all_pinned
- return :single
- else
- return :overlay_ij
- end
- else
- if !has_matrix
- return :overlay_fields
- end
-
- all_pinned = (!has_i || i_defined) && (!has_j || j_defined)
- if all_pinned
- return :overlay_fields
- else
- return :per_ij_overlay_fields
- end
- end
-end
-
-# Infer matrix dimensions (Ni, Nj) from any axis container that carries the
-# i/j selector structure. Falls back to (1,1) if no matrix indices are used.
-function matrix_size(::Type{S}, nt::NamedTuple) where {S <: AbstractPlotSpec}
- idx_raw = index_keys(S)
- idx = idx_raw isa Tuple ? idx_raw : (idx_raw,)
-
- has_i = :i in idx
- has_j = :j in idx
-
- Ni = 1
- Nj = 1
-
- (!has_i && !has_j) && return Ni, Nj
-
- dims_raw = geom_axes(S)
- dims = dims_raw isa Tuple ? dims_raw : (dims_raw,)
- obj = nt.obj
-
- for dim in dims
- # skip if this axis has no selector in nt (e.g. z unused)
- !haskey(nt, dim) && continue
-
- datakey = getfield(nt, dim)
- datakey isa Symbol || continue
-
- arr = container_array(S, obj, dim, datakey)
-
- arr isa AbstractArray || continue
-
- if has_i && size(arr, 1) > 1
- Ni = size(arr, 1)
- end
- if has_j && ndims(arr) >= 2 && size(arr, 2) > 1
- Nj = size(arr, 2)
- end
-
- if (!has_i || Ni > 1) && (!has_j || Nj > 1)
- break
- end
- end
-
- return Ni, Nj
-end
-
-# For specs where source data is a NamedTuple (select_field used but no field
-# chosen yet), infer which axis carries the NamedTuple leaf and what its
-# field keys are, using axis_slice to get a vector of NamedTuples.
-function get_fields(
- ::Type{S},
- nt::NamedTuple,
- axes::NamedTuple,
-) where {S <: AbstractPlotSpec}
- dims_raw = geom_axes(S)
- dims = dims_raw isa Tuple ? dims_raw : (dims_raw,)
-
- # Find the first axis that uses select_field
- dim_field = nothing
- kfield = nothing
- for dim in dims
- kf = select_field(S, Val(dim))
- kf === nothing && continue
- dim_field = dim
- kfield = kf
- break
- end
-
- if dim_field === nothing || kfield === nothing
- Base.error("get_fields: no axis uses select_field for spec $(S).")
- end
-
- # axis descriptor
- axis =
- dim_field === :x ? axes.xaxis :
- dim_field === :y ? axes.yaxis :
- axes.zaxis
-
- axis === nothing &&
- Base.error(
- "get_fields: axis $(dim_field) has no AxisSpec for spec $(S).",
- )
-
- # axis_slice will return a vector of NamedTuples in the "NamedTuple leaf" modes
- vec = axis_slice(S, nt, axis, Val(dim_field))
- isempty(vec) &&
- Base.error(
- "get_fields: empty data along axis $(dim_field) for spec $(S); cannot infer NamedTuple fields.",
- )
-
- leaf = first(vec)
- leaf isa NamedTuple ||
- Base.error(
- "get_fields: expected NamedTuple leaf for spec $(S) axis $(dim_field), got $(typeof(leaf)).",
- )
-
- field_keys = collect(keys(leaf))
- return dim_field, kfield, field_keys
-end
-
-"""
- is_modal(obj)
-
-Return `true` iff `domain(obj)` is a modal-like tag.
-"""
-@inline is_modal(x) = (D = domain(x); D !== nothing && D <: ModalDomain)
-
-# --------------------------------------------------------------------------
-# Index pair iterator: PhaseDomain vs ModalDomain
-# --------------------------------------------------------------------------
-
-"""
- index_pairs(::Type{S}, nt) where {S<:AbstractPlotSpec}
-
-Return the list of \\((i,j)\\) index pairs that this spec should materialize
-for the given resolved input `nt`.
-
-Semantics:
-
-- Phase-like domain (default, or `domain(nt.obj) === nothing`):
- * If :i is in `index_keys(S)`:
- - If `nt` pins `i`, use only that value.
- - Otherwise, use `1:Ni` where `Ni` is inferred from `matrix_size(S, nt)`.
- * If :j is in `index_keys(S)`:
- - Same, using `nj` / `Nj`.
-
- Result: full rectangular coverage over the active ranges.
-
-- ModalDomain AND both :i and :j are in `index_keys(S)` AND neither is pinned in `nt`:
- * Let `(Ni, Nj) = matrix_size(S, nt)` and `N = min(Ni, Nj)`.
- * Return only diagonal pairs: `(1,1), (2,2), ..., (N,N)`.
-
-- If the user pins `i` and/or `j`, user intent takes precedence regardless
- of domain tag.
-"""
-function index_pairs(
- ::Type{S},
- nt::NamedTuple,
-) where {S <: AbstractPlotSpec}
- idx_raw = index_keys(S)
- idx = idx_raw isa Tuple ? idx_raw : (idx_raw,)
-
- has_i = :i in idx
- has_j = :j in idx
-
- Ni, Nj = matrix_size(S, nt)
-
- i_defined = has_i && haskey(nt, :i)
- j_defined = has_j && haskey(nt, :j)
-
- obj = nt.obj
- modal = is_modal(obj)
-
- # Modal diagonal semantics:
- # - both :i and :j are matrix selectors,
- # - neither is pinned by the user or defaults,
- # - domain is ModalDomain (or subtype).
- if modal && has_i && has_j && !i_defined && !j_defined
- N = min(Ni, Nj)
- pairs = Vector{Tuple{Int, Int}}(undef, N)
- @inbounds for k in 1:N
- pairs[k] = (k, k)
- end
- return pairs
- end
-
- # Phase-like / generic semantics (or user-pinned case in modal)
- I_range =
- if has_i
- i_defined ? (nt.i:nt.i) : (1:Ni)
- else
- 1:1
- end
-
- J_range =
- if has_j
- j_defined ? (nt.j:nt.j) : (1:Nj)
- else
- 1:1
- end
-
- pairs = Tuple{Int, Int}[]
- @inbounds for i in I_range
- for j in J_range
- push!(pairs, (i, j))
- end
- end
-
- return pairs
-end
-
-
-"""
- make_pages(::Type{S}, nt) where {S<:AbstractPlotSpec}
-
-Top-level figure builder for spec `S`.
-
-Decides a high-level figure mode based on:
-- whether the leaf behaves as scalar or NamedTuple (via select_field traits),
-- whether index selectors :i and :j are present in `index_keys(S)`,
-- whether :i and :j are defined or free in the resolved input `nt`.
-
-It then delegates to `make_pages(::Type{S}, ::Val{mode}, nt, axes)` where
-`mode` is one of:
-- :single → single atom, no i/j or field expansion
-- :overlay_ij → scalar leaf, some of :i/:j free → overlay all (i,j)
-- :overlay_fields → NamedTuple leaf, fixed (i,j) → overlay all fields
-- :per_ij_overlay_fields → NamedTuple leaf, some of :i/:j free → one ViewSpec
- per (i,j), overlaying all fields in each view.
-"""
-function make_pages(
- ::Type{S},
- nt::NamedTuple,
-) where {S <: AbstractPlotSpec}
- axes = make_axes(S, nt)
- mode = resolve_group_mode(S, nt)
- return make_pages(S, Val(mode), nt, axes)
-end
-
-function make_pages(
- ::Type{S},
- ::Val{:single},
- nt::NamedTuple,
- axes::NamedTuple,
-) where {S <: AbstractPlotSpec}
- series = make_series(S, nt, axes)
- views = make_views(S, nt, axes, series)
- return make_pages(S, nt, views)
-end
-
-function make_pages(
- ::Type{S},
- ::Val{:overlay_ij},
- nt::NamedTuple,
- axes::NamedTuple,
-) where {S <: AbstractPlotSpec}
- idx_raw = index_keys(S)
- idx = idx_raw isa Tuple ? idx_raw : (idx_raw,)
-
- has_i = :i in idx
- has_j = :j in idx
-
- all_series = SeriesSpec[]
-
- for (i, j) in index_pairs(S, nt)
- nt_ij = nt
- if has_i
- nt_ij = merge(nt_ij, (; i = i))
- end
- if has_j
- nt_ij = merge(nt_ij, (; j = j))
- end
-
- series_ij = make_series(S, nt_ij, axes)
- append!(all_series, series_ij)
- end
-
- views = make_views(S, nt, axes, all_series)
- return make_pages(S, nt, views)
-end
-
-function make_pages(
- ::Type{S},
- ::Val{:overlay_fields},
- nt::NamedTuple,
- axes::NamedTuple,
-) where {S <: AbstractPlotSpec}
- _, kfield, field_keys = get_fields(S, nt, axes)
-
- all_series = SeriesSpec[]
-
- for fk in field_keys
- nt_fk = merge(nt, (; kfield => fk))
- series_fk = make_series(S, nt_fk, axes)
- append!(all_series, series_fk)
- end
-
- views = make_views(S, nt, axes, all_series)
- return make_pages(S, nt, views)
-end
-
-function make_pages(
- ::Type{S},
- ::Val{:per_ij_overlay_fields},
- nt::NamedTuple,
- axes::NamedTuple,
-) where {S <: AbstractPlotSpec}
- idx_raw = index_keys(S)
- idx = idx_raw isa Tuple ? idx_raw : (idx_raw,)
-
- has_i = :i in idx
- has_j = :j in idx
-
- _, kfield, field_keys = get_fields(S, nt, axes)
-
- views = ViewSpec[]
-
- for (i, j) in index_pairs(S, nt)
- nt_ij = nt
- if has_i
- nt_ij = merge(nt_ij, (; i = i))
- end
- if has_j
- nt_ij = merge(nt_ij, (; j = j))
- end
-
- series_ij = SeriesSpec[]
-
- for fk in field_keys
- nt_ij_fk = merge(nt_ij, (; kfield => fk))
- series_fk = make_series(S, nt_ij_fk, axes)
- append!(series_ij, series_fk)
- end
-
- # Populate view key with whatever matrix indices this spec actually uses.
- key =
- if has_i && has_j
- (; i = i, j = j)
- elseif has_i
- (; i = i)
- elseif has_j
- (; j = j)
- else
- (;)
- end
-
- title = default_title(S, nt_ij)
-
- view = ViewSpec(
- axes.xaxis,
- axes.yaxis,
- axes.zaxis,
- title,
- series_ij,
- key,
- )
-
- push!(views, view)
- end
-
- return make_pages(S, nt, views)
-end
-
-
diff --git a/src/plotbuilder/parse.jl b/src/plotbuilder/parse.jl
deleted file mode 100644
index ca11e1218..000000000
--- a/src/plotbuilder/parse.jl
+++ /dev/null
@@ -1,537 +0,0 @@
-
-# split_kwargs – purely “what did the user say?”
-function split_kwargs(
- ::Type{S},
- kwargs::NamedTuple,
- input_keys::Tuple,
- renderer_keys::Tuple,
- idx::Tuple,
- dims::Tuple,
-) where {S <: AbstractPlotSpec}
-
- # AxisSpec selector keys: (:x, :y, :z)
- select_fields = dims
-
- semantic_keys = (input_keys..., idx..., select_fields...)
- allowed = (semantic_keys..., renderer_keys...)
-
- spec_pairs = Tuple(filter(((k, _),) -> k in semantic_keys, pairs(kwargs)))
- renderer_pairs = Tuple(filter(((k, _),) -> k in renderer_keys, pairs(kwargs)))
- for k in keys(kwargs)
- k in allowed || @warn "Unknown plot keyword for $(S): :$(k)"
- end
-
- spec = NamedTuple(spec_pairs)
- renderer = NamedTuple(renderer_pairs)
-
- return spec, renderer
-end
-
-# merge_defaults – “how does this spec fill in the blanks?”
-function merge_defaults(
- ::Type{S},
- obj,
- spec::NamedTuple,
- renderer::NamedTuple,
-) where {S <: AbstractPlotSpec}
-
- idefault = input_defaults(S, obj)
- bdefault = renderer_defaults(S, obj)
-
- spec_merged = merge(idefault, spec)
- renderer_merged = merge(bdefault, renderer)
-
- return spec_merged, renderer_merged
-end
-
-# normalize_indices – enforce Int vs range-capable
-function normalize_indices(
- ::Type{S},
- spec::NamedTuple,
- idx_keys::Tuple{Vararg{Symbol}},
- ranged_keys::Tuple{Vararg{Symbol}},
-) where {S <: AbstractPlotSpec}
-
- # No index keys → nothing to normalize
- isempty(idx_keys) && return spec
-
- out = spec
-
- for k in idx_keys
- is_ranged = k in ranged_keys
-
- if is_ranged
- # Sample-like index (typically :k, optionally :l)
- # Default to full range when not provided at all.
- v = get(out, k, Colon())
-
- (v isa Int ||
- v isa AbstractUnitRange{<:Int} ||
- v isa Colon) ||
- Base.error(
- "Index $(k) for spec $(S) must be Int, AbstractUnitRange{<:Int} or `:`; " *
- "got $(typeof(v)).",
- )
-
- out = merge(out, NamedTuple{(k,)}((v,)))
- else
- # Selector indices (:i, :j, ...) – only normalized if explicitly present.
- # No defaults are invented for these.
- if haskey(out, k)
- v = getfield(out, k)
- v isa Int || Base.error(
- "Index $(k) for spec $(S) must be Int when provided; got $(typeof(v)).",
- )
- out = merge(out, NamedTuple{(k,)}((v,)))
- end
- end
- end
-
- return out
-end
-
-
-# sanity check selectors of datasources – ensure sources for xdata/ydata/... exist and are Symbols
-function verify_selectors(
- ::Type{S},
- spec::NamedTuple,
- dims::Tuple,
-) where {S <: AbstractPlotSpec}
-
- for d in dims
- val = get(spec, d, nothing)
- val === nothing &&
- Base.error("Missing axis selector $(d) for spec $(S) after defaults")
- val isa Symbol ||
- Base.error(
- "AxisSpec selector $(d) must be a Symbol, got $(typeof(val)) for spec $(S)",
- )
- end
-
- return
-end
-
-function container_array(
- ::Type{S},
- obj,
- dim::Symbol,
- datakey::Symbol,
-) where {S <: AbstractPlotSpec}
-
- container = data_container(S, Val(dim))
-
- if container === nothing
- hasproperty(obj, datakey) ||
- Base.error(
- "For spec $(S), axis $(dim) expects obj.$(datakey), " *
- "but $(typeof(obj)) has no such field.",
- )
- return getproperty(obj, datakey)
- else
- container isa Symbol ||
- Base.error(
- "data_container(::Type{$(S)}, Val($(dim))) must be Symbol or nothing; got $(typeof(container))",
- )
-
- hasproperty(obj, container) ||
- Base.error(
- "data_container(::Type{$(S)}, Val($(dim))) = :$(container), " *
- "but $(typeof(obj)) has no field :$(container).",
- )
-
- parent = getproperty(obj, container)
-
- if parent isa AbstractDict
- haskey(parent, datakey) ||
- Base.error(
- "Container field :$(container) for $(S) has no key :$(datakey) for axis $(dim).",
- )
- return parent[datakey]
- elseif parent isa NamedTuple && haskey(parent, datakey)
- return parent[datakey]
- elseif hasproperty(parent, datakey)
- return getproperty(parent, datakey)
- else
- try
- return parent[datakey]
- catch
- Base.error(
- "Container field :$(container) of type $(typeof(parent)) " *
- "does not provide data for key :$(datakey) for axis $(dim) in $(S).",
- )
- end
- end
- end
-end
-
-# normalize_shapes – centralized structural sanity
-# container resolution,
-# i/j bounds,
-# sample length alignment.
-# adjusted to respect the axis-level data_container(::Type{S}, ::Val{dim}) contract:
-function verify_shapes(
- ::Type{S},
- obj,
- spec::NamedTuple,
- dims::Tuple,
- idx_keys::Tuple,
-) where {S <: AbstractPlotSpec}
-
- # AxisSpec → datakey mapping (:x → :f, :y → :R, etc.)
- datakeys = Dict{Symbol, Symbol}()
- for d in dims
- datakeys[d] = getfield(spec, d) # verified by verify_selectors
- end
-
- # Index presence semantics:
- # - i/j are selector indices: present iff field exists in spec NT
- # - k is sample-like only if it is in ranged_keys(S)
- rk = ranged_keys(S)
-
- has_i = (:i in idx_keys) && haskey(spec, :i)
- has_j = (:j in idx_keys) && haskey(spec, :j)
- has_k = (:k in rk) # sample-like dimension iff ranged_keys(S) contains :k
-
- i_val = has_i ? spec.i : nothing
- j_val = has_j ? spec.j : nothing
- k_val = has_k ? spec.k : Colon() # normalized in normalize_indices
-
- local function _check_index(name::Symbol, v, n::Int)
- v isa Int || Base.error(
- "Index $(name) must be Int, got $(typeof(v)) for spec $(S)",
- )
- (1 <= v <= n) ||
- error("Index $(name) = $(v) out of bounds 1:$(n) for spec $(S)")
- v
- end
-
- lengths = Dict{Symbol, Int}()
-
- for d in dims
- datakey = datakeys[d]
- arr = container_array(S, obj, d, datakey)
-
- nd = ndims(arr)
- nd == 0 &&
- Base.error("AxisSpec $(d) data for $(S) is scalar; expected an array.")
-
- # Enforce the same storage contract as axis_slice,
- # except that :x may be a global 1D vector.
- if has_i && has_j && !(d === :x && nd == 1) && nd < 3
- Base.error(
- "Invalid axis storage for $(d): spec uses indices :i and :j, " *
- "but container_array($(S), $(d)) returned an array with $(nd) dimension(s). " *
- "When both :i and :j are active, the underlying array must be at least 3D " *
- "(Ni, Nj, Nk...).",
- )
- end
-
- # Check i/j bounds using first/second dims when present.
- # Skip for global 1D :x vectors.
- if !(d === :x && nd == 1)
- if has_i && nd >= 1
- _check_index(:i, i_val, size(arr, 1))
- end
- if has_j && nd >= 2
- _check_index(:j, j_val, size(arr, 2))
- end
- end
-
- # Determine sample length along k or last dimension
- n_samp = if nd == 1
- length(arr)
- else
- size(arr, nd)
- end
-
- len = if has_k
- kv = k_val
- if kv isa Int
- _check_index(:k, kv, n_samp)
- 1
- elseif kv isa AbstractUnitRange{<:Int}
- first(kv) >= 1 && last(kv) <= n_samp ||
- error(
- "Range k = $(kv) out of bounds 1:$(n_samp) for spec $(S) on axis $(d).",
- )
- length(kv)
- elseif kv isa Colon
- n_samp
- else
- Base.error(
- "Index :k must be Int, Int range, or `:` after normalization; " *
- "got $(typeof(kv)) for spec $(S).",
- )
- end
- else
- # No ranged k for this spec → sample length is the 1D length (nd == 1)
- # or the last dimension if nd ≥ 2; caller already ensured alignment.
- nd == 1 ? length(arr) : n_samp
- end
-
- lengths[d] = len
-
- # guard select_field semantics
- kfield = select_field(S, Val(d))
- if kfield !== nothing
- # select_field is interpreted strictly as a spec field name.
- # If that field is provided in the spec NT, then it must be a Symbol
- # and the data must be NamedTuple with that key.
- # If not provided, we only enforce that elements are NamedTuple;
- # the grammar decides how to use the keys later.
- isempty(arr) && continue
-
- first_el = first(arr)
-
- if kfield in keys(spec)
- v = spec[kfield]
- v isa Symbol || Base.error(
- "select_field($(S), Val($(d))) = :$(kfield) but spec.$(kfield) " *
- "is not a Symbol; got $(typeof(v)).",
- )
- sym = v
-
- first_el isa NamedTuple || Base.error(
- "Data for axis $(d) in $(S) must be NamedTuple when a leaf " *
- "field is selected via select_field; got $(typeof(first_el)).",
- )
- haskey(first_el, sym) || Base.error(
- "NamedTuple data for axis $(d) in $(S) has no key $(sym).",
- )
- else
- # select_field is defined but no concrete field has been bound yet.
- # Enforce that the data are NamedTuple; actual key usage is left
- # to the generic make_series/make_views logic.
- first_el isa NamedTuple || Base.error(
- "Data for axis $(d) in $(S) must be NamedTuple when " *
- "select_field($(S), Val($(d))) is defined; got $(typeof(first_el)).",
- )
- end
- end
- end
-
- vals = collect(values(lengths))
- isempty(vals) && return
-
- ref = first(vals)
- for (d, len) in lengths
- len == ref || Base.error(
- "Mismatched sample lengths for spec $(S): axis $(d) has length $(len), " *
- "expected $(ref). Containers must align along their sample dimension.",
- )
- end
-
- return
-end
-
-
-
-
-
-@inline function trait_to_tuple(::Type{S}, raw, name) where {S <: AbstractPlotSpec}
- raw === () && return ()
- raw isa Tuple && return raw
- @warn "Trait $(name) for $(S) should be a Tuple; got $(typeof(raw)). Coercing to 1-tuple."
- return (raw,)
-end
-
-"""
- parse_kwargs(::Type{S}, obj, kwargs::NamedTuple) where {S<:AbstractPlotSpec}
-
-Grammar-level normalization phase.
-
-Responsibilities:
-
- 1. Decide which kwargs matter for this spec (`input_kwargs`, `renderer_kwargs`,
- `index_keys`, `geom_axes`) and partition user kwargs into semantic vs
- renderer.
- 2. Merge user kwargs with `input_defaults(S, obj)` and
- `renderer_defaults(S, obj)`.
- 3. Normalize indices (`index_keys(S)` / `ranged_keys(S)`) to the allowed
- types and fill in defaults.
- 4. Ensure axis data keys (`x`, `y`, ...) exist and are `Symbol`s.
- 5. Run grammar-level structural checks:
- - data sources exist under `obj` according to `data_container`,
- - indices are in bounds,
- - all active axes have compatible sample lengths.
-
-Returns a canonical NamedTuple:
-
- (; obj = obj, spec = spec_nt, backend = backend_nt)
-
-to be consumed by `resolve_input`.
-"""
-function parse_kwargs(::Type{S}, obj, kwargs::NamedTuple) where {S <: AbstractPlotSpec}
- # Raw traits
- ik_raw = input_kwargs(S)
- bk_raw = renderer_kwargs(S)
- idx_raw = index_keys(S)
- dims_raw = geom_axes(S)
- rk_raw = ranged_keys(S)
-
- # Coerce to tuples with warnings if someone was lazy
- ik = trait_to_tuple(S, ik_raw, "input_kwargs")
- bk = trait_to_tuple(S, bk_raw, "renderer_kwargs")
- idx = trait_to_tuple(S, idx_raw, "index_keys")
- dims = trait_to_tuple(S, dims_raw, "geom_axes")
- rk = trait_to_tuple(S, rk_raw, "ranged_keys")
-
- # Basic trait sanity: they should all be Symbols, and axes only from :x,:y,:z
- for (name, tup) in (("input_kwargs", ik), ("renderer_kwargs", bk),
- ("index_keys", idx), ("ranged_keys", rk))
- all(k -> k isa Symbol, tup) ||
- @warn "$(name)(::Type{$(S)}) should be a Tuple of Symbols, got $(tup)."
- end
-
- for d in dims
- d in (:x, :y, :z) ||
- Base.error(
- "geom_axes(::Type{$(S)}) returned unsupported axis $(d). " *
- "Valid axes are :x, :y, :z.",
- )
- end
-
- # Additional trait sanity for ranged_keys:
- # - ranged_keys ⊆ index_keys
- # - only :k and :l are allowed to be ranged (sample-like dims)
- if !isempty(rk)
- # ranged_keys ⊆ index_keys
- for key in rk
- key in idx || Base.error(
- "ranged_keys(::Type{$(S)}) includes $(key), which is not in " *
- "index_keys(::Type{$(S)}) = $(idx).",
- )
- end
-
- # Only :k and :l allowed as rangeable indices (sample dimensions)
- for key in rk
- (key === :k || key === :l) || Base.error(
- "ranged_keys(::Type{$(S)}) may only contain :k and/or :l. " *
- "Got $(key). Allowing :i or :j here would break the axis " *
- "semantics (sample dimension must remain unique).",
- )
- end
- end
-
- # 1) Split user kwargs into semantic vs backend
- spec_inputs, renderer_inputs = split_kwargs(S, kwargs, ik, bk, idx, dims)
-
- # 2) Merge with defaults
- spec_nt, renderer_nt = merge_defaults(S, obj, spec_inputs, renderer_inputs)
-
- # 3) Normalize indices (i,j,k,...) according to index/ranged traits
- spec_nt = normalize_indices(S, spec_nt, idx, rk)
-
- # 4) Ensure axis data keys exist and are Symbols
- verify_selectors(S, spec_nt, dims)
-
- # 5) Grammar-level structural sanity: containers, bounds, lengths
- verify_shapes(S, obj, spec_nt, dims, idx)
-
- return (; obj = obj, spec = spec_nt, renderer = renderer_nt)
-end
-
-# Convenience varargs wrapper
-parse_kwargs(::Type{S}, obj; kwargs...) where {S <: AbstractPlotSpec} =
- parse_kwargs(S, obj, (; kwargs...))
-
-"""
-Resolve raw inputs into a normalized NamedTuple understood by `make_pages`.
-
-This is where a spec implements its own mini-grammar:
-
-- parse `values_expr` / `ijk`,
-- pick matrix indices/slices,
-- decide which quantities (R/L/C/G etc.) and which kind (:hist, :heatmap, ...).
-
-Default is identity; spec types are expected to override.
-"""
-function resolve_input(::Type{S}, nt::NamedTuple) where {S <: AbstractPlotSpec}
- obj = nt.obj
- spec = nt.spec
- renderer_nt = nt.renderer
-
- dims = geom_axes(S)
- dims = dims isa Tuple ? dims : (dims,)
-
- xsel = :x in dims ? spec.x : nothing
- ysel = :y in dims ? spec.y : nothing
- zsel = :z in dims && haskey(spec, :z) ? spec.z : nothing
-
- # raw user knob (global, for now)
- has_as = haskey(spec, :as)
- raw_as = has_as ? spec.as : nothing
-
- xq = yq = zq = nothing
- xas = yas = zas = nothing
-
- any_complex = false
-
- if :x in dims
- if has_complex_qty(S, Val(:x), Val(xsel))
- any_complex = true
- xas = raw_as === nothing ? complex_as_default(S, Val(:x), Val(xsel)) : raw_as
-
- allowed = complex_as(S, Val(:x), Val(xsel))
- xas in allowed ||
- Base.error("Invalid as=$(xas) for x=$(xsel). Allowed: $(allowed).")
-
- xq = axis_quantity(S, Val(:x), Val(xsel), Val(xas))
- else
- xq = axis_quantity(S, Val(:x), Val(xsel))
- end
- end
-
- if :y in dims
- if has_complex_qty(S, Val(:y), Val(ysel))
- any_complex = true
- yas = raw_as === nothing ? complex_as_default(S, Val(:y), Val(ysel)) : raw_as
- allowed = complex_as(S, Val(:y), Val(ysel))
- yas in allowed ||
- Base.error("Invalid as=$(yas) for y=$(ysel). Allowed: $(allowed).")
-
- yq = axis_quantity(S, Val(:y), Val(ysel), Val(yas))
- else
- yq = axis_quantity(S, Val(:y), Val(ysel))
- end
- end
-
- if :z in dims && zsel !== nothing
- if has_complex_qty(S, Val(:z), Val(zsel))
- any_complex = true
- zas = raw_as === nothing ? complex_as_default(S, Val(:z), Val(zsel)) : raw_as
-
- allowed = complex_as(S, Val(:z), Val(zsel))
- zas in allowed ||
- Base.error("Invalid as=$(zas) for z=$(zsel). Allowed: $(allowed).")
-
- zq = axis_quantity(S, Val(:z), Val(zsel), Val(zas))
- else
- zq = axis_quantity(S, Val(:z), Val(zsel))
- end
- end
-
- # Tight API: if user asked for as= but nothing is complex, that's nonsense.
- if has_as && !any_complex
- Base.error(
- "Keyword as= is only valid for complex selectors with trait has_complex_qty == true.",
- )
- end
-
- out = spec
-
- if :x in dims
- out = merge(out, (; x = xsel, x_quantity = xq))
- xas === nothing || (out = merge(out, (; x_as = xas)))
- end
- if :y in dims
- out = merge(out, (; y = ysel, y_quantity = yq))
- yas === nothing || (out = merge(out, (; y_as = yas)))
- end
- if :z in dims && zsel !== nothing
- out = merge(out, (; z = zsel, z_quantity = zq))
- zas === nothing || (out = merge(out, (; z_as = zas)))
- end
-
- return merge(out, (; obj = obj, renderer = renderer_nt))
-end
diff --git a/src/plotbuilder/plothelpers.jl b/src/plotbuilder/plothelpers.jl
deleted file mode 100644
index 802f71ff2..000000000
--- a/src/plotbuilder/plothelpers.jl
+++ /dev/null
@@ -1,36 +0,0 @@
-using Makie
-
-using Base: basename, mod1
-using Dates: format, now
-using Printf: @sprintf
-
-import LineCableModels.PlotBuilder.BackendHandler: BackendHandler, next_fignum
-
-using LineCableModels.PlotBuilder.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!,
- TICKFORMATTER,
- EXPORT_EXTENSION,
- EXPORT_TIMESTAMP_FORMAT
-
-using LineCableModels.PlotBuilder: AbstractPlotSpec,
- _sanitize_filename_plot,
- _default_export_path,
- _save_plot_export,
- _parse_values_expr,
- _autoscale_axis,
- _render_plot_specs,
- _build_common_plot_controls
diff --git a/src/plotbuilder/plotspecs.jl b/src/plotbuilder/plotspecs.jl
deleted file mode 100644
index 4c8f0ca4d..000000000
--- a/src/plotbuilder/plotspecs.jl
+++ /dev/null
@@ -1,167 +0,0 @@
-function _sanitize_filename_plot(str::AbstractString)
- sanitized = lowercase(strip(str))
- sanitized = replace(sanitized, r"[^0-9a-z]+" => "_")
- sanitized = strip(sanitized, '_')
- return isempty(sanitized) ? "linecablemodels_plot" : sanitized
-end
-
-function _default_export_path(
- spec::AbstractPlotSpec;
- extension::AbstractString = EXPORT_EXTENSION,
-)
- base_title = strip(spec.title)
- base = isempty(base_title) ? string(spec.parent_kind, "_", spec.component) : base_title
- name = _sanitize_filename_plot(base)
- timestamp = format(now(), EXPORT_TIMESTAMP_FORMAT)
- filename = string(name, "_", timestamp, ".", extension)
- return joinpath(pwd(), filename)
-end
-
-function _save_plot_export(spec::AbstractPlotSpec, 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
-
-"""
-Parses a values expression like :X[1,1,:] or :X[1,1,1:5].
-"""
-function _parse_values_expr(values_expr, ijk)
-
- # --- input is :R[1,1,:] ---
- if ijk === nothing
- if values_expr isa Expr && values_expr.head === :ref &&
- length(values_expr.args) == 4
- q = values_expr.args[1]
- q isa Symbol ||
- Base.error("Expected values symbol as first argument, got $(q)")
-
- local i::Int
- local j::Int
- local k::Union{Int, Colon, AbstractRange}
-
- # Eval the indices to resolve them from Expr.
- # It will correctly resolve 1, :, and 1:5.
- try
- i = eval(values_expr.args[2])
- j = eval(values_expr.args[3])
- k = eval(values_expr.args[4])
- catch e
- Base.error(
- "Failed to parse indices from $(values_expr). Ensure they are valid literals (1, :, 1:5, etc.). Error: $e",
- )
- end
-
- # Type checking after eval
- i isa Int || Base.error("i index '$i' is not an Int")
- j isa Int || Base.error("j index '$j' is not an Int")
- (k isa Int || k == (:) || k isa AbstractRange) ||
- Base.error("k index '$k' must be Int, ':', or AbstractRange")
-
- return q, (i, j, k)
- else
- Base.error(
- "Provide values as Expr like :X[1,1,:] or :X[1,1,1:5], or pass symbol and `ijk`",
- )
- end
-
- # --- input is :X, ijk=(1,1,:) ---
- else
- # This branch already supports non-Int types, it just needs a type assertion.
- ijk isa NTuple{3, Any} ||
- Base.error("ijk must be NTuple{3,Any}, got $(typeof(ijk))")
- values_expr isa Symbol ||
- Base.error(
- "values must be Symbol when ijk is provided; got $(typeof(values_expr))",
- )
-
- i, j, k = ijk
-
- # Check types
- i isa Int || Base.error("i in ijk must be Int")
- j isa Int || Base.error("j in ijk must be Int")
- (k isa Int || k == (:) || k isa AbstractRange) ||
- Base.error("k in ijk must be Int, ':', or AbstractRange")
-
- return values_expr, (i, j, k)
- end
-end
-
-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))
- _threshold_exp = floor(Int, log10(_threshold))
- abs(exp) < abs(_threshold_exp) && return values, 0
- scale = 10.0 ^ exp
- return values ./ scale, exp
-end
-
-function _render_plot_specs(
- spec::AbstractPlotSpec;
- 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,
- # This closure dispatches to the correct _build_plot! method
- # based on the *concrete type* of 'spec'.
- (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
-
-# --- De-duplicated Button Logic ---
-function _build_common_plot_controls(spec::AbstractPlotSpec, axis)
- buttons = [
- ControlButtonSpec(
- (_ctx, _btn) -> (Makie.autolimits!(axis); nothing),
- icon = MI_REFRESH,
- on_success = ControlReaction(status_string = "Axis limits reset"),
- ),
- ControlButtonSpec(
- (_ctx, _btn) -> _save_plot_export(spec, axis), # Generic save
- icon = MI_SAVE,
- on_success = ControlReaction(
- status_string = path -> string("Saved SVG to ", basename(path)),
- ),
- ),
- ]
- return buttons
-end
diff --git a/src/plotbuilder/plotuicomponents/PlotUIComponents.jl b/src/plotbuilder/plotuicomponents/PlotUIComponents.jl
deleted file mode 100644
index ed2e8510d..000000000
--- a/src/plotbuilder/plotuicomponents/PlotUIComponents.jl
+++ /dev/null
@@ -1,818 +0,0 @@
-module PlotUIComponents
-
-using Makie
-
-# Makie now supplies `toggle_visibility!`. Mutating Makie's module from this
-# package's `__init__` breaks incremental compilation on Julia 1.12.
-
-using Printf: @sprintf
-import ..PlotBuilder.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)
-const TICK_FMT = x -> @sprintf("%.4g", x)
-const TICKFORMATTER = values -> [TICK_FMT(v) for v in values]
-const EXPORT_TIMESTAMP_FORMAT = "yyyymmdd_HHMMSS"
-const EXPORT_EXTENSION = "svg"
-
-# -----------------------------------------------------------------------------
-# Material UI icons
-# -----------------------------------------------------------------------------
-const MI_REFRESH = "\uE5D5" # Material Icons: 'refresh'
-const MI_SAVE = "\uE161" # Material Icons: 'save'
-const ICON_TTF = joinpath(pkgdir(@__MODULE__), "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
- # Optional keyword: empty if no icon font, otherwise sets fonts = (; icons = icon_font)
- fonts_kw = icon_font === nothing ? NamedTuple() : (fonts = (; icons = icon_font),)
- # one single theme with conditional fonts because we are civilized barbarians
- custom = 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,
- xtickformat = TICKFORMATTER,
- ytickformat = TICKFORMATTER,
- ),
- Legend = (
- fontsize = AXIS_LABEL_FONT_SIZE,
- labelsize = AXIS_LABEL_FONT_SIZE,
- ),
- Colorbar = (
- labelsize = AXIS_LABEL_FONT_SIZE,
- ticklabelsize = AXIS_TICK_FONT_SIZE,
- ),
- fonts_kw..., # <- conditionally adds `fonts` only when icon_font ≠ nothing
- )
- 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/plotbuilder/plotuicomponents/callbacks.jl b/src/plotbuilder/plotuicomponents/callbacks.jl
deleted file mode 100644
index e69de29bb..000000000
diff --git a/src/plotbuilder/seriesspec.jl b/src/plotbuilder/seriesspec.jl
deleted file mode 100644
index 767fa2679..000000000
--- a/src/plotbuilder/seriesspec.jl
+++ /dev/null
@@ -1,289 +0,0 @@
-
-# --------------------------------------------------------------------------
-# AxisSpec-level transform hook
-# --------------------------------------------------------------------------
-
-"""
- axis_transform(::Type{S}, ::Val{dim}, ::Val{datakey}, nt, axis::AxisSpec, data) where {S,dim,datakey}
-
-Per-spec hook to post-process the sliced axis data *before* unit scaling.
-
-- `dim` : :x, :y, or :z
-- `datakey` : axis selector symbol (e.g. :f, :R, :Z, ...)
-- `nt` : resolved input NamedTuple from `resolve_input`
-- `axis` : AxisSpec for this dimension (quantity + units + label + scale)
-- `data` : 1D numeric array returned by `axis_slice`
-
-Default is identity; in case of complex quantities defined by the trait `has_complex_qty`, the selector `as` defines what to extract: real, imaginary, magnitude, or phase components.
-"""
-axis_transform(
- ::Type{S},
- ::Val{dim},
- ::Val{datakey},
- nt::NamedTuple,
- axis::AxisSpec,
- data,
-) where {S <: AbstractPlotSpec, dim, datakey} = begin
- has_complex_qty(S, Val(dim), Val(datakey)) || return data
- (data isa AbstractArray && eltype(data) <: Number) || return data
-
- ask = Symbol(dim, :_as)
- haskey(nt, ask) || return data
- as = getfield(nt, ask)
-
- # Warn if a "complex view" is requested but the materialized slice is real.
- # This is mathematically valid (imag(real)=0, abs(real)=|real|, angle(real)=0/π),
- # but it usually indicates the pipeline expected complex data and got real instead.
- if as !== :re && !(eltype(data) <: Complex)
- @warn "Complex view requested on real-valued data; did you materialize a real quantity where complex was expected?" spec=S dim=dim datakey=datakey as=as eltype=eltype(
- data,
- )
- end
-
- as === :re && return real.(data)
- as === :im && return imag.(data)
- as === :abs && return abs.(data)
- as === :angle && return angle.(data) .* (180 / pi)
-
- Base.error(
- "Unsupported as=$(as) for $(datakey) on axis $(dim). Valid options: :re, :im, :abs, :angle.",
- )
-end
-
-
-"""
- axis_slice(::Type{S}, nt, axis::AxisSpec, ::Val{dim}) where {S<:AbstractPlotSpec}
-
-Return a 1D slice for axis `dim` using the grammar:
-
- * Use `data_container(S, Val(dim))` and the axis selector `nt.`
- (e.g. `nt.x`, `nt.y`) to locate the raw storage in `nt.obj`.
- * Apply indices `i, j` if present in `nt`, assuming the sample dimension
- is the last array dimension.
- * Optionally unwrap child fields using `select_field(S, Val(dim))` if it is
- non-`nothing` and elements are NamedTuples.
-
-No unit scaling and no numeric check happen here; those are handled by
-`axis_transform` and `make_series`.
-"""
-function axis_slice(
- ::Type{S},
- nt::NamedTuple,
- axis::AxisSpec,
- ::Val{dim},
-) where {S <: AbstractPlotSpec, dim}
-
- obj = nt.obj
-
- # AxisSpec selector: what the user (or defaults) chose for this axis, e.g. :f, :R, ...
- selector = getfield(nt, dim)::Symbol
-
- # --- Fetch raw array via centralized container logic ---
- raw_arr = container_array(S, obj, dim, selector)
-
- # --- Apply indices (i,j,k) → 1D slice along sample dimension ---
- arr = raw_arr
- nd = ndims(arr)
-
- has_i = haskey(nt, :i)
- has_j = haskey(nt, :j)
- has_k = haskey(nt, :k)
-
- # First slice in i,j where applicable.
- # Exception: allow :x to be a global 1D vector shared across all (i,j).
- if has_i && has_j
- if dim === :x && nd == 1
- # global x; do nothing
- elseif nd < 3
- Base.error(
- "Invalid axis storage for $(dim): spec uses indices :i and :j, " *
- "but container_array($(S), $(dim)) returned an array with $(nd) dimension(s). " *
- "When both :i and :j are active, the underlying array must be at least 3D " *
- "(Ni, Nj, Nk...). Check index_keys($(S)) and container_array($(S), $(dim)).",
- )
- else
- # canonical case: Ni×Nj×Nk...
- arr = view(arr, nt.i, nt.j, :)
- end
- elseif has_i && !has_j
- if dim === :x && nd == 1
- # global x; do nothing
- elseif nd >= 2
- arr = view(arr, nt.i, :)
- end
- elseif has_j && !has_i
- if dim === :x && nd == 1
- # global x; do nothing
- elseif nd >= 2
- arr = view(arr, :, nt.j)
- end
- end
-
- # Then slice in k along last dimension (sample dim)
- if has_k
- k = nt.k
- nd2 = ndims(arr)
-
- if nd2 == 0
- Base.error(
- "AxisSpec $(dim) for $(S) has scalar data after i/j slicing; cannot apply k index.",
- )
- end
-
- if nd2 == 1
- if k isa Int
- arr = view(arr, k:k)
- elseif k isa AbstractUnitRange{<:Int} || k isa Colon
- arr = view(arr, k)
- else
- Base.error(
- "Index :k must be Int, Int range, or `:` after normalization; " *
- "got $(typeof(k)) for spec $(S) on axis $(dim).",
- )
- end
- else
- # nd2 ≥ 2, index last dimension
- lastdim = nd2
- if k isa Int
- inds = ntuple(d -> d == lastdim ? (k:k) : Colon(), lastdim)
- elseif k isa AbstractUnitRange{<:Int} || k isa Colon
- inds = ntuple(d -> d == lastdim ? k : Colon(), lastdim)
- else
- Base.error(
- "Index :k must be Int, Int range, or `:` after normalization; " *
- "got $(typeof(k)) for spec $(S) on axis $(dim).",
- )
- end
- arr = view(arr, inds...)
- end
- end
-
- ndims(arr) == 1 ||
- Base.error(
- "AxisSpec $(dim) for $(S) expected to resolve to a 1D slice after indexing; " *
- "got $(ndims(arr))-dimensional array.",
- )
-
- vec_arr = arr
-
- # --- NamedTuple unwrapping via select_field ---
- kfield = select_field(S, Val(dim))
-
- if kfield === nothing
- return collect(vec_arr)
- else
- # select_field is interpreted strictly as a spec field name that, when present
- # in the resolved input `nt`, holds the Symbol of the NamedTuple field to
- # extract. If the field is not present, we *do not* guess: we simply
- # return the NamedTuple vector and let higher-level grammar decide what
- # to do (overlay all fields, facet, etc.).
- if haskey(nt, kfield)
- v = getfield(nt, kfield)
- v isa Symbol || Base.error(
- "Field $(kfield) in resolved input for $(S) on axis $(dim) " *
- "must be a Symbol; got $(typeof(v)).",
- )
- ksym = v
-
- first_el = first(vec_arr)
- first_el isa NamedTuple ||
- Base.error(
- "select_field($(S), Val($(dim))) expects NamedTuple elements; " *
- "got $(typeof(first_el)).",
- )
-
- haskey(first_el, ksym) || Base.error(
- "NamedTuple elements on axis $(dim) for $(S) have no key $(ksym). " *
- "Available keys: $(collect(keys(first_el))).",
- )
-
- return [el[ksym] for el in vec_arr]
- else
- # No leaf field bound yet; just enforce NamedTuple contract and return as-is.
- first_el = first(vec_arr)
- first_el isa NamedTuple ||
- Base.error(
- "select_field($(S), Val($(dim))) is defined but resolved input has no " *
- "field $(kfield); data elements on axis $(dim) for $(S) must be " *
- "NamedTuple; got $(typeof(first_el)).",
- )
- return collect(vec_arr)
- end
- end
-end
-
-
-# Process one axis if present
-@inline function axis_data(
- ::Type{S},
- dim::Symbol,
- nt::NamedTuple,
- axis::Union{AxisSpec, Nothing},
-) where {S <: AbstractPlotSpec}
- axis === nothing && return nothing
-
- # axis selector: nt.x / nt.y / nt.z
- selector = getfield(nt, dim)::Symbol
-
- # 1) slice + select_field unwrapping (no scaling)
- raw_vec = axis_slice(S, nt, axis, Val(dim))
-
- # 2) spec-level transform
- transformed = axis_transform(S, Val(dim), Val(selector), nt, axis, raw_vec)
-
- # 3) numeric check
- transformed isa AbstractArray ||
- Base.error(
- "AxisSpec $(dim) for $(S) did not resolve to an array; got $(typeof(transformed)).",
- )
-
- eltype(transformed) <: Number ||
- Base.error(
- "AxisSpec $(dim) for $(S) did not resolve to numeric data; got eltype $(eltype(transformed)).",
- )
-
- # 4) unit scaling
- sf = scale_factor(axis.quantity, axis.units)
- return sf .* transformed
-end
-
-"""
- make_series(::Type{S}, nt, axes) where {S<:AbstractPlotSpec}
-
-Builds the vector of SeriesSpec for the given spec and resolved input `nt`.
-
-Defaults to a single SeriesSpec corresponding to the primary primitive of
-this spec, using the axis data computed by `axis_data`. Specs that need
-multiple traces (overlays, histogram + CDF, etc.) should override this
-method and typically still call `axis_data` under the hood.
-"""
-function make_series(
- ::Type{S},
- nt::NamedTuple,
- axes::NamedTuple,
-) where {S <: AbstractPlotSpec}
- dims = geom_axes(S)
- dims = dims isa Tuple ? dims : (dims,)
-
- xaxis = axes.xaxis
- yaxis = axes.yaxis
- zaxis = axes.zaxis
-
- xdata = :x in dims ? axis_data(S, :x, nt, xaxis) : nothing
- ydata = :y in dims ? axis_data(S, :y, nt, yaxis) : nothing
- zdata = :z in dims ? axis_data(S, :z, nt, zaxis) : nothing
-
- kind = plot_kind(S)
- labels = legend_labels(S, nt)
- label = isempty(labels) ? nothing : first(labels)
-
- series = SeriesSpec(
- kind,
- xdata,
- ydata,
- zdata,
- label,
- )
-
- return SeriesSpec[series]
-end
\ No newline at end of file
diff --git a/src/plotbuilder/traits.jl b/src/plotbuilder/traits.jl
deleted file mode 100644
index bcff4a498..000000000
--- a/src/plotbuilder/traits.jl
+++ /dev/null
@@ -1,302 +0,0 @@
-
-
-# -----------------------------------------------------------------------------
-# Spec-level traits (configuration surface)
-# -----------------------------------------------------------------------------
-
-"""
-Kind of plotting primitive this spec corresponds to.
-
-Typical values: :line, :heatmap, :hist, :bar, :surface, ...
-"""
-plot_kind(::Type{S}) where {S <: AbstractPlotSpec} = :unknown
-
-"""
-Axes that can be toggled to log-scale at the UI level.
-
-Returns a tuple of axis dims, e.g.:
-
- enable_logscale(::Type{MySpec}) = (:x,) # only x can log
- enable_logscale(::Type{OtherSpec}) = (:x,:y) # x and y
-"""
-enable_logscale(::Type{S}) where {S <: AbstractPlotSpec} = ()
-
-"""
-Domain/container type this spec expects to dispatch on.
-
-Example:
-
- dispatch_on(::Type{MyRPlotSpec}) = LineParameters
-"""
-dispatch_on(::Type{S}) where {S <: AbstractPlotSpec} = Any
-
-"""
-Default figure size for this spec type, in pixels.
-
-This removes hard-coded consts in PlotUIComponents and pushes that
-decision into the grammar layer.
-"""
-default_figsize(::Type{S}) where {S <: AbstractPlotSpec} = (800, 400)
-
-"""
- axis_quantity(::Type{S}, ::Val{dim}) where {S<:AbstractPlotSpec, dim}
-
-Return the default semantic quantity for axis `dim` in spec `S`, when it
-does not depend on which data source is selected.
-
- axis_quantity(::Type{S}, ::Val{dim}, ::Val{datakey})
-
-Higher-ranked variant: given a data selector `datakey` (e.g. :f, :R, :L, :Z),
-return the semantic quantity for axis `dim`.
-
-The `datakey` is a symbol describing *where* data comes from in the container;
-it is not necessarily equal to the quantity name used in `QuantityTag{Q}`.
-"""
-axis_quantity(::Type{S}, dim::Symbol) where {S <: AbstractPlotSpec} =
- QuantityTag{:unknown}()
-
-axis_quantity(::Type{S}, ::Val{dim}) where {S <: AbstractPlotSpec, dim} =
- axis_quantity(S, dim)
-
-axis_quantity(
- ::Type{S},
- ::Val{dim},
- ::Val{datakey},
-) where {S <: AbstractPlotSpec, dim, datakey} =
- axis_quantity(S, dim)
-
-"""
- index_keys(::Type{S}) where {S<:AbstractPlotSpec}
-
-Semantic index parameters this spec uses to address elements of its underlying
-tensors (e.g. (:i, :j) for matrix-like data, (:i, :j, :k) for 3D, etc.).
-
-By default, no index keys are assumed. Specs that work on per-element data
-over frequencies should typically override this to `(:i, :j)`.
-"""
-index_keys(::Type{S}) where {S <: AbstractPlotSpec} = ()
-
-"""
- ranged_keys(::Type{S}) where {S<:AbstractPlotSpec}
-
-Index keys among `index_keys(S)` that may also be specified as ranges.
-
-A ranged index may be:
-
- • `Int` → single position (e.g. `k = 5`)
- • `AbstractUnitRange{<:Int}` → slice (e.g. `k = 1:10`, `k = 3:2:99`)
- • `:` → full range
-
-Default: empty tuple (no ranged indices).
-"""
-ranged_keys(::Type{S}) where {S <: AbstractPlotSpec} = ()
-
-"""
- geom_axes(::Type{S}) where {S<:AbstractPlotSpec}
-
-Geometric axes used by this spec, in order.
-Default is 2D (:x, :y). If your spec is 3D, override to return (:x, :y, :z).
-"""
-geom_axes(::Type{S}) where {S <: AbstractPlotSpec} = (:x, :y)
-
-
-# --------------------------------------------------------------------------
-# Title / legend grammar traits
-# --------------------------------------------------------------------------
-
-"""
- default_title(::Type{S}, nt) where {S<:AbstractPlotSpec}
-
-Return the default plot title for this spec, given the resolved input `nt`.
-`nt` is the output of `resolve_input(S, ...)`, so its structure is spec-defined.
-"""
-default_title(::Type{S}, nt::NamedTuple) where {S <: AbstractPlotSpec} = ""
-
-"""
- legend_labels(::Type{S}, nt) where {S<:AbstractPlotSpec}
-
-Return the legend entry labels for this spec, given the resolved input `nt`.
-Length of the returned vector must match the number of primitives produced
-by `make_series(S, nt)`.
-"""
-legend_labels(::Type{S}, nt::NamedTuple) where {S <: AbstractPlotSpec} = String[]
-
-
-# -----------------------------------------------------------------------------
-# Quantity-level unit and label hooks (using UnitHandler)
-# -----------------------------------------------------------------------------
-
-"""
-Spec-level hook: unit for specific spec + axis dim.
-
-By default, delegates to `display_unit(quantity)`, since plotting is a
-display concern. Specs can override for special cases if needed or to
-honour user overrides.
-"""
-axis_unit(::Type{S}, q::QuantityTag, dim::Symbol) where {S <: AbstractPlotSpec} =
- display_unit(q)
-
-
-# --------------------------------------------------------------------------
-# Input / backend grammar traits
-# --------------------------------------------------------------------------
-
-"""
- input_kwargs(::Type{S}) where {S<:AbstractPlotSpec}
-
-Plot-level *semantic* kwargs understood by this spec.
-
-These describe what is plotted or how the data is selected/sliced
-(e.g. :quantity, :stat, :i, :j, :k, :values_expr, ...).
-"""
-input_kwargs(::Type{S}) where {S <: AbstractPlotSpec} = ()
-
-"""
- renderer_kwargs(::Type{S}) where {S<:AbstractPlotSpec}
-
-Figure kwargs that are simply forwarded to the renderer that will be processed by the backend (Makie today, whatever tomorrow).
-"""
-renderer_kwargs(::Type{S}) where {S <: AbstractPlotSpec} = ()
-
-"""
-Root container inside `obj` for the data of axis `dim`.
-
-Default: `nothing` → use `obj` itself.
-
-For example, if `obj.stats` is a NamedTuple of tensors and y-axis data
-comes from there, define:
-
- data_container(::Type{MySpec}, ::Val{:y}) = :stats
-"""
-data_container(::Type{S}, ::Val{dim}) where {S <: AbstractPlotSpec, dim} = nothing
-
-# Child key for axis `dim` inside the container returned by `data_container`.
-# child_key = getfield(nt, select_field(S, Val(dim))) # e.g. :mean
-# data = obj.container.datakey[i,j,k].(child_key)
-select_field(::Type{S}, ::Val{dim}) where {S <: AbstractPlotSpec, dim} = nothing
-
-"""
- input_defaults(::Type{S}, obj) where {S<:AbstractPlotSpec}
-
-Defaults for semantic kwargs declared in `input_kwargs(S)`.
-
-May depend on the dispatched object `obj` (e.g. pick default `:quantity`
-from `obj` contents).
-"""
-input_defaults(::Type{S}, obj) where {S <: AbstractPlotSpec} = NamedTuple()
-
-"""
- renderer_defaults(::Type{S}, obj) where {S<:AbstractPlotSpec}
-
-Defaults for figure kwargs declared in `renderer_kwargs(S)`.
-
-This is where a spec declares its default color/linestyle/whatever,
-possibly depending on `obj` (e.g. per-phase colors).
-"""
-renderer_defaults(::Type{S}, obj) where {S <: AbstractPlotSpec} = NamedTuple()
-
-"""
-How to group dataseries into figures.
-Options: :auto -> let the machinery decide;
- :single -> one dataseries in one plot area (view), same axis;
- :overlay_ij -> one plot area (view), overlay all (i,j) on the same axis - target/leaf resolved to one field;
- :overlay_fields -> one plot area (view), overlay all fields on the same axis - data container resolved to one pair (i,j).
- :per_ij_overlay_fields -> multiple plot areas (views), one per (i,j), overlay all fields.
-"""
-grouping_mode(::Type{S}) where {S <: AbstractPlotSpec} = :auto
-
-"""
-How to render figures into Makie windows.
-Options: :single -> one view per window;
- :grid -> all views in a single window, arranged in a grid;
- :tabs -> TBD: all views in a single window, arranged in tabs.
-"""
-figure_layout(::Type{S}) where {S <: AbstractPlotSpec} = :single # default
-
-# --------------------------------------------------------------------------
-# Complex quantity / "as" traits (default: disabled)
-# --------------------------------------------------------------------------
-
-has_complex_qty(
- ::Type{S},
- ::Val{dim},
- ::Val{datakey},
-) where {S <: AbstractPlotSpec, dim, datakey} =
- false
-
-complex_as(
- ::Type{S},
- ::Val{dim},
- ::Val{datakey},
-) where {S <: AbstractPlotSpec, dim, datakey} =
- (:re, :im, :abs, :angle)
-
-complex_as_default(
- ::Type{S},
- ::Val{dim},
- ::Val{datakey},
-) where {S <: AbstractPlotSpec, dim, datakey} =
- :re
-
-# View-aware axis_quantity: fallback keeps existing grammar intact
-axis_quantity(
- ::Type{S},
- ::Val{dim},
- ::Val{datakey},
- ::Val{as},
-) where {S <: AbstractPlotSpec, dim, datakey, as} =
- axis_quantity(S, Val(dim), Val(datakey))
-
-# Z: re/im correspond to R/X
-axis_quantity(
- ::Type{S},
- ::Val{dim},
- ::Val{:Z},
- ::Val{:re},
-) where {S <: AbstractPlotSpec, dim} = QuantityTag{:resistance}()
-axis_quantity(
- ::Type{S},
- ::Val{dim},
- ::Val{:Z},
- ::Val{:im},
-) where {S <: AbstractPlotSpec, dim} = QuantityTag{:reactance}()
-
-# Y: re/im correspond to G/B
-axis_quantity(
- ::Type{S},
- ::Val{dim},
- ::Val{:Y},
- ::Val{:re},
-) where {S <: AbstractPlotSpec, dim} = QuantityTag{:conductance}()
-axis_quantity(
- ::Type{S},
- ::Val{dim},
- ::Val{:Y},
- ::Val{:im},
-) where {S <: AbstractPlotSpec, dim} = QuantityTag{:susceptance}()
-
-axis_quantity(
- ::Type{S},
- ::Val{dim},
- ::Val{:Z},
- ::Val{:abs},
-) where {S <: AbstractPlotSpec, dim} = QuantityTag{(:impedance, :abs)}()
-axis_quantity(
- ::Type{S},
- ::Val{dim},
- ::Val{:Z},
- ::Val{:angle},
-) where {S <: AbstractPlotSpec, dim} = QuantityTag{(:impedance, :angle)}()
-
-axis_quantity(
- ::Type{S},
- ::Val{dim},
- ::Val{:Y},
- ::Val{:abs},
-) where {S <: AbstractPlotSpec, dim} = QuantityTag{(:admittance, :abs)}()
-axis_quantity(
- ::Type{S},
- ::Val{dim},
- ::Val{:Y},
- ::Val{:angle},
-) where {S <: AbstractPlotSpec, dim} = QuantityTag{(:admittance, :angle)}()
diff --git a/src/plotbuilder/types.jl b/src/plotbuilder/types.jl
deleted file mode 100644
index 8e3d1f743..000000000
--- a/src/plotbuilder/types.jl
+++ /dev/null
@@ -1,104 +0,0 @@
-abstract type AbstractPlotSpec end
-
-"""
-AxisSpec is the fully decided axis descriptor used by plot areas (views).
-
-- `dim` : axis dimension (e.g. :x, :y, :z)
-- `quantity` : semantic quantity tag (from UnitHandler)
-- `units` : unit system for this axis
-- `label` : full label text, including unit symbol
-- `scale` : :linear or :log10
-"""
-struct AxisSpec
- dim::Symbol
- quantity::QuantityTag
- units::Units
- label::String
- scale::Symbol
-end
-
-# --------------------------------------------------------------------------
-# Payload hierarchy: series → view → figure → renderer
-# --------------------------------------------------------------------------
-"""
- SeriesSpec
-
-Single plot primitive (one Makie call).
-
-Fields:
-- `kind` : plotting primitive kind (:line, :scatter, :hist, :heatmap, ...)
-- `xdata` : x values \\[dimensionless or scaled\\]
-- `ydata` : y values \\[dimensionless or scaled\\]
-- `zdata` : z values if applicable, otherwise `nothing`
-- `label` : legend entry for this series, or `nothing` for no legend
-"""
-struct SeriesSpec
- kind :: Symbol
- xdata :: Union{Nothing, AbstractVector{<:Number}}
- ydata :: Union{Nothing, AbstractArray{<:Number}}
- zdata :: Union{Nothing, AbstractArray{<:Number}}
- label :: Union{Nothing, String}
-end
-
-"""
- ViewSpec
-
-One plot view / axis system.
-
-All SeriesSpec inside a ViewSpec share the same x/y(/z) axes. The `key`
-field encodes the semantic facet this area represents (e.g. \\(i,j\\),
-quantity, frequency).
-
-Fields:
-- `xaxis` : x AxisSpec or `nothing` if unused
-- `yaxis` : y AxisSpec or `nothing` if unused
-- `zaxis` : z AxisSpec or `nothing` if unused
-- `title` : view title
-- `series` : vector of SeriesSpec
-- `key` : NamedTuple identifying the facet, or empty `NamedTuple` if none
-"""
-struct ViewSpec
- xaxis :: Union{Nothing, AxisSpec}
- yaxis :: Union{Nothing, AxisSpec}
- zaxis :: Union{Nothing, AxisSpec}
- title :: String
- series :: Vector{SeriesSpec}
- key :: NamedTuple
-end
-
-"""
- PageSpec
-
-One logical figure / window.
-
-Fields:
-- `title` : optional figure-level title (may be empty)
-- `size` : (width, height) in pixels
-- `layout` : layout spec (:windows, :grid, :tabbed, ...)
-- `views` : vector of ViewSpec values contained in this figure
-- `kwargs` : figure-level backend options (e.g. figsize)
-"""
-struct PageSpec
- title :: String
- size :: Tuple{Int, Int}
- layout :: Symbol
- views :: Vector{ViewSpec}
- kwargs :: NamedTuple
-end
-
-"""
- RenderSpec{S}
-
-Final product of the grammar pipeline for spec type `S`.
-
-Carries only:
-- the spec type (the grammar definition),
-- a vector of PageSpec payloads.
-
-The rendering backend (Makie) should only see RenderSpec values and must
-never touch domain objects or grammar logic.
-"""
-struct RenderSpec{S <: AbstractPlotSpec}
- spec :: Type{S}
- figures :: Vector{PageSpec}
-end
diff --git a/src/plotbuilder/uicomponents/UIComponents.jl b/src/plotbuilder/uicomponents/UIComponents.jl
deleted file mode 100644
index 8570c07b2..000000000
--- a/src/plotbuilder/uicomponents/UIComponents.jl
+++ /dev/null
@@ -1,23 +0,0 @@
-module UIComponents
-
-using Makie
-
-import ..BackendHandler
-
-import ..PlotBuilder: AbstractPlotSpec, RenderSpec, PageSpec, ViewSpec, SeriesSpec, AxisSpec
-
-export build, export_svg!,
- UIContext, UILayoutSpec, UIContainerSpec, UISlotSpec,
- UIFigure, UIPanel, PlotAssembly
-
-export build_context, display!
-
-include("themes.jl")
-include("types.jl")
-include("layoutspecs.jl")
-include("actions.jl")
-include("draw.jl")
-include("widgets.jl")
-include("pipeline.jl")
-
-end # module UIComponents
diff --git a/src/plotbuilder/uicomponents/actions.jl b/src/plotbuilder/uicomponents/actions.jl
deleted file mode 100644
index 9b0ef4a46..000000000
--- a/src/plotbuilder/uicomponents/actions.jl
+++ /dev/null
@@ -1,67 +0,0 @@
-function action_set_status!(ctx::UIContext, msg::AbstractString)
- if ctx.status !== nothing
- ctx.status[] = String(msg)
- end
- return nothing
-end
-
-# Adapting signature to match build! call (ctx, uifig, btn) -> needs mapping to Assembly?
-# Ideally, we pass the Assembly.
-# We can rely on the fact that UIPlot is the container.
-# Let's assume the action receives (ctx, assem::UIPlot, widget)
-# The build! loop in pipeline.jl needs to wrap this.
-# Correction: In pipeline.jl, we can't fully bind UIPlot because it's being built.
-# However, `uifig` contains everything graphical.
-# `action_refresh` needs access to panels. `uifig` does NOT store panels directly (UIPlot does).
-# We should store panels in uifig or return to pipeline to bind actions AFTER UIPlot creation.
-# Let's fix pipeline.jl logic in the next iteration or use a workaround here.
-# Workaround: action_refresh takes `uifig` and assumes it can find axes?
-# No, `uifig` has `containers`. We can iterate `uifig.slots[:canvas].content`.
-
-function action_refresh(uifig::UIFigure)
- # Iterate over axes in the canvas slot
- canvas = uifig.slots[:canvas]
- for content in canvas.content
- if content.content isa Makie.Axis
- Makie.autolimits!(content.content)
- end
- end
- return nothing
-end
-
-# Signature overload for compatibility if called with UIPlot
-action_refresh(assem::UIPlot) = action_refresh(assem.uifig)
-
-function action_export_svg!(ctx::UIContext, assem_or_fig, path::AbstractString)
- # We need the spec and page to re-render.
- # If we only have uifig, we are stuck.
- # The widgets need the UIPlot.
- # FIX: The widgets must be wired up AFTER UIPlot is created in pipeline.jl.
- # See note in pipeline.jl.
- error("Export requires full UIPlot assembly context.")
-end
-
-function action_export_svg!(ctx::UIContext, assem::UIPlot, path::AbstractString)
- action_set_status!(ctx, "Exporting to $path...")
-
- BackendHandler.with_backend(:cairo) do
- # THEME SWITCHING: Force interactive=false for export style
- export_theme = make_theme(ctx; interactive = false)
-
- Makie.with_theme(export_theme) do
- # Reconstruct RenderSpec from the UIPlot data
- r = RenderSpec(assem.spec, PageSpec[assem.page])
-
- # Render headless
- new_assems = render(r; backend = :cairo, display = false)
-
- if !isempty(new_assems)
- target_fig = new_assems[1].uifig.figure
- Makie.save(path, target_fig)
- end
- end
- end
-
- action_set_status!(ctx, "Saved SVG to $path")
- return nothing
-end
diff --git a/src/plotbuilder/uicomponents/draw.jl b/src/plotbuilder/uicomponents/draw.jl
deleted file mode 100644
index 3ec32ee75..000000000
--- a/src/plotbuilder/uicomponents/draw.jl
+++ /dev/null
@@ -1,54 +0,0 @@
-# -------------------------
-# The Artist: draw!
-# -------------------------
-
-function draw!(axis, s::SeriesSpec; kwargs...)
- draw!(Val(s.kind), axis, s; kwargs...)
-end
-
-function draw!(::Val{kind}, axis, s::SeriesSpec; kwargs...) where {kind}
- @warn "Unsupported plot kind :$kind"
- return Any[]
-end
-
-function draw!(::Val{:line}, axis, s::SeriesSpec; kwargs...)
- (s.xdata === nothing || s.ydata === nothing) && return Any[]
-
- plots = Any[]
- if s.ydata isa AbstractMatrix
- for k in 1:size(s.ydata, 2)
- # PBSeries/SeriesSpec only supports one label.
- # We label the first trace for the legend.
- lbl = (k==1) ? s.label : nothing
- p = Makie.lines!(axis, s.xdata, view(s.ydata, :, k); label = lbl, kwargs...)
- push!(plots, p)
- end
- else
- p = Makie.lines!(axis, s.xdata, s.ydata; label = s.label, kwargs...)
- push!(plots, p)
- end
- return plots
-end
-
-function draw!(::Val{:scatter}, axis, s::SeriesSpec; kwargs...)
- (s.xdata === nothing || s.ydata === nothing) && return Any[]
-
- plots = Any[]
- if s.ydata isa AbstractMatrix
- for k in 1:size(s.ydata, 2)
- lbl = (k==1) ? s.label : nothing
- p = Makie.scatter!(axis, s.xdata, view(s.ydata, :, k); label = lbl, kwargs...)
- push!(plots, p)
- end
- else
- p = Makie.scatter!(axis, s.xdata, s.ydata; label = s.label, kwargs...)
- push!(plots, p)
- end
- return plots
-end
-
-function draw!(::Val{:heatmap}, axis, s::SeriesSpec; kwargs...)
- (s.xdata === nothing || s.ydata === nothing || s.zdata === nothing) && return Any[]
- p = Makie.heatmap!(axis, s.xdata, s.ydata, s.zdata; kwargs...)
- return Any[p]
-end
\ No newline at end of file
diff --git a/src/plotbuilder/uicomponents/layoutspecs.jl b/src/plotbuilder/uicomponents/layoutspecs.jl
deleted file mode 100644
index 3a3f9f6c9..000000000
--- a/src/plotbuilder/uicomponents/layoutspecs.jl
+++ /dev/null
@@ -1,75 +0,0 @@
-# -------------------------
-# The Architect: make_layout
-# -------------------------
-
-function make_layout(::Val{layout}) where {layout}
- error("make_layout(Val(:$layout)) not implemented")
-end
-
-function make_layout(::Val{:single})
- # 1. ROOT CONFIGURATION (The global visual effect)
- # We define a container for :root to apply gaps and padding.
- root = UIContainerSpec(
- :root, nothing, (1, 1); # Position ignored for root
- layout = (;
- rowgap = GRID_ROW_GAP,
- colgap = LEGEND_GAP, # The gap between Canvas and Legend
- alignmode = Makie.Outside(FIG_PADDING...), # (L, R, B, T)
- ),
- )
-
- # 2. SLOTS
- slots = [
- # Toolbar: Rigid height, internal spacing for buttons
- UISlotSpec(:toolbar, :root, (1, 1);
- layout = (;
- height = CTLBAR_HEIGHT,
- tellheight = true,
- colgap = CTLBAR_GAP # Spacing between buttons
- ),
- ),
-
- # Canvas: Takes available space
- UISlotSpec(:canvas, :root, (2, 1);
- layout = (;
- alignmode = Makie.Inside() # Standard plot behavior
- ),
- ),
-
- # Status: Rigid height
- UISlotSpec(:status, :root, (3, 1);
- layout = (;
- height = STATUSBAR_HEIGHT,
- tellheight = true,
- ),
- ),
-
- # Legend: Fixed width column
- UISlotSpec(:legend, :root, (1:3, 2);
- layout = (;
- width = LEGEND_WIDTH, # Enforce width at slot level too for safety
- tellwidth = true,
- alignmode = Makie.Inside(),
- ),
- attrs = (; valign = :top),
- ),
- ]
-
- # 3. ROOT SIZING (Structural constraints)
- rs = Dict(
- :root => Any[
- Makie.Fixed(CTLBAR_HEIGHT),
- Makie.Relative(1.0),
- Makie.Fixed(STATUSBAR_HEIGHT),
- ],
- )
-
- cs = Dict(:root => Any[
- Makie.Relative(1.0),
- Makie.Fixed(LEGEND_WIDTH),
- ])
-
- return UILayoutSpec(:single, [root], slots, rs, cs)
-end
-
-make_layout(::Val{:grid}) = make_layout(Val(:single))
\ No newline at end of file
diff --git a/src/plotbuilder/uicomponents/pipeline.jl b/src/plotbuilder/uicomponents/pipeline.jl
deleted file mode 100644
index 276631779..000000000
--- a/src/plotbuilder/uicomponents/pipeline.jl
+++ /dev/null
@@ -1,310 +0,0 @@
-# -------------------------
-# The Architect: make_context
-# -------------------------
-
-function make_context(;
- backend::Union{Nothing, Symbol} = nothing,
- display::Bool = true,
- title::AbstractString = "LineCableModels Plot",
- theme::Union{Nothing, Makie.Theme} = nothing,
- use_latex_fonts::Bool = false,
- kwargs...,
-)
- active_backend = BackendHandler.ensure_backend!(backend)
- interactive = (display && active_backend in (:gl, :wgl))
-
- stat = interactive ? Makie.Observable("Ready.") : nothing
-
- win = nothing
- scr = nothing
- if interactive && active_backend == :gl
- scr = BackendHandler.make_screen(title; backend = :gl)
- win = scr
- end
-
- # Default theme
- default_theme = make_theme(; interactive, use_latex_fonts)
-
- # 2. Build official theme (uses ctx.interactive by default)
- built_theme = theme === nothing ? default_theme : merge(default_theme, theme)
-
- return UIContext(
- active_backend,
- interactive,
- use_latex_fonts,
- win,
- scr,
- stat,
- built_theme,
- )
-end
-
-# -------------------------
-# The Boss: render
-# -------------------------
-
-function build(
- r::RenderSpec{S};
- backend = nothing,
- display::Bool = true,
- kwargs...,
-) where {S}
- ctx = make_context(; backend = backend, display = display, kwargs...)
- assemblies = UIPlot[]
-
- Makie.with_theme(ctx.theme) do
- for page in r.figures # page is PageSpec
-
- # A. Architect
- layout_s = make_layout(Val(page.layout))
-
- # B. Constructor (Shell)
- uifig = build_figure(ctx, page, layout_s)
-
- # C. Constructor (Panels)
- panels = UIPanel[]
- for view in page.views # view is ViewSpec
- push!(panels, build_panel!(uifig, view))
- end
-
- # D. Constructor (Decorations)
- widgets_s = make_widgets(S, ctx, page, panels)
- widgets_dict = build_toolbar!(uifig, widgets_s, ctx)
-
- build_statusbar!(uifig, Val(:status), ctx)
- build_legend!(uifig, Val(:legend), panels)
-
- # E. Assembly
- assem = UIPlot(S, ctx, page, uifig, panels, widgets_dict)
- push!(assemblies, assem)
-
- if display
- display!(ctx, assem)
- end
- end
- end
- return assemblies
-end
-
-# -------------------------
-# The Constructors: build_ / build_!
-# -------------------------
-
-# Helper to find spec for a slot name (to retrieve attrs)
-function get_slot_spec(uifig::UIFigure, name::Symbol)
- idx = findfirst(s -> s.name == name, uifig.layoutspec.slots)
- return idx === nothing ? nothing : uifig.layoutspec.slots[idx]
-end
-
-function build_figure(ctx::UIContext, page::PageSpec, ls::UILayoutSpec)
- kw = page.kwargs
- safe_kw = (; (k=>v for (k, v) in pairs(kw) if k != :size && k != :resolution)...)
-
- fig = Makie.Figure(; size = page.size, safe_kw...)
-
- containers = Dict{Symbol, Makie.GridLayout}()
- slots = Dict{Symbol, Any}()
-
- containers[:root] = fig.layout
-
- # --- PHASE 1: Materialize Slots/Containers ---
- # Uses `s.layout` for Grid properties
-
- # 1a. Intermediate Containers (and Root configuration)
- for c in ls.containers
- if c.name == :root
- # SPECIAL CASE: Configuration for the main Figure layout
- # Apply gaps, alignmode (padding), etc.
- gl = containers[:root]
- for (k, v) in pairs(c.layout)
- # We use setproperty! or specific Makie functions for gaps
- if k == :rowgap
- Makie.rowgap!(gl, v)
- elseif k == :colgap
- Makie.colgap!(gl, v)
- else
- # alignmode, etc.
- setproperty!(gl, k, v)
- end
- end
- else
- # Standard nested container creation
- parent_gl = containers[c.parent]
- subgl = Makie.GridLayout(; c.layout...)
- parent_gl[c.at...] = subgl
- containers[c.name] = subgl
- end
- end
-
- # 1b. Slots (Terminals)
- for s in ls.slots
- parent_gl = containers[s.parent]
- subgl = Makie.GridLayout(; s.layout...)
- parent_gl[s.at...] = subgl
- slots[s.name] = subgl
-
- # --- DEBUG: VISUALIZE SLOTS ---
- # Makie.Box(parent_gl[s.at...], color = (:red, 0.2), strokewidth = 0)
- end
-
- # --- PHASE 2: Apply Sizing ---
-
- for (name, sizes) in ls.rowsizes
- gl = get(containers, name, nothing)
- gl === nothing && continue
- for (i, s) in enumerate(sizes)
- Makie.rowsize!(gl, i, s)
- end
- end
-
- for (name, sizes) in ls.colsizes
- gl = get(containers, name, nothing)
- gl === nothing && continue
- for (i, s) in enumerate(sizes)
- Makie.colsize!(gl, i, s)
- end
- end
-
- # Grid Shape Logic
- n = length(page.views)
- panel_shape = (1, 1)
- if n > 1
- nr = ceil(Int, sqrt(n))
- nc = ceil(Int, n / nr)
- panel_shape = (nr, nc)
- end
-
- return UIFigure(fig, ls, containers, slots, Ref(0), panel_shape)
-end
-
-function build_panel!(uifig::UIFigure, view::ViewSpec)
- target_gl = uifig.slots[:canvas]
- nr, nc = uifig.panelshape
-
- ax_pos = if nr > 1 || nc > 1
- uifig.cursor[] += 1
- k = uifig.cursor[]
- row = (k - 1) ÷ nc + 1
- col = (k - 1) % nc + 1
- target_gl[row, col]
- else
- target_gl[1, 1]
- end
-
- # Retrieve 'attrs' for content
- slot_spec = get_slot_spec(uifig, :canvas)
- slot_attrs = slot_spec !== nothing ? slot_spec.attrs : (;)
-
- ax = Makie.Axis(ax_pos;
- xlabel = something(view.xaxis.label, ""),
- ylabel = something(view.yaxis.label, ""),
- title = view.title,
- xscale = (view.xaxis.scale == :log10) ? Makie.log10 : Makie.identity,
- yscale = (view.yaxis.scale == :log10) ? Makie.log10 : Makie.identity,
- slot_attrs...,
- )
-
- plots = Any[]
- for s in view.series # s is SeriesSpec
- append!(plots, draw!(ax, s))
- end
-
- return UIPanel(view, ax, plots)
-end
-
-function build_toolbar!(uifig::UIFigure, specs::Vector{UIWidgetSpec}, ctx::UIContext)
- dict = Dict{Symbol, Any}()
- haskey(uifig.slots, :toolbar) || return dict
-
- gl = uifig.slots[:toolbar]
- gl.halign = :left
-
- for (i, s) in enumerate(specs)
- # --- BUTTON ---
- if s isa UIButtonSpec
- lbl = (s.icon !== nothing) ? with_icon(s.icon; text = s.label) : s.label
-
- btn = Makie.Button(gl[1, i]; label = lbl, s.attrs...)
- dict[Symbol(:btn_, i)] = btn
-
- Makie.on(btn.clicks) do _
- Base.@async begin
- try
- s.action(ctx, uifig, btn)
- catch e
- @error "Widget error" exception=(e, catch_backtrace())
- action_set_status!(ctx, "Error: $(e)")
- end
- end
- end
-
- # --- TOGGLE ---
- elseif s isa UIToggleSpec
- # Container for [Label | Toggle] to keep them grouped in the toolbar slot
- sub = Makie.GridLayout(gl[1, i])
-
- # 1. Label
- Makie.Label(sub[1, 1], s.label, halign = :right)
-
- # 2. Toggle
- tgl = Makie.Toggle(sub[1, 2]; active = s.active, s.attrs...)
- dict[Symbol(:tgl_, i)] = tgl
-
- Makie.on(tgl.active) do val
- Base.@async begin
- try
- s.action(ctx, uifig, val)
- catch e
- @error "Widget error" exception=(e, catch_backtrace())
- action_set_status!(ctx, "Error: $(e)")
- end
- end
- end
-
- # Tweak subgrid spacing
- Makie.colgap!(sub, 4)
- end
- end
- return dict
-end
-
-function build_statusbar!(uifig::UIFigure, ::Val{:status}, ctx::UIContext)
- haskey(uifig.slots, :status) || return
- gl = uifig.slots[:status]
- txt = (ctx.status !== nothing) ? ctx.status : Makie.Observable("")
- Makie.Label(gl[1, 1], txt, halign = :left, fontsize = 12)
-end
-
-function build_legend!(uifig::UIFigure, ::Val{:legend}, panels::Vector{UIPanel})
- haskey(uifig.slots, :legend) || return
-
- seen = Set{String}()
- elements = Any[]
- labels = String[]
-
- for p in panels, plt in p.plots
- if hasproperty(plt, :label)
- lbl = plt.label[]
- if lbl !== nothing && !isempty(lbl) && !(lbl in seen)
- push!(elements, plt)
- push!(labels, lbl)
- push!(seen, lbl)
- end
- end
- end
-
- if !isempty(elements)
- slot_spec = get_slot_spec(uifig, :legend)
- slot_attrs = slot_spec !== nothing ? slot_spec.attrs : (;)
-
- Makie.Legend(uifig.slots[:legend][1, 1], elements, labels; slot_attrs...)
- end
-end
-
-function display!(ctx::UIContext, assem::UIPlot)
- if ctx.interactive && ctx.window !== nothing
- display(ctx.window, assem.uifig.figure)
- else
- BackendHandler.renderfig(assem.uifig.figure)
- end
-end
\ No newline at end of file
diff --git a/src/plotbuilder/uicomponents/themes.jl b/src/plotbuilder/uicomponents/themes.jl
deleted file mode 100644
index 53f554d3b..000000000
--- a/src/plotbuilder/uicomponents/themes.jl
+++ /dev/null
@@ -1,108 +0,0 @@
-using Printf: @sprintf
-
-# -----------------------------------------------------------------------------
-# 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)
-const TICK_FMT = x -> @sprintf("%.4g", x)
-const TICKFORMATTER = values -> [TICK_FMT(v) for v in values]
-const EXPORT_TIMESTAMP_FORMAT = "yyyymmdd_HHMMSS"
-const EXPORT_EXTENSION = "svg"
-
-# -----------------------------------------------------------------------------
-# Material UI icons
-# -----------------------------------------------------------------------------
-const MI_REFRESH = "\uE5D5" # Material Icons: 'refresh'
-const MI_SAVE = "\uE161" # Material Icons: 'save'
-
-# -----------------------------------------------------------------------------
-# Helpers
-# -----------------------------------------------------------------------------
-
-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)),
- )
-
-"""
- make_theme(; interactive::Bool, use_latex_fonts::Bool)
-
-Returns the package-specific Theme delta.
-"""
-function make_theme(; interactive::Bool, use_latex_fonts::Bool)
- background = interactive ? BG_COLOR_INTERACTIVE : BG_COLOR_EXPORT
-
- # Base configuration
- config = Dict{Symbol, Any}(
- :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,
- xtickformat = TICKFORMATTER,
- ytickformat = TICKFORMATTER,
- ),
- :Legend => (
- fontsize = AXIS_LABEL_FONT_SIZE,
- labelsize = AXIS_LABEL_FONT_SIZE,
- ),
- :Colorbar => (
- labelsize = AXIS_LABEL_FONT_SIZE,
- ticklabelsize = AXIS_TICK_FONT_SIZE,
- ),
- )
-
- # Conditional logic: Fonts
- # 1. Latex fonts (Export only)
- if use_latex_fonts && !interactive
- # merge! is safe on Dicts
- merge!(config, Makie.theme_latexfonts().attributes)
- end
-
- # 2. Icon fonts (Always try to load)
- font_path = joinpath(
- pkgdir(@__MODULE__),
- "assets",
- "fonts",
- "material-icons",
- "MaterialIcons-Regular.ttf",
- )
- if isfile(font_path)
- current_fonts = get(config, :fonts, (;))
- # Convert to NamedTuple to simple merge
- new_fonts = merge(current_fonts, (; icons = font_path))
- config[:fonts] = new_fonts
- end
-
- return Makie.Theme(; config...)
-end
diff --git a/src/plotbuilder/uicomponents/types.jl b/src/plotbuilder/uicomponents/types.jl
deleted file mode 100644
index bc0f70db2..000000000
--- a/src/plotbuilder/uicomponents/types.jl
+++ /dev/null
@@ -1,95 +0,0 @@
-# -------------------------
-# UI Logic Specs (Recipes)
-# -------------------------
-
-struct UIContainerSpec
- name::Symbol
- parent::Union{Nothing, Symbol}
- at::Tuple # Relaxed from strict Union types to generic Tuple
- layout::NamedTuple
-end
-
-UIContainerSpec(name, parent, at; layout = (;)) =
- UIContainerSpec(name, parent, at, layout)
-
-struct UISlotSpec
- name::Symbol
- parent::Symbol
- at::Tuple # Relaxed from strict Union types
- layout::NamedTuple # Grid properties (e.g. alignmode, height)
- attrs::NamedTuple # Content properties (e.g. Axis background, Legend align)
-end
-
-# Robust helper constructor
-UISlotSpec(name, parent, at; layout = (;), attrs = (;)) =
- UISlotSpec(name, parent, at, layout, attrs)
-
-struct UILayoutSpec
- name::Symbol
- containers::Vector{UIContainerSpec}
- slots::Vector{UISlotSpec}
- rowsizes::Dict{Symbol, Vector{Any}}
- colsizes::Dict{Symbol, Vector{Any}}
-end
-
-abstract type UIWidgetSpec end
-
-struct UIButtonSpec <: UIWidgetSpec
- label::String
- icon::Union{Nothing, String}
- action::Function # (ctx, uifig, button) -> nothing
- attrs::NamedTuple # Passed to Makie.Button
-end
-
-# Constructor
-UIButtonSpec(label, icon, action; attrs = (;)) =
- UIButtonSpec(label, icon, action, attrs)
-
-struct UIToggleSpec <: UIWidgetSpec
- label::String
- active::Bool
- action::Function # (ctx, uifig, active::Bool) -> nothing
- attrs::NamedTuple # Passed to Makie.Toggle
-end
-
-# Constructor
-UIToggleSpec(label, active, action; attrs = (;)) =
- UIToggleSpec(label, active, action, attrs)
-
-# -------------------------
-# UI Instances (Objects)
-# -------------------------
-
-mutable struct UIContext
- backend::Symbol
- interactive::Bool
- use_latex_fonts::Bool
- window::Union{Nothing, Any}
- screen::Union{Nothing, Any}
- status::Union{Nothing, Makie.Observable{String}}
- theme::Makie.Theme
-end
-
-struct UIFigure
- figure::Makie.Figure
- layoutspec::UILayoutSpec
- containers::Dict{Symbol, Makie.GridLayout}
- slots::Dict{Symbol, Any}
- cursor::Base.RefValue{Int}
- panelshape::Tuple{Int, Int}
-end
-
-struct UIPanel
- view::ViewSpec
- axis::Any
- plots::Vector{Any}
-end
-
-struct UIPlot
- spec::DataType
- ctx::UIContext
- page::PageSpec
- uifig::UIFigure
- panels::Vector{UIPanel}
- widgets::Dict{Symbol, Any}
-end
\ No newline at end of file
diff --git a/src/plotbuilder/uicomponents/widgets.jl b/src/plotbuilder/uicomponents/widgets.jl
deleted file mode 100644
index df2664aff..000000000
--- a/src/plotbuilder/uicomponents/widgets.jl
+++ /dev/null
@@ -1,34 +0,0 @@
-# -------------------------
-# Traits (Declarative)
-# -------------------------
-
-function controls_default(::Type{S}, ctx::UIContext, page, panels) where {S}
- !ctx.interactive && return UIWidgetSpec[]
-
- return UIWidgetSpec[
- UIButtonSpec(
- "",
- MI_REFRESH,
- (c, a, o) -> action_refresh(a),
- ),
- UIButtonSpec(
- "",
- MI_SAVE,
- (c, a, o) -> action_export_svg!(c, a, "plot_export.svg"),
- ),
- ]
-end
-
-function controls_custom(::Type{S}, ctx, page, panels) where {S}
- return UIWidgetSpec[]
-end
-
-# -------------------------
-# The Architect: make_widgets
-# -------------------------
-
-function make_widgets(::Type{S}, ctx::UIContext, page, panels) where {S}
- w = controls_default(S, ctx, page, panels)
- append!(w, controls_custom(S, ctx, page, panels))
- return w
-end
\ No newline at end of file
diff --git a/src/plotbuilder/viewspec.jl b/src/plotbuilder/viewspec.jl
deleted file mode 100644
index 65f5d46f7..000000000
--- a/src/plotbuilder/viewspec.jl
+++ /dev/null
@@ -1,31 +0,0 @@
-"""
- make_views(::Type{S}, nt, axes, series) where {S<:AbstractPlotSpec}
-
-Groups SeriesSpec into ViewSpec values.
-
-Default behavior:
-- all series share the same axes `axes.xaxis`, `axes.yaxis`, `axes.zaxis`,
-- one ViewSpec is created,
-- `title` is taken from `default_title(S, nt)`,
-- `key` is the empty NamedTuple `(; )` (no faceting semantics).
-
-Specs that require multiple views (e.g. grids over indices or frequencies)
-should override this method and partition `series` accordingly, setting
-a meaningful `key` for each ViewSpec.
-"""
-function make_views(
- ::Type{S},
- nt::NamedTuple,
- axes::NamedTuple,
- series::Vector{SeriesSpec},
-) where {S <: AbstractPlotSpec}
- title = default_title(S, nt)
- key = (;)
-
- xaxis = axes.xaxis
- yaxis = axes.yaxis
- zaxis = axes.zaxis
-
- view = ViewSpec(xaxis, yaxis, zaxis, title, series, key)
- return ViewSpec[view]
-end
\ No newline at end of file
diff --git a/src/pscad/PSCAD.jl b/src/pscad/PSCAD.jl
new file mode 100644
index 000000000..839f54741
--- /dev/null
+++ b/src/pscad/PSCAD.jl
@@ -0,0 +1,52 @@
+"""
+ PSCAD
+
+PSCAD model exchange and native line-parameter computation. Loading this module
+defines the backend without contacting a station or launching PSCAD.
+"""
+module PSCAD
+
+using Base64: base64encode
+using SHA: sha256
+import TOML
+import Logging
+using LineCableModels
+using LineCableModels.DataModel: LineCableSystem
+using LineCableModels.Earth: EarthModel
+using LineCableModels.Commons: vacuum_permittivity
+using LineCableModels.Engine
+using LineCableModels.ImportExport
+import LineCableModels: description, parameterize, computation_details, validate,
+ Expression, verbosity
+import LineCableModels.Engine.EarthImpedance: earth_impedance
+import LineCableModels.Engine.EarthAdmittance: earth_potential_coefficient
+import LineCableModels.Engine.InternalImpedance: internal_impedance
+import LineCableModels.Engine.InsulationImpedance: insulation_impedance
+import LineCableModels.Engine: Formulation,
+ LineParametersProblem
+import LineCableModels.Commons: AbstractFormulation, ComputationOptions, ComputationDetails,
+ FormulationOptions, computation_options, compute,
+ formulation_options, gridpoint_id
+using DocStringExtensions: TYPEDSIGNATURES, TYPEDEF, TYPEDFIELDS
+import LineCableModels: constitutive
+import LineCableModels.DataModel
+import LineCableModels.Engine
+import LineCableModels.ImportExport: import_data, export_data
+import EzXML
+using EzXML: ElementNode, XMLDocument, addelement!, nodename,
+ readxml, root, setroot!
+
+export PSCADFormulation, RemoteConfig,
+ read_pscad_result, remote_command, run_remote_pscad, identify
+public pscad_setting
+
+include("formulations.jl")
+include("importexport/pscad.jl")
+include("results.jl")
+include("validate.jl")
+include("remote/configuration.jl")
+include("remote/files.jl")
+include("remote/remote.jl")
+include("compute.jl")
+
+end
diff --git a/src/pscad/compute.jl b/src/pscad/compute.jl
new file mode 100644
index 000000000..1a1d487ff
--- /dev/null
+++ b/src/pscad/compute.jl
@@ -0,0 +1,420 @@
+function computation_options(
+ ::Type{PSCADFormulation},
+ record::ComputationOptions
+)::ComputationOptions
+ options = record.data
+ allowed = (:output_stem, :remote, :verbosity, :output_basis, :on_result,
+ :resume_run_directory, :solver_identity, :work_root, :timing)
+ unknown = filter(key -> key ∉ allowed, keys(options))
+ isempty(unknown) || throw(ArgumentError(
+ "unknown PSCAD computation options: $(sort!(collect(unknown)))",
+ ))
+ haskey(options, :remote) || throw(ArgumentError(
+ "PSCAD computation options must define remote::RemoteConfig",
+ ))
+ options.remote isa RemoteConfig || throw(ArgumentError(
+ "PSCAD computation option remote must be a RemoteConfig",
+ ))
+ normalized = merge(
+ (
+ output_stem = "lcm",
+ work_root = options.remote.local_root,
+ verbosity = (default = 0,),
+ output_basis = :pul,
+ on_result = nothing,
+ resume_run_directory = nothing,
+ solver_identity = nothing,
+ timing = false
+ ),
+ options
+ )
+ normalized.output_stem isa AbstractString || throw(ArgumentError(
+ "PSCAD output_stem must be a string",
+ ))
+ output_stem = String(normalized.output_stem)
+ occursin(r"^[A-Za-z0-9][A-Za-z0-9_]{0,19}$", output_stem) ||
+ throw(ArgumentError(
+ "PSCAD output_stem must contain 1–20 ASCII letters, digits, or underscores",
+ ))
+ levels = verbosity(normalized.verbosity)
+ normalized.timing isa Bool || throw(ArgumentError("timing must be Bool"))
+ basis_value = normalized.output_basis
+ basis_value in (:pul, :total) || throw(ArgumentError(
+ "output_basis must be :pul or :total; got $(repr(basis_value))",
+ ))
+ resume = normalized.resume_run_directory
+ (resume === nothing || resume === :latest || resume isa AbstractString) ||
+ throw(ArgumentError("PSCAD resume_run_directory must be nothing, :latest, or a completed run path"))
+ resume isa AbstractString && isempty(resume) &&
+ throw(ArgumentError(
+ "PSCAD resume_run_directory cannot be empty"))
+ identity = normalized.solver_identity
+ (identity === nothing || identity isa AbstractDict{String, String}) ||
+ throw(ArgumentError("PSCAD solver_identity must be the record returned by identify(remote)"))
+ identity = identity === nothing ? nothing :
+ Dict{String, String}(validate(identity, options.remote))
+ options.remote.transport === :local && !Sys.iswindows() && throw(ArgumentError(
+ "PSCAD local transport requires a Windows caller"))
+ work_root = abspath(normalized.work_root)
+ first(splitpath(relpath(work_root, options.remote.local_root))) == ".." &&
+ throw(ArgumentError("PSCAD work_root must be inside remote.local_root"))
+ return ComputationOptions(;
+ work_root,
+ output_stem,
+ remote = options.remote,
+ verbosity = levels,
+ timing = normalized.timing,
+ output_basis = Val(basis_value),
+ on_result = normalized.on_result,
+ resume_run_directory = resume isa AbstractString ? abspath(resume) : resume,
+ solver_identity = identity
+ )
+end
+
+function _pscad_size(problem::LineParametersProblem)
+ assignments = problem.system.connection_order
+ isempty(assignments) && throw(ArgumentError(
+ "PSCAD computation requires at least one explicit terminal",
+ ))
+ any(iszero, assignments) && throw(ArgumentError(
+ "PSCAD computation does not permit conductor elimination",
+ ))
+ all(>(0), assignments) || throw(ArgumentError(
+ "PSCAD computation phase assignments must identify active phases",
+ ))
+ length(unique(assignments)) == length(assignments) || throw(ArgumentError(
+ "PSCAD computation does not permit bundled terminals",
+ ))
+ sort(assignments) == collect(1:length(assignments)) || throw(ArgumentError(
+ "PSCAD computation active-phase assignments must be contiguous from 1",
+ ))
+ return (length(assignments), length(assignments), length(problem.frequencies))
+end
+
+function _pscad_basis(parameters, ::LineParametersProblem, ::Val{:pul})
+ parameters
+end
+
+function _pscad_basis(
+ parameters::LineParameters,
+ problem::LineParametersProblem,
+ ::Val{:total}
+)
+ return LineParameters(
+ PhaseDomain,
+ Z(parameters) .* problem.system.line_length,
+ Y(parameters) .* problem.system.line_length,
+ frequencies(parameters);
+ basis = :total, details = details(parameters)
+ )
+end
+
+function _pscad_inputs(problem::LineParametersProblem, formulation::PSCADFormulation, blueprints)
+ _pscad_deterministic(eltype(problem), typeof(formulation.options.data.base_frequency))
+ setting = pscad_setting(formulation, problem, blueprints)
+ frequency = formulation.options.data.base_frequency
+ components = [_pscad_components(blueprint, frequency, formulation, problem.temperature)
+ for blueprint in blueprints]
+ native_order = [(cable = cable, terminal = component.name)
+ for (cable, values) in enumerate(components) for component in values]
+ allunique(native_order) && Set(native_order) == Set(problem.system.terminal_order) ||
+ throw(ArgumentError("PSCAD equivalent components must represent every terminal exactly once"))
+ requested_order = problem.system.terminal_order[sortperm(problem.system.connection_order)]
+ permutation = [only(findall(==(terminal), native_order)) for terminal in requested_order]
+ coordinates = ["cable:$(terminal.cable):$(terminal.terminal)" for terminal in requested_order]
+ document = _pscad_project(problem.system, problem.earth_props, frequency, components;
+ native_settings = setting[(:ground, :frequency)])
+ project = sprint(print, document)
+ dielectric_losses = map(enumerate(components)) do (cable, values)
+ map(enumerate(values)) do (layer, component)
+ dielectric = component.dielectric
+ requested = iszero(dielectric.shunt_capacitance) ? 0.0 :
+ dielectric.shunt_conductance / (2pi * frequency * dielectric.shunt_capacitance)
+ exported = parse(Float64, _pscad_value(requested; maximum = 10))
+ (; cable, layer, requested, exported, capped = requested > 10)
+ end
+ end
+ return (; setting, project, native_order, permutation, coordinates, dielectric_losses)
+end
+
+function _stage_pscad_project(inputs, work_root::AbstractString)
+ root = mktempdir(mkpath(abspath(work_root)); prefix = "run-", cleanup = false)
+ @debug "Exporting PSCAD computation project"
+ staged = joinpath(root, "generated.pscx")
+ write(staged, inputs.project)
+ return (; root, staged)
+end
+
+const PSCAD_COMPLETED_OUTPUTS = ("result_zm.out", "result_zp.out", "result_ym.out",
+ "result_yp.out", "solver.toml", "native-settings.toml", "timing.txt")
+
+_pscad_digest(record::AbstractDict) =
+ bytes2hex(sha256(sprint(io -> TOML.print(io, record; sorted = true))))
+
+function _compute_pscad(problem::LineParametersProblem, formulation::PSCADFormulation,
+ execution_options, inputs)
+ config = execution_options.data.remote
+ setting = inputs.setting
+ input = Dict{String, Any}(
+ "schema_version" => 4,
+ "project_sha256" => bytes2hex(sha256(inputs.project)),
+ "frequencies" => Float64.(problem.frequencies),
+ "matrix_size" => collect(_pscad_size(problem)),
+ "native_settings" => Dict(string(component) => Dict(string(field) => Dict(
+ "value" => control.value, "readback" => control.readback)
+ for (field, control) in pairs(getproperty(setting, component)))
+ for component in (:ground, :frequency, :configuration)),
+ "toolkit" => Dict(name => bytes2hex(sha256(source)) for (name, source) in PSCAD_REMOTE_SOURCES))
+ # Expected identity constrains execution, not numerical compatibility. The
+ # completion record independently authenticates the complete request.
+ signature = _pscad_digest(input)
+ expected = execution_options.data.solver_identity
+ expected === nothing || (input["expected_solver"] = expected)
+ resume = execution_options.data.resume_run_directory
+ parent = execution_options.data.work_root
+ candidates = resume === nothing ? String[] : resume === :latest ?
+ (isdir(parent) ? sort!(filter(path -> isdir(path) && isfile(joinpath(path, "complete.toml")),
+ readdir(parent; join = true)); by = path -> mtime(joinpath(path, "complete.toml")), rev = true) : String[]) : [resume]
+ source_root = nothing
+ station_identity = nothing
+ actual = nothing
+ for candidate in candidates
+ record_path = joinpath(candidate, "complete.toml")
+ isfile(record_path) || throw(ArgumentError("PSCAD run has no completion record: $candidate"))
+ record = TOML.parsefile(record_path)
+ if get(record, "schema_version", nothing) != 4
+ resume === :latest && continue
+ throw(ArgumentError("PSCAD completed run requires a fresh version-4 computation: $candidate"))
+ end
+ if get(record, "input_sha256", nothing) != signature
+ resume === :latest && continue
+ throw(ArgumentError("PSCAD completed run has different numerical inputs or solver implementation: $candidate"))
+ end
+ stored = TOML.parsefile(joinpath(candidate, "computation.toml"))
+ _pscad_digest(stored) == get(record, "request_sha256", nothing) || throw(ArgumentError(
+ "PSCAD completed-run input integrity check failed: $candidate"))
+ numerical = copy(stored)
+ pop!(numerical, "expected_solver", nothing)
+ _pscad_digest(numerical) == signature || throw(ArgumentError(
+ "PSCAD completed-run numerical input integrity check failed: $candidate"))
+ bytes2hex(open(sha256, joinpath(candidate, "generated.pscx"))) == stored["project_sha256"] ||
+ throw(ArgumentError("PSCAD completed-run exported project changed: $candidate"))
+ for (name, digest) in stored["toolkit"]
+ bytes2hex(open(sha256, joinpath(candidate, "toolkit", name))) == digest ||
+ throw(ArgumentError("PSCAD completed-run solver source changed: $candidate/toolkit/$name"))
+ end
+ for name in PSCAD_COMPLETED_OUTPUTS
+ path = joinpath(candidate, "outputs", name)
+ isfile(path) && bytes2hex(open(sha256, path)) == get(record["outputs"], name, nothing) ||
+ throw(ArgumentError("PSCAD completed-run output integrity check failed: $path"))
+ end
+ stored_solver_identity = Dict{String, String}(validate(
+ TOML.parsefile(joinpath(candidate, "outputs", "solver.toml")), config))
+ stored_solver_identity == get(record, "observed_solver", nothing) || throw(ArgumentError(
+ "PSCAD completed run has no matching solver attestation: $candidate"))
+ station_identity === nothing && (station_identity = identify(config))
+ expected === nothing || expected == station_identity || throw(ArgumentError(
+ "PSCAD station does not match the expected solver identity"))
+ if stored_solver_identity != station_identity
+ resume === :latest && continue
+ throw(ArgumentError("PSCAD completed run belongs to a different solver installation: $candidate"))
+ end
+ source_root, actual = candidate, stored_solver_identity
+ break
+ end
+ reused = source_root !== nothing
+ if reused
+ @debug "PSCAD reuses a verified completed run" source_run=source_root
+ output = joinpath(source_root, "outputs")
+ execution = (exit_code = 0,
+ stdout_path = joinpath(output, "stdout.txt"), stderr_path = joinpath(output, "stderr.txt"),
+ console_path = joinpath(output, "pscad-console.txt"), output_dir = output)
+ else
+ root, staged = _stage_pscad_project(inputs, parent)
+ source_root = root
+ open(joinpath(root, "computation.toml"), "w") do io
+ TOML.print(io, input; sorted = true)
+ end
+ @debug "Computing PSCAD line parameters" system=problem.system.system_id
+ execution = run_remote_pscad(config, staged, joinpath(root, "outputs"),
+ formulation, problem.frequencies; output_stem = execution_options.data.output_stem,
+ verbosity = verbosity(execution_options, :PSCAD))
+ actual = Dict{String, String}(validate(
+ TOML.parsefile(joinpath(execution.output_dir, "solver.toml")), config))
+ expected === nothing || actual == expected || throw(ArgumentError(
+ "PSCAD result does not match the expected solver identity: $root"))
+ end
+ native_readback = TOML.parsefile(joinpath(execution.output_dir, "native-settings.toml"))
+ expected_readback = Dict(component => Dict(field => control["readback"]
+ for (field, control) in controls) for (component, controls) in input["native_settings"])
+ native_readback == expected_readback || throw(ArgumentError(
+ "PSCAD result has no matching native-settings readback: $(execution.output_dir)"))
+ parameters = try
+ read_pscad_result(execution.output_dir, problem.frequencies, _pscad_size(problem))
+ catch error
+ verbosity(execution_options, :progress) > 0 && @info "PSCAD result validation failed" _group=:progress exception=error
+ throw(ErrorException("PSCAD result validation failed: $(sprint(showerror, error))" *
+ "\nLast PSCAD diagnostics:\n$(_diagnostic_tail(execution.console_path))" *
+ "\nFull PSCAD diagnostics: $(execution.console_path)"))
+ end
+ source_elapsed = parse(Float64, strip(read(joinpath(execution.output_dir, "timing.txt"), String)))
+ isfinite(source_elapsed) && source_elapsed >= 0 || throw(ArgumentError("invalid PSCAD execution timing"))
+ if !reused
+ completion = Dict("schema_version" => 4, "input_sha256" => signature,
+ "request_sha256" => _pscad_digest(input), "observed_solver" => actual,
+ "outputs" => Dict(name => bytes2hex(open(sha256, joinpath(execution.output_dir, name)))
+ for name in PSCAD_COMPLETED_OUTPUTS))
+ temporary = tempname(source_root)
+ try
+ open(temporary, "w") do io
+ TOML.print(io, completion; sorted = true)
+ end
+ mv(temporary, joinpath(source_root, "complete.toml"); force = false)
+ finally
+ isfile(temporary) && rm(temporary)
+ end
+ end
+ files = [(path = relpath(joinpath(directory, name), source_root),
+ source = joinpath(directory, name), sha256 = bytes2hex(open(sha256, joinpath(directory, name))))
+ for (directory, _, names) in walkdir(source_root) for name in sort(names)]
+ # Reused and fresh native records have the same execution-fact schema.
+ # The public remote call's duration is transient. The scan projects it once.
+ execution = (; (key => value for (key, value) in pairs(execution)
+ if key ∉ (:elapsed_seconds, :elapsed_scope))...)
+ execution = merge(execution, (backend = :pscad, pscad_version = config.pscad_version,
+ reused, source_run = source_root, input_sha256 = signature, solver_identity = actual))
+ return (; parameters, native_readback, files, execution, compile_call_seconds=source_elapsed)
+end
+
+function _pscad_result(problem, formulation, options, inputs, native; batch_reuse = false)
+ permutation = inputs.permutation
+ parameters = LineParameters(PhaseDomain,
+ Z(native.parameters)[permutation, permutation, :], Y(native.parameters)[permutation, permutation, :],
+ copy(frequencies(native.parameters)); details = details(native.parameters))
+ parameters = _pscad_basis(parameters, problem, options.data.output_basis)
+ all(isfinite, Z(parameters)) || throw(DomainError(Z(parameters),
+ "completed PSCAD series impedance must contain only finite entries"))
+ all(isfinite, Y(parameters)) || throw(DomainError(Y(parameters),
+ "completed PSCAD shunt admittance must contain only finite entries"))
+ execution = native.execution
+ batch_reuse && (execution=merge(execution, (reused=true,)))
+ retained = (files = deepcopy(native.files), coordinates = copy(inputs.coordinates),
+ native_terminal_order = copy(inputs.native_order), requested_frequencies = copy(problem.frequencies),
+ formulations = computation_details(formulation).data, native_setting = inputs.setting,
+ base_frequency = formulation.options.data.base_frequency, loss_tangent_limit = 10.0,
+ aerial_shunt_conductance = 1e-38, native_readback = deepcopy(native.native_readback),
+ native_frequencies = copy(details(native.parameters).data.native_frequencies),
+ dielectric_losses = deepcopy(inputs.dielectric_losses), exported_project = inputs.project,
+ execution = deepcopy(execution))
+ if options.data.timing
+ timing = execution.reused ? (;) : (compile_call_seconds=native.compile_call_seconds,)
+ retained = merge(retained, (; timing))
+ end
+ return LineParameters(parameters.domain, parameters.Z, parameters.Y, parameters.f, Engine.completion_details(retained))
+end
+
+function compute(problem::LineParametersProblem, formulation::PSCADFormulation;
+ options::Union{NamedTuple, ComputationOptions} = ComputationOptions(),
+ modal=nothing, modal_options::Union{NamedTuple, ComputationOptions}=ComputationOptions())
+ modal===nothing || return compute(problem,formulation,
+ LineCableModels.ModalAnalysisFormulation(modal);options,modal_options)
+ isempty(modal_options isa NamedTuple ? modal_options : modal_options.data) ||
+ throw(ArgumentError("modal_options require a modal formulation"))
+ return first(compute(problem, [formulation]; options))
+end
+
+function compute(problem::LineParametersProblem, formulations::AbstractVector{<:PSCADFormulation};
+ options::Union{NamedTuple, ComputationOptions} = ComputationOptions(),
+ modal=nothing, modal_options::Union{NamedTuple, ComputationOptions}=ComputationOptions())
+ isempty(formulations) && throw(ArgumentError("PSCAD formulation collections cannot be empty"))
+ options = options isa NamedTuple ? ComputationOptions(options) : options
+ modal===nothing || return compute(problem,formulations,
+ LineCableModels.ModalAnalysisFormulation(modal);options,modal_options)
+ isempty(modal_options isa NamedTuple ? modal_options : modal_options.data) ||
+ throw(ArgumentError("modal_options require a modal formulation"))
+ execution = computation_options(PSCADFormulation, options)
+ _pscad_deterministic(eltype(problem))
+ validate(problem.frequencies, PSCADFormulation)
+ _pscad_size(problem)
+ blueprints = _pscad_blueprints(problem.system)
+ inputs = [_pscad_inputs(problem, formulation, blueprints) for formulation in formulations]
+ logger = LineCableModels.VerbosityLogger(Logging.current_logger(), execution.data.verbosity)
+ return Logging.with_logger(logger) do
+ _compute_pscad(problem, formulations, execution, inputs)
+ end
+end
+
+function _compute_pscad(problem::LineParametersProblem,
+ formulations::AbstractVector{<:PSCADFormulation}, execution::ComputationOptions, inputs)
+ progress = verbosity(execution, :progress) > 0
+ started = progress ? time_ns() : UInt64(0)
+ last_log = started
+ previous = started
+ average_seconds = 0.0
+ progress && @info "PSCAD computation started" _group=:progress total=length(formulations)
+ physical_inputs = Engine.completed_inputs(problem)
+ source_id = gridpoint_id().source_id
+ keys = [(project = value.project, setting = value.setting[(:ground, :frequency, :configuration)])
+ for value in inputs]
+ # Include native execution, readback, and final result construction, but
+ # exclude batch input construction, measurement attachment, and callbacks.
+ scan_started = execution.data.timing ? time_ns() : UInt64(0)
+ native = _compute_pscad(problem, first(formulations), execution, first(inputs))
+ execution = ComputationOptions(merge(execution.data, (solver_identity = native.execution.solver_identity,)))
+ first_result = Engine.retain_gridpoint(_pscad_result(problem, first(formulations), execution,
+ first(inputs), native), gridpoint_id(; source_id);
+ fields = merge(Engine.completed_formulation(first(formulations)), (inputs = physical_inputs,)))
+ if execution.data.timing && !isempty(first_result.details.data.timing)
+ wall_seconds = (time_ns() - scan_started) * 1e-9
+ first_result = Engine.retain_gridpoint(first_result, first_result.details.data.gridpoint;
+ fields=(timing=merge((; wall_seconds), first_result.details.data.timing),))
+ end
+ values = Vector{typeof(first_result)}(undef, length(formulations))
+ values[1] = first_result
+ execution.data.on_result === nothing || execution.data.on_result(problem, 1, first_result)
+ if progress
+ now = time_ns()
+ average_seconds = (now - previous) * 1e-9
+ previous = now
+ if now - last_log >= 5_000_000_000
+ @info "PSCAD progress" _group=:progress completed=1 total=length(formulations) elapsed_seconds=(now-started)*1e-9 eta_hours=(length(formulations)-1)*average_seconds/3600
+ last_log = now
+ end
+ end
+ completed = Dict(first(keys) => native)
+ for index in 2:length(formulations)
+ shared = haskey(completed, keys[index])
+ scan_started = execution.data.timing ? time_ns() : UInt64(0)
+ native = if shared
+ @debug "PSCAD reuses identical exported inputs" formulation=index
+ completed[keys[index]]
+ else
+ _compute_pscad(problem, formulations[index], execution, inputs[index])
+ end
+ value = _pscad_result(problem, formulations[index], execution, inputs[index], native; batch_reuse = shared)
+ value = Engine.retain_gridpoint(value,
+ gridpoint_id(; source_id, formulation_index = index);
+ fields = merge(Engine.completed_formulation(formulations[index]), (inputs = physical_inputs,)))
+ if execution.data.timing && !isempty(value.details.data.timing)
+ wall_seconds = (time_ns() - scan_started) * 1e-9
+ value = Engine.retain_gridpoint(value, value.details.data.gridpoint;
+ fields=(timing=merge((; wall_seconds), value.details.data.timing),))
+ end
+ typeof(value) === eltype(values) || throw(ArgumentError("PSCAD formulations produced inconsistent result types"))
+ values[index] = value
+ completed[keys[index]] = native
+ execution.data.on_result === nothing || execution.data.on_result(problem, index, values[index])
+ if progress
+ now = time_ns()
+ interval = (now - previous) * 1e-9
+ average_seconds = 0.2 * interval + 0.8 * average_seconds
+ previous = now
+ if now - last_log >= 5_000_000_000
+ @info "PSCAD progress" _group=:progress completed=index total=length(formulations) elapsed_seconds=(now-started)*1e-9 eta_hours=(length(formulations)-index)*average_seconds/3600
+ last_log = now
+ end
+ end
+ end
+ progress && @info "PSCAD computation completed successfully" _group=:progress completed=length(values) total=length(formulations) elapsed_seconds=(time_ns()-started)*1e-9
+ return values
+end
diff --git a/src/pscad/formulations.jl b/src/pscad/formulations.jl
new file mode 100644
index 000000000..55eedc3e0
--- /dev/null
+++ b/src/pscad/formulations.jl
@@ -0,0 +1,350 @@
+import LineCableModels: formula_id
+
+"""
+ PSCADFormulation
+
+Store shared formula selections, requested definitions, and physical options
+for PSCAD. Backend execution settings remain computation options.
+"""
+struct PSCADFormulation{M <: NamedTuple, O <: FormulationOptions, D <: NamedTuple} <:
+ AbstractFormulation
+ methods::M
+ options::O
+ definitions::D
+end
+
+"""Identify PSCAD without server access or native setting compilation."""
+description(::Type{<:PSCADFormulation}; compact::Bool=false) = "PSCAD"
+description(::PSCADFormulation; compact::Bool=false) = description(PSCADFormulation;compact)
+formula_id(::Type{<:PSCADFormulation}) = :pscad
+formula_id(::PSCADFormulation) = :pscad
+formulation_options(value::PSCADFormulation) = value.options
+function Base.pairs(::Type{PSCADFormulation}; quantity=nothing)
+ return pairs((; (name => owner
+ for (name, owner) in pairs(LineParametersFormulation; quantity)
+ if name !== :shunt_model)...))
+end
+Base.pairs(value::PSCADFormulation;quantity=nothing) =
+ pairs(PSCADFormulation,(methods=value.methods,requested=value.definitions,options=value.options.data);quantity)
+description(::Type{PSCADFormulation},slot::Val;compact::Bool=false) =
+ description(LineParametersFormulation,slot;compact)
+description(::Type{PSCADFormulation},slot::Union{Val{:reduce_bundle},Val{:kron_reduction},Val{:ideal_transposition}},
+ value::Bool;compact::Bool=false) = description(LineParametersFormulation,slot,value;compact)
+description(::Type{PSCADFormulation},::Val{:base_frequency},value::Real;compact::Bool=false) =
+ "base frequency="*string(value)*" Hz"
+Base.pairs(::Type{PSCADFormulation},retained::NamedTuple;quantity=nothing) =
+ pairs(LineParametersFormulation,retained;quantity,owner=PSCADFormulation)
+
+function formulation_options(::Type{PSCADFormulation}, record::FormulationOptions)::FormulationOptions
+ options = record.data
+ base_frequency = get(options, :base_frequency, 50.0)
+ _pscad_deterministic(typeof(base_frequency))
+ isfinite(base_frequency) && base_frequency >= 0.1 || throw(DomainError(
+ base_frequency, "PSCAD base frequency must be finite and at least 0.1 Hz"))
+ physical = (;
+ (key => value for (key, value) in pairs(options) if key !== :base_frequency)...)
+ normalized = formulation_options(LineParametersFormulation,
+ FormulationOptions(merge((reduce_bundle = false, kron_reduction = false, ideal_transposition = false), physical)))
+ any((normalized.data.reduce_bundle, normalized.data.kron_reduction,
+ normalized.data.ideal_transposition)) &&
+ throw(ArgumentError("PSCAD currently requires unreduced, untransposed terminal matrices"))
+ return FormulationOptions(merge(normalized.data, (; base_frequency)))
+end
+
+# Resolve the backend's explicit defaults before the Engine constructs selections.
+# Partial recipes retain their original branches and are validated by the owner.
+_pscad_default(selected, default) = selected
+_pscad_default(selected::Symbol, default) = _pscad_default(LineCableModels.formula(selected), default)
+function _pscad_default(selected::LineCableModels.FormulaDefinition{:default, Order}, default) where {Order}
+ Order === :default && selected.equivalent_earth === nothing || throw(ArgumentError(
+ "PSCAD equations do not execute equivalent-earth reductions"))
+ isempty(selected.parameters) && isempty(selected.options.data) || throw(ArgumentError(
+ "PSCAD equations do not accept analytical parameters or numerical controls"))
+ return default
+end
+function _pscad_default(selected::NamedTuple, default)
+ return NamedTuple{keys(selected)}(map(keys(selected)) do name
+ _pscad_default(selected[name], default isa NamedTuple ? get(default, name, selected[name]) : default)
+ end)
+end
+
+function validate(selected::Union{InternalImpedance.Formula, InsulationImpedance.Formula,
+ EarthImpedance.Formula, EarthAdmittance.Formula}, ::Type{<:PSCADFormulation})
+ empty_controls = selected isa InternalImpedance.Formula ?
+ all(isempty, values(selected.options.data)) : isempty(selected.options.data)
+ isempty(selected.parameters) && empty_controls || throw(ArgumentError(
+ "PSCAD equations do not accept analytical parameters or numerical controls"))
+ if selected isa Union{EarthImpedance.Formula, EarthAdmittance.Formula}
+ selected.equivalent_earth === nothing || throw(ArgumentError(
+ "PSCAD equations do not execute equivalent-earth reductions"))
+ end
+ return selected
+end
+
+function _pscad_formulation(internal_impedance, insulation_impedance, earth_impedance,
+ insulation_admittance, semicon_admittance, earth_admittance, earth_properties,
+ pipe_impedance, temperature_dependence, options::FormulationOptions)
+ selections = (; internal_impedance, insulation_impedance, earth_impedance,
+ insulation_admittance, semicon_admittance, earth_admittance, earth_properties,
+ pipe_impedance, temperature_dependence)
+ normalized = formulation_options(PSCADFormulation, options)
+ defaults = (internal_impedance = :wedepohl1973, insulation_impedance = :ametani1980,
+ earth_impedance = (air = :carson1926, earth = :pollaczek1926, mixed = :lucca1994),
+ earth_admittance = :ideal)
+ methods = (; (name => begin
+ requested = selections[name]
+ if requested === nothing && name in (:earth_properties, :temperature_dependence)
+ nothing
+ else
+ selected = owner(haskey(defaults, name) ?
+ _pscad_default(requested, defaults[name]) : requested)
+ if haskey(defaults, name)
+ for leaf in (selected isa NamedTuple ? values(selected) : (selected,))
+ leaf === nothing || validate(leaf, PSCADFormulation)
+ end
+ end
+ selected
+ end
+ end for (name, owner) in pairs(PSCADFormulation))...)
+ definitions = map(selections, methods) do requested, selected
+ requested isa NamedTuple ? NamedTuple{keys(selected)}(requested) : requested
+ end
+ return PSCADFormulation(methods, normalized, definitions)
+end
+
+"""
+ PSCADFormulation(; kwargs...)
+
+Select PSCAD through the shared formula grammar. `Formulation(:pscad; kwargs...)` calls
+it. All formula slots and options accept Grid inputs with product or zip composition. Earth-impedance
+`:default` resolves to `(air=:carson1926, earth=:pollaczek1926, mixed=:lucca1994)`.
+Internal impedance selects `:wedepohl1973` for inner, outer, and transfer surfaces.
+Magnetic insulation impedance selects `:ametani1980`. Earth potential coefficients
+select `:ideal`: electrostatic images in air, zero for buried and mixed pairs.
+Each scientific selection is registered by its Engine owner. Dielectric
+`:default` routes to `:lossless`. An explicit `:lossy` selection is represented
+by the equivalent capacitance and loss tangent at the export reference
+frequency. PSCAD's native frequency law and loss-tangent cap of ten still apply.
+External selections accept the same `(air, earth, mixed)` shorthand as LCM.
+Each required native field is compiled from the indexed Engine selections.
+PSCAD has no separate potential-model selector. Native direct integration can
+produce nonzero aerial conductance under the `:ideal` selection. Raw native
+matrices are preserved. Unsupported equations and analytical integration settings
+fail before export.
+"""
+function PSCADFormulation(;
+ internal_impedance = formula(:default),
+ insulation_impedance = formula(:default),
+ earth_impedance = formula(:default),
+ insulation_admittance = formula(:default),
+ semicon_admittance = formula(:default),
+ earth_admittance = formula(:default),
+ earth_properties = formula(:default),
+ pipe_impedance = formula(:default),
+ temperature_dependence = formula(:default),
+ options = FormulationOptions(), combine::Symbol = :product)
+ selections = (internal_impedance, insulation_impedance, earth_impedance,
+ insulation_admittance, semicon_admittance, earth_admittance, earth_properties,
+ pipe_impedance, temperature_dependence)
+ return parameterize(PSCADFormulation, (inputs...) -> _pscad_formulation(inputs[1:end-1]...,
+ last(inputs) isa NamedTuple ? FormulationOptions(last(inputs)) : last(inputs)),
+ (selections..., options); combine)
+end
+
+Formulation(::Val{:pscad}; kwargs...) = PSCADFormulation(; kwargs...)
+
+# The Engine defines each selected formula and its kind/s/t expression. These methods
+# translate that expression to PSCAD settings without evaluating it in Julia.
+function earth_impedance(
+ ::EarthImpedance.Formula{ID}, ::Val{Kind}, ::Val{S}, ::Val{T}, ::PSCADFormulation) where {ID, Kind, S, T}
+ throw(ArgumentError("PSCAD earth_impedance :$ID, kind :$Kind: formula not implemented for source in layer $S and target in layer $T"))
+end
+function earth_potential_coefficient(
+ ::EarthAdmittance.Formula{ID}, ::Val{Kind}, ::Val{S}, ::Val{T}, ::PSCADFormulation) where {ID, Kind, S, T}
+ throw(ArgumentError("PSCAD earth_potential_coefficient :$ID, kind :$Kind: formula not implemented for source in layer $S and target in layer $T"))
+end
+function internal_impedance(::InternalImpedance.Formula{ID}, ::Val{Kind}, ::PSCADFormulation) where {ID, Kind}
+ throw(ArgumentError("PSCAD internal_impedance :$ID: formula not implemented for kind :$Kind"))
+end
+function insulation_impedance(::InsulationImpedance.Formula{ID}, ::PSCADFormulation) where {ID}
+ throw(ArgumentError("PSCAD insulation_impedance :$ID: formula not implemented"))
+end
+
+function earth_impedance(::EarthImpedance.Formula{:gary1976}, ::Union{Val{:self}, Val{:mutual}},
+ ::Val{1}, ::Val{1}, ::PSCADFormulation)
+ (EarthForm2 = (value = 0, readback = "DERISEMLYEN"),)
+end
+function earth_impedance(::EarthImpedance.Formula{:carson1926}, ::Union{Val{:self}, Val{:mutual}},
+ ::Val{1}, ::Val{1}, ::PSCADFormulation)
+ (EarthForm2 = (value = 2, readback = "DIRECT_NUMERICAL_INTEGRATION"),)
+end
+function earth_impedance(::EarthImpedance.Formula{:pollaczek1926}, ::Union{Val{:self}, Val{:mutual}},
+ ::Val{2}, ::Val{2}, ::PSCADFormulation)
+ (EarthForm = (value = 2, readback = "DIRECT_NUMERICAL_INTEGRATION"),)
+end
+function earth_impedance(::EarthImpedance.Formula{:wedepohl1973}, ::Union{Val{:self}, Val{:mutual}},
+ ::Val{2}, ::Val{2}, ::PSCADFormulation)
+ (EarthForm = (value = 0, readback = "WEDEPOHL"),)
+end
+function earth_impedance(::EarthImpedance.Formula{:saad1996}, ::Union{Val{:self}, Val{:mutual}},
+ ::Val{2}, ::Val{2}, ::PSCADFormulation)
+ (EarthForm = (value = 3, readback = "SAAD"),)
+end
+function earth_impedance(
+ ::EarthImpedance.Formula{:ametani2009}, ::Val{:mutual}, ::Val{1}, ::Val{2}, ::PSCADFormulation)
+ (EarthForm3 = (value = 0, readback = "AMETANIL"),)
+end
+function earth_impedance(
+ ::EarthImpedance.Formula{:ametani2009}, ::Val{:mutual}, ::Val{2}, ::Val{1}, ::PSCADFormulation)
+ (EarthForm3 = (value = 0, readback = "AMETANIL"),)
+end
+function earth_impedance(
+ ::EarthImpedance.Formula{:lucca1994}, ::Val{:mutual}, ::Val{1}, ::Val{2}, ::PSCADFormulation)
+ (EarthForm3 = (value = 2, readback = "LUCCA"),)
+end
+function earth_impedance(
+ ::EarthImpedance.Formula{:lucca1994}, ::Val{:mutual}, ::Val{2}, ::Val{1}, ::PSCADFormulation)
+ (EarthForm3 = (value = 2, readback = "LUCCA"),)
+end
+
+# The documented external potential law uses ideal images in air and zero
+# coefficients for buried and mixed pairs. There is no independent native selector.
+# Direct integration can deviate from strict ideal behavior in the aerial block.
+function earth_potential_coefficient(::EarthAdmittance.Formula{:ideal}, ::Union{Val{:self}, Val{:mutual}},
+ ::Val{1}, ::Val{1}, ::PSCADFormulation)
+ (;)
+end
+function earth_potential_coefficient(::EarthAdmittance.Formula{:ideal}, ::Union{Val{:self}, Val{:mutual}},
+ ::Val{2}, ::Val{2}, ::PSCADFormulation)
+ (;)
+end
+function earth_potential_coefficient(
+ ::EarthAdmittance.Formula{:ideal}, ::Val{:mutual}, ::Val{1}, ::Val{2}, ::PSCADFormulation)
+ (;)
+end
+function earth_potential_coefficient(
+ ::EarthAdmittance.Formula{:ideal}, ::Val{:mutual}, ::Val{2}, ::Val{1}, ::PSCADFormulation)
+ (;)
+end
+function internal_impedance(
+ ::InternalImpedance.Formula{:wedepohl1973}, ::Union{
+ Val{:inner}, Val{:outer}, Val{:transfer}}, ::PSCADFormulation)
+ (;) # PSCAD fixes the conductor surfaces to the Wedepohl-Wilcox approximation.
+end
+insulation_impedance(::InsulationImpedance.Formula{:ametani1980}, ::PSCADFormulation) = (;)
+
+"""
+Compile every required native setting from the physical indexed interactions.
+"""
+function pscad_setting(formulation::PSCADFormulation, problem::LineParametersProblem)
+ _pscad_deterministic(eltype(problem), typeof(formulation.options.data.base_frequency))
+ return pscad_setting(formulation, problem, _pscad_blueprints(problem.system))
+end
+
+function pscad_setting(formulation::PSCADFormulation, problem::LineParametersProblem, blueprints)
+ validate(problem)
+ model = problem.earth_props
+ validate(model, PSCADFormulation)
+ relation = formulation.methods.earth_properties
+ (relation === nothing ||
+ relation === LineCableModels.Earth.FrequencyDependent.Formula(:default)) ||
+ throw(ArgumentError("PSCAD does not implement the selected frequency-dependent soil relation"))
+ for name in (:insulation_admittance, :semicon_admittance)
+ selected = getproperty(formulation.methods, name)
+ selected isa Union{InsulationAdmittance.Formula{:lossless},InsulationAdmittance.Formula{:lossy},
+ SemiconAdmittance.Formula{:lossless},SemiconAdmittance.Formula{:lossy}} || throw(ArgumentError(
+ "PSCAD does not implement $name :$(formula_id(selected))"))
+ end
+ for design in problem.system.designs
+ validate(design, formulation.methods.pipe_impedance, formulation)
+ end
+ input = Engine.lineinput(problem, blueprints)
+ pairs = Engine.earth_pairs(first.(input.cable.assemblies), input.horz, input.vert,
+ input.horz_sep, problem.earth_props)
+ settings = Dict{Symbol, NamedTuple}()
+ interactions = map(formulation.methods[(:earth_impedance, :earth_admittance)]) do selection
+ selection isa NamedTuple && validate(problem.earth_props)
+ records = NamedTuple{
+ (:formula, :kind, :source, :target), Tuple{Symbol, Symbol, Int, Int}}[]
+ for pair in pairs
+ validate(pair)
+ expression = Expression(selection, pair)
+ selected = expression.selection
+ # Registration and physical validity belong to the equation owner.
+ # Execution availability is selected by the native expression.
+ validate(pair, expression)
+ formulation_options(selected, (expression,))
+ record = (formula = formula_id(selected),
+ kind = pair.row == pair.column ? :self : :mutual,
+ source = pair.layers[1], target = pair.layers[2])
+ record in records && continue
+ push!(records, record)
+ for (field, setting) in Base.pairs(expression(formulation))
+ haskey(settings, field) && settings[field] != setting &&
+ throw(ArgumentError(
+ "PSCAD field $field has conflicting formula selections"))
+ settings[field] = setting
+ end
+ end
+ records
+ end
+ kinds = any(indices -> length(indices) > 1, input.cable.assemblies) ?
+ (:inner, :outer, :transfer) : (:outer,)
+ for kind in kinds
+ Expression(formulation.methods.internal_impedance,
+ internal_impedance, Val(kind))(formulation)
+ end
+ Expression(formulation.methods.insulation_impedance, insulation_impedance)(formulation)
+ # Unused native slots are set deterministically and retained too. They do not
+ # authorize any additional physical case.
+ ground = (
+ EarthForm2 = get(settings, :EarthForm2, (
+ value = 2, readback = "DIRECT_NUMERICAL_INTEGRATION")),
+ EarthForm = get(settings, :EarthForm, (
+ value = 2, readback = "DIRECT_NUMERICAL_INTEGRATION")),
+ EarthForm3 = get(settings, :EarthForm3, (value = 2, readback = "LUCCA")))
+ return (ground = ground,
+ frequency = (enablf = (value = 1, readback = "YES"),
+ FS = (value = Float64(first(problem.frequencies)),
+ readback = Float64(first(problem.frequencies))),
+ FE = (value = Float64(last(problem.frequencies)),
+ readback = Float64(last(problem.frequencies))),
+ Numf = (value = length(problem.frequencies)-1,
+ readback = length(problem.frequencies)-1)),
+ configuration = (Freq = (value = Float64(formulation.options.data.base_frequency),
+ readback = Float64(formulation.options.data.base_frequency)),), interactions = interactions)
+end
+
+"""Record consumed PSCAD identifiers and fixed native assumptions with bounded field types."""
+function computation_details(formulation::PSCADFormulation)::ComputationDetails
+ methods = formulation.methods
+ return ComputationDetails(merge((
+ schema_version = 4,
+ assumptions = (
+ internal_impedance = "Wedepohl-Wilcox (1973) inner, outer, and transfer conductor surface approximations",
+ insulation_impedance = "Ametani (1980) concentric-insulation magnetic impedance",
+ earth_admittance = "Ideal-earth images in air; zero external potential coefficients for buried and mixed pairs. Native direct integration can add aerial conductance; native matrices are preserved",
+ insulation_admittance = description(methods.insulation_admittance),
+ semicon_admittance = description(methods.semicon_admittance),
+ dielectric_equivalence = "Reference-frequency equivalent capacitance and loss tangent; native PSCAD frequency law; loss tangent capped at 10",
+ earth = "One homogeneous earth layer; no FrequencyDependent relation",
+ pipe_impedance = "Cable_Coax only; shared eccentric metallic enclosure unsupported"),
+ ),NamedTuple(formulation)))
+end
+
+function computation_details(::Type{<:PSCADFormulation}, result::LineParameters)::ComputationDetails
+ return details(result)
+end
+
+"""Expose complete requested PSCAD formula choices and native configuration options."""
+function Base.NamedTuple(value::PSCADFormulation)
+ record = function (selected)
+ selected === nothing && return nothing
+ selected isa Symbol && return NamedTuple(formula(selected))
+ selected isa NamedTuple && return map(record,selected)
+ return NamedTuple(selected)
+ end
+ Record=NamedTuple{(:backend,:requested,:methods,:options),
+ Tuple{Symbol,NamedTuple,NamedTuple,NamedTuple}}
+ return Record((:pscad,map(record,value.definitions),map(record,value.methods),value.options.data))
+end
diff --git a/src/pscad/importexport/import.jl b/src/pscad/importexport/import.jl
new file mode 100644
index 000000000..6abba2669
--- /dev/null
+++ b/src/pscad/importexport/import.jl
@@ -0,0 +1,529 @@
+
+const _PSCAD_PART_FIELDS = (
+ (
+ inner = "R1", outer = "R2", insulation = "R3",
+ rho = "RHOC", conductor_mu = "PERMC",
+ eps = "EPS1", insulation_mu = "PERM1", loss = "LT1"
+ ),
+ (
+ inner = nothing, outer = "R4", insulation = "R5",
+ rho = "RHOS", conductor_mu = "PERMS",
+ eps = "EPS2", insulation_mu = "PERM2", loss = "LT2"
+ ),
+ (
+ inner = nothing, outer = "R6", insulation = "R7",
+ rho = "RHOA", conductor_mu = "PERMA",
+ eps = "EPS3", insulation_mu = "PERM3", loss = "LT3"
+ ),
+ (
+ inner = nothing, outer = "R8", insulation = "R9",
+ rho = "RHOO", conductor_mu = "PERMO",
+ eps = "EPS4", insulation_mu = "PERM4", loss = "LT4"
+ )
+)
+
+function _pscad_parameters(node)
+ values = Dict{String, String}()
+ for parameter in findall("./paramlist/param", node)
+ name = parameter["name"]
+ haskey(values, name) && throw(ArgumentError(
+ "duplicate PSCAD parameter '$name'",
+ ))
+ values[name] = parameter["value"]
+ end
+ return values
+end
+
+function _pscad_parameter(values, name::AbstractString)
+ haskey(values, name) || throw(KeyError(name))
+ return values[name]
+end
+
+function _pscad_number(values, name::AbstractString)
+ raw = _pscad_parameter(values, name)
+ scalar = strip(first(split(raw, '['; limit = 2)))
+ scalar = replace(scalar, 'D' => 'E', 'd' => 'e')
+ value = tryparse(Float64, scalar)
+ value === nothing && throw(ArgumentError(
+ "PSCAD parameter '$name' is not numeric: '$raw'",
+ ))
+ return value
+end
+
+function _pscad_integer(values, name::AbstractString)
+ value = _pscad_number(values, name)
+ isinteger(value) || throw(ArgumentError(
+ "PSCAD parameter '$name' must be an integer; got $value",
+ ))
+ return round(Int, value)
+end
+
+function _pscad_binding(node)
+ return haskey(node, "defn") ? node["defn"] : ""
+end
+
+function _pscad_output_enabled(node)
+ values = _pscad_parameters(node)
+ haskey(values, "Output") || return nothing
+ return uppercase(strip(values["Output"])) in ("1", "YES", "ENABLED", "TRUE")
+end
+
+function _pscad_row_definition(
+ project,
+ requested::Union{Nothing, AbstractString} = nothing
+)
+ row_definitions = NamedTuple[]
+ for definition in findall("./definitions/Definition", project)
+ users = findall("./schematic/User", definition)
+ frequency = filter(
+ node -> _pscad_binding(node) == _PSCAD_FREQUENCY_BINDING,
+ users
+ )
+ ground = filter(
+ node -> _pscad_binding(node) == _PSCAD_GROUND_BINDING,
+ users
+ )
+ isempty(frequency) && continue
+ isempty(ground) && continue
+ length(frequency) == 1 || throw(ArgumentError(
+ "PSCAD definition '$(definition["name"])' has multiple frequency options",
+ ))
+ length(ground) == 1 || throw(ArgumentError(
+ "PSCAD definition '$(definition["name"])' has multiple ground definitions",
+ ))
+ push!(row_definitions, (;
+ definition,
+ users,
+ frequency = only(frequency),
+ ground = only(ground)
+ ))
+ end
+ isempty(row_definitions) && throw(ArgumentError(
+ "PSCAD project contains no supported frequency-dependent row definition",
+ ))
+ if requested !== nothing
+ qualified = String(requested)
+ name = last(split(qualified, ':'; limit = 2))
+ selected = filter(row_definition -> row_definition.definition["name"] == name, row_definitions)
+ length(selected) == 1 || throw(ArgumentError(
+ isempty(selected) ?
+ "PSCAD project contains no supported row definition '$qualified'" :
+ "PSCAD project contains multiple supported row definitions named '$name'",
+ ))
+ return only(selected)
+ end
+ enabled = filter(row_definition -> _pscad_output_enabled(row_definition.frequency) === true,
+ row_definitions)
+ if length(enabled) == 1
+ return only(enabled)
+ end
+ isempty(enabled) && length(row_definitions) == 1 && return only(row_definitions)
+ throw(ArgumentError(
+ isempty(enabled) ?
+ "PSCAD project has multiple row definitions and none is selected for output" :
+ "PSCAD project has multiple row definitions selected for output",
+ ))
+end
+
+function _pscad_active_cables(row)
+ unsupported = String[]
+ cables = typeof(row.definition)[]
+ for node in row.users
+ binding = _pscad_binding(node)
+ if binding in (_PSCAD_CABLE_BINDING, "master:Cable_CoaxSimpl")
+ values = _pscad_parameters(node)
+ _pscad_integer(values, "LL") >= 0 && push!(cables, node)
+ elseif startswith(binding, "master:Line_Tower_") ||
+ binding == "master:Cable_PipeType"
+ push!(unsupported, binding)
+ end
+ end
+ isempty(unsupported) || throw(ArgumentError(
+ "PSCAD row definition uses unsupported physical components: " *
+ join(unique(unsupported), ", "),
+ ))
+ isempty(cables) && throw(ArgumentError(
+ "PSCAD row definition contains no active master:Cable_Coax component",
+ ))
+ sort!(cables; by = node -> _pscad_integer(_pscad_parameters(node), "CABNUM"))
+ return cables
+end
+
+function _pscad_instance(document, project, row)
+ namespace = project["name"]
+ target = "$namespace:$(row.definition["name"])"
+ matching_nodes = filter(findall("//User", document)) do node
+ _pscad_binding(node) == target || return false
+ values = _pscad_parameters(node)
+ return haskey(values, "Length") && haskey(values, "Name")
+ end
+ length(matching_nodes) == 1 || throw(ArgumentError(
+ "PSCAD project must contain one instance of '$target' with line parameters",
+ ))
+ return only(matching_nodes)
+end
+
+function _pscad_material(kind::Symbol, rho, eps_r, mu_r)
+ return Material(kind, rho, eps_r, mu_r, 20.0, 0.0)
+end
+
+function _pscad_dielectric(values, fields, frequency; eps_r = _pscad_number(values, fields.eps))
+ mu_r = _pscad_number(values, fields.insulation_mu)
+ loss = _pscad_number(values, fields.loss)
+ 0 <= loss <= 10 || throw(DomainError(
+ loss, "PSCAD loss tangent must be between zero and ten"
+ ))
+ conductivity = 2π * frequency * vacuum_permittivity(typeof(eps_r)) * eps_r * loss
+ rho = iszero(conductivity) ? Inf : inv(conductivity)
+ return _pscad_material(:insulator, rho, eps_r, mu_r)
+end
+
+function _pscad_radial_design(
+ cable_name::AbstractString,
+ components
+)
+ parts = AbstractCablePart[]
+ for component in components
+ terminal = Symbol(component.name)
+ push!(parts,
+ Group(
+ terminal,
+ Region(
+ Symbol(terminal, :_conductor),
+ Annulus(component.conductor_inner, component.conductor_outer),
+ component.conductor_material
+ )
+ ))
+ if component.insulation_outer > component.conductor_outer
+ push!(parts,
+ Region(
+ Symbol(terminal, :_insulation),
+ Annulus(component.conductor_outer, component.insulation_outer),
+ component.dielectric_material
+ ))
+ end
+ end
+ return build(CableDesign, cable_name, Stack(parts))
+end
+
+function _pscad_design(values, cable_number::Int, frequency)
+ line_layers = _pscad_integer(values, "LL")
+ if iszero(line_layers)
+ conductor_inner = _pscad_number(values, "R1")
+ conductor_outer = _pscad_number(values, "R2")
+ cable_name = strip(get(values, "Name", ""))
+ isempty(cable_name) && (cable_name = "cable$cable_number")
+ component = (;
+ name = "conductor",
+ conductor_inner,
+ conductor_outer,
+ insulation_outer = conductor_outer,
+ conductor_material = _pscad_material(
+ :conductor,
+ _pscad_number(values, "RHOC"),
+ 0.0,
+ _pscad_number(values, "PERMC")
+ ),
+ dielectric_material = _pscad_material(:insulator, Inf, 1.0, 1.0)
+ )
+ return _pscad_radial_design(cable_name, (component,))
+ end
+ isodd(line_layers) || throw(ArgumentError(
+ "PSCAD LL must describe alternating conductor and insulation layers",
+ ))
+ component_count = (line_layers + 1) ÷ 2
+ component_count in eachindex(_PSCAD_PART_FIELDS) || throw(ArgumentError(
+ "PSCAD Cable_Coax supports one to four concentric components",
+ ))
+
+ components = NamedTuple[]
+ previous_outer = 0.0
+ for index in 1:component_count
+ fields = _PSCAD_PART_FIELDS[index]
+ conductor_inner = fields.inner === nothing ? previous_outer :
+ _pscad_number(values, fields.inner)
+ conductor_outer = _pscad_number(values, fields.outer)
+ insulation_outer = _pscad_number(values, fields.insulation)
+ name = strip(_pscad_parameter(values, "CONNAM$index"))
+ isempty(name) && (name = "component$index")
+ lowercase(name) == "none" && throw(ArgumentError(
+ "PSCAD active component $index cannot be named 'none'",
+ ))
+ push!(components,
+ (;
+ name = lowercasefirst(name),
+ conductor_inner,
+ conductor_outer,
+ insulation_outer,
+ conductor_material = _pscad_material(
+ :conductor,
+ _pscad_number(values, fields.rho),
+ 0.0,
+ _pscad_number(values, fields.conductor_mu)
+ ),
+ dielectric_material = _pscad_dielectric(values, fields, frequency)
+ ))
+ previous_outer = insulation_outer
+ end
+ cable_name = strip(get(values, "Name", ""))
+ isempty(cable_name) && (cable_name = "cable$cable_number")
+ return _pscad_radial_design(cable_name, components)
+end
+
+const _PSCAD_SIMPLIFIED_FIELDS = (
+ (
+ inner = "R1", outer = "R2", insulation = "R3",
+ rho = "RHOC", resistance = "DCRC", material_mode = "DTC",
+ mu = "PERMC", eps = "EPS1", capacitance = "CI1",
+ dielectric_mode = "DTI1", insulation_mu = "mu_r1", loss = "LT1",
+ name = "core"
+ ),
+ (
+ inner = "R3", outer = "R4", insulation = "R5",
+ rho = "RHOS", resistance = "DCRS", material_mode = "DTS",
+ mu = "PERMS", eps = "EPS2", capacitance = "CI2",
+ dielectric_mode = "DTI2", insulation_mu = "mu_r2", loss = "LT2",
+ name = "sheath"
+ ),
+ (
+ inner = "R5", outer = "R6", insulation = "R7",
+ rho = "RHOA", resistance = "DCRA", material_mode = "DTA",
+ mu = "PERMA", eps = "EPS3", capacitance = "CI3",
+ dielectric_mode = "DTI3", insulation_mu = "mu_r3", loss = "LT3",
+ name = "armor"
+ )
+)
+
+function _pscad_simplified_rho(values, fields, r_in, r_ex)
+ mode = _pscad_integer(values, fields.material_mode)
+ mode in (0, 1) || throw(ArgumentError(
+ "PSCAD simplified-cable material selector must be zero or one",
+ ))
+ mode == 1 && return _pscad_number(values, fields.rho)
+ resistance = _pscad_number(values, fields.resistance) / 1000
+ return resistance * π * (r_ex^2 - r_in^2)
+end
+
+function _pscad_simplified_eps(values, fields, r_in, r_ex)
+ mode = _pscad_integer(values, fields.dielectric_mode)
+ mode in (0, 1) || throw(ArgumentError(
+ "PSCAD simplified-cable dielectric selector must be zero or one",
+ ))
+ mode == 1 && return _pscad_number(values, fields.eps)
+ capacitance = _pscad_number(values, fields.capacitance) * 1e-9
+ return capacitance * log(r_ex / r_in) / (2π * vacuum_permittivity(typeof(capacitance)))
+end
+
+function _pscad_simplified_design(values, cable_number::Int, frequency)
+ _pscad_integer(values, "RorT") == 0 || throw(ArgumentError(
+ "PSCAD simplified-cable import currently requires radius input",
+ ))
+ _pscad_integer(values, "SemiCL") == 0 || throw(ArgumentError(
+ "PSCAD simplified-cable import does not support semiconductive layers",
+ ))
+ line_layers = _pscad_integer(values, "LL")
+ isodd(line_layers) || throw(ArgumentError(
+ "PSCAD LL must describe alternating conductor and insulation layers",
+ ))
+ component_count = (line_layers + 1) ÷ 2
+ component_count in eachindex(_PSCAD_SIMPLIFIED_FIELDS) || throw(ArgumentError(
+ "PSCAD Cable_CoaxSimpl supports one to three concentric components",
+ ))
+ components = NamedTuple[]
+ for index in 1:component_count
+ fields = _PSCAD_SIMPLIFIED_FIELDS[index]
+ conductor_inner = _pscad_number(values, fields.inner)
+ conductor_outer = _pscad_number(values, fields.outer)
+ insulation_outer = _pscad_number(values, fields.insulation)
+ eps_r = _pscad_simplified_eps(
+ values, fields, conductor_outer, insulation_outer
+ )
+ push!(components,
+ (;
+ name = fields.name,
+ conductor_inner,
+ conductor_outer,
+ insulation_outer,
+ conductor_material = _pscad_material(
+ :conductor,
+ _pscad_simplified_rho(
+ values, fields, conductor_inner, conductor_outer
+ ),
+ 0.0,
+ _pscad_number(values, fields.mu)
+ ),
+ dielectric_material = _pscad_dielectric(values, fields, frequency; eps_r)
+ ))
+ end
+ cable_name = strip(get(values, "Name", ""))
+ isempty(cable_name) && (cable_name = "cable$cable_number")
+ return _pscad_radial_design(cable_name, components)
+end
+
+function _pscad_position(values, cable_number::Int, next_phase::Ref{Int})
+ frequency = _pscad_number(values, "FLT")
+ isfinite(frequency) && frequency > 0 || throw(DomainError(
+ frequency, "PSCAD cable reference frequency must be positive and finite"
+ ))
+ design = _pscad_design(values, cable_number, frequency)
+ connections = Dict{Symbol, Int}()
+ for (index, terminal) in enumerate(design.terminal_order)
+ eliminated = index > 1 && _pscad_integer(values, "elim$(index - 1)") != 0
+ connections[terminal] = eliminated ? 0 : next_phase[]
+ eliminated || (next_phase[] += 1)
+ end
+
+ horizontal = _pscad_number(values, "X")
+ overhead = _pscad_integer(values, "OHC") != 0
+ vertical = overhead ? _pscad_number(values, "Y2") :
+ -abs(_pscad_number(values, "Y"))
+ return (;
+ design,
+ position = Pose2(horizontal, vertical, 0),
+ connections
+ )
+end
+
+function _pscad_simplified_positions(values, next_phase::Ref{Int})
+ circuit_count = _pscad_integer(values, "NC")
+ circuit_count > 0 || throw(DomainError(
+ circuit_count, "PSCAD simplified-cable circuit count must be positive"
+ ))
+ first_number = _pscad_integer(values, "CABNUM")
+ spacing = _pscad_number(values, "D")
+ horizontal = _pscad_number(values, "X")
+ vertical = -abs(_pscad_number(values, "Y"))
+ frequency = _pscad_number(values, "FLT")
+ isfinite(frequency) && frequency > 0 || throw(DomainError(
+ frequency, "PSCAD cable reference frequency must be positive and finite"
+ ))
+ positions = NamedTuple[]
+ for offset in 0:(3circuit_count - 1)
+ cable_number = first_number + offset
+ design = _pscad_simplified_design(values, cable_number, frequency)
+ connections = Dict(
+ terminal => (next_phase[] += 1; next_phase[] - 1)
+ for terminal in design.terminal_order
+ )
+ push!(positions,
+ (;
+ design,
+ position = Pose2(horizontal + offset * spacing, vertical, 0),
+ connections
+ ))
+ end
+ return positions
+end
+
+function _pscad_earth(values)
+ return EarthModel(
+ _pscad_number(values, "GRRES"),
+ _pscad_number(values, "GRP"),
+ _pscad_number(values, "GPERM")
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Import a self-contained PSCAD project that follows the supported
+frequency-dependent coaxial-cable schema.
+
+# Arguments
+
+- `file_name`: existing PSCAD project with a `.pscx` extension.
+
+# Keywords
+
+- `definition`: qualified or local name of the frequency-dependent row to import. Leave it unset when the project contains one eligible row or one row selected for output.
+
+# Returns
+
+- `earth`: materialized homogeneous [`EarthModel`](@ref).
+- `system`: materialized [`LineCableSystem`](@ref).
+
+# Notes
+
+The PSCAD schema stores equivalent concentric layers but does not retain the
+source cable's detailed strand geometry, material reference temperature, or
+temperature coefficient. Imported layers use the emitted equivalent
+properties at 20 °C with zero temperature coefficient. Non-eliminated PSCAD
+conductors receive consecutive phase indices in cable and layer order.
+
+PSCAD bounds dielectric loss tangent at ten. A project produced from a more
+conductive equivalent dielectric contains the bounded PSCAD value,
+and import materializes that value rather than the pre-export resistivity.
+
+The importer accepts detailed and simplified coaxial cables. Simplified cables
+must use radius input and may not contain semiconductive layers. Tower and
+pipe-type definitions are rejected.
+
+# Errors
+
+Throws `ArgumentError`, `DomainError`, `DimensionMismatch`, or `KeyError` when
+the file, schema, geometry, or physical parameters are invalid.
+"""
+function import_data(
+ ::Val{:pscad},
+ file_name::AbstractString;
+ definition::Union{Nothing, AbstractString} = nothing
+)
+ extension = lowercase(splitext(file_name)[2])
+ extension == ".pscx" || throw(ArgumentError(
+ "PSCAD import requires a .pscx file",
+ ))
+ isfile(file_name) || throw(ArgumentError(
+ "PSCAD project does not exist: $(abspath(file_name))",
+ ))
+ document = readxml(file_name)
+ project = root(document)
+ nodename(project) == "project" || throw(ArgumentError(
+ "PSCAD input root must be ",
+ ))
+ haskey(project, "name") || throw(ArgumentError(
+ "PSCAD project has no name",
+ ))
+
+ row = _pscad_row_definition(project, definition)
+ cables = _pscad_active_cables(row)
+ instance = _pscad_parameters(_pscad_instance(document, project, row))
+ system_id = strip(_pscad_parameter(instance, "Name"))
+ isempty(system_id) && throw(ArgumentError("PSCAD line instance has no name"))
+ line_length = 1000 * _pscad_number(instance, "Length")
+
+ next_phase = Ref(1)
+ numbered_positions = Pair{Int, NamedTuple}[]
+ for cable in cables
+ values = _pscad_parameters(cable)
+ first_number = _pscad_integer(values, "CABNUM")
+ if _pscad_binding(cable) == "master:Cable_CoaxSimpl"
+ append!(numbered_positions,
+ (first_number + offset - 1) => position
+ for (offset, position) in enumerate(
+ _pscad_simplified_positions(values, next_phase)
+ ))
+ else
+ push!(numbered_positions, first_number => _pscad_position(
+ values,
+ first_number,
+ next_phase
+ ))
+ end
+ end
+ sort!(numbered_positions; by = first)
+ first.(numbered_positions) == collect(eachindex(numbered_positions)) ||
+ throw(ArgumentError(
+ "PSCAD cable numbers must be consecutive and start at one",
+ ))
+ declarations = last.(numbered_positions)
+ earth = _pscad_earth(_pscad_parameters(row.ground))
+ system = build(
+ LineCableSystem,
+ CableDesign[item.design for item in declarations],
+ Pose2[item.position for item in declarations];
+ system_id,
+ line_length,
+ connections = [item.connections for item in declarations]
+ )
+ return earth, system
+end
diff --git a/src/pscad/importexport/project.jl b/src/pscad/importexport/project.jl
new file mode 100644
index 000000000..ab0e9d5be
--- /dev/null
+++ b/src/pscad/importexport/project.jl
@@ -0,0 +1,511 @@
+function _pscad_output_path(system::LineCableSystem, file_name)
+ if file_name === nothing
+ return joinpath(@__DIR__, "$(system.system_id)_export.pscx")
+ end
+ requested = isabspath(file_name) ? String(file_name) : abspath(file_name)
+ directory, name = splitdir(requested)
+ return joinpath(directory, "$(system.system_id)_$name")
+end
+
+const _PSCAD_DEFINITION_DATE = "0"
+const _PSCAD_IDS = (
+ station = 100000001,
+ station_wire = 100000002,
+ main_instance = 100000003,
+ main = 100000004,
+ main_rectangle = 100000005,
+ main_text = 100000006,
+ cable_wire = 100000007,
+ cable_instance = 100000008,
+ cable_system = 100000009,
+ first_physical_component = 100000010
+)
+
+function _pscad_project_settings!(project)
+ settings = Pair{String, String}[
+ "creator" => "LineCableModels.jl",
+ "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" => "Generated by LineCableModels.jl",
+ "Debug" => "0"
+]
+ return _pscad_paramlist!(project, settings; name = "Settings")
+end
+
+function _pscad_project_scaffolding!(project)
+ addelement!(project, "Layers")
+ addelement!(project, "List")["classid"] = "Settings"
+ addelement!(project, "bookmarks")
+ substitutions = addelement!(project, "GlobalSubstitutions")
+ substitutions["name"] = "Default"
+ addelement!(substitutions, "List")["classid"] = "Sub"
+ addelement!(substitutions, "List")["classid"] = "ValueSet"
+ _pscad_paramlist!(substitutions, ["Current" => ""])
+ return project
+end
+
+function _pscad_station_definition!(definitions, namespace::AbstractString)
+ station = addelement!(definitions, "Definition")
+ _pscad_attributes!(station,
+ (
+ id = _PSCAD_IDS.station,
+ classid = "StationDefn",
+ name = "DS",
+ group = "",
+ url = "",
+ version = "",
+ build = "",
+ crc = -1,
+ view = "false"
+ ))
+ _pscad_paramlist!(station, ["Description" => ""]; name = "")
+ schematic = addelement!(station, "schematic")
+ schematic["classid"] = "StationCanvas"
+ _pscad_canvas_params!(schematic)
+ addelement!(schematic, "grouping")
+ _pscad_wire!(
+ schematic,
+ _PSCAD_IDS.station_wire,
+ _PSCAD_IDS.main_instance,
+ "Branch",
+ "Main",
+ "Main",
+ "$namespace:Main",
+ Pair{String, String}[];
+ x = 180,
+ y = 180,
+ width = 66,
+ height = 82
+ )
+ return station
+end
+
+function _pscad_main_instance_parameters(system::LineCableSystem, base_frequency)
+ length_km = system.line_length / 1000
+ return Pair{String, String}[
+ "Name" => system.system_id,
+ "R" => "#NaN",
+ "X" => "#NaN",
+ "B" => "#NaN",
+ "Freq" => _pscad_value(base_frequency),
+ "Length" => _pscad_value(length_km),
+ "Dim" => "0",
+ "Mode" => "0",
+ "CoupleEnab" => "0",
+ "CoupleName" => "row",
+ "CoupleOffset" => "0.0 [m]",
+ "CoupleRef" => "0",
+ "tname" => "tandem_segment",
+ "sfault" => "0",
+ "linc" => "$(_pscad_value(length_km)) [km]",
+ "steps" => "3",
+ "gen_cnst" => "1",
+ "const_path" => "%TEMP%\\my_constants_file.tlo",
+ "Date" => _PSCAD_DEFINITION_DATE
+ ]
+end
+
+function _pscad_main_definition!(
+ definitions,
+ system::LineCableSystem,
+ base_frequency
+)
+ namespace = system.system_id
+ main = addelement!(definitions, "Definition")
+ _pscad_attributes!(main,
+ (
+ id = _PSCAD_IDS.main,
+ classid = "UserCmpDefn",
+ name = "Main",
+ group = "",
+ url = "",
+ version = "",
+ build = "",
+ crc = -1,
+ view = "false",
+ date = _PSCAD_DEFINITION_DATE
+ ))
+ _pscad_paramlist!(main, ["Description" => ""]; name = "")
+ form = addelement!(main, "form")
+ _pscad_attributes!(form, (name = "", w = 320, h = 400, splitter = 60))
+
+ graphics = addelement!(main, "graphics")
+ _pscad_attributes!(graphics, (viewBox = "-200 -200 200 200", size = 2))
+ rectangle = addelement!(graphics, "Gfx")
+ _pscad_attributes!(rectangle,
+ (
+ id = _PSCAD_IDS.main_rectangle,
+ classid = "Graphics.Rectangle",
+ x = -36,
+ y = -36,
+ w = 72,
+ h = 72
+ ))
+ _pscad_paramlist!(rectangle,
+ [
+ "color" => "Black",
+ "dasharray" => "0",
+ "thickness" => "0",
+ "port" => "",
+ "fill_style" => "0",
+ "fill_fg" => "Black",
+ "fill_bg" => "Black",
+ "cond" => "true"
+ ])
+ text = addelement!(graphics, "Gfx")
+ _pscad_attributes!(text, (
+ id = _PSCAD_IDS.main_text,
+ classid = "Graphics.Text",
+ x = 0,
+ y = 0
+ ))
+ _pscad_paramlist!(text,
+ [
+ "text" => "%:Name",
+ "anchor" => "0",
+ "full_font" => "Tahoma, 13world",
+ "angle" => "0",
+ "color" => "Black",
+ "cond" => "true"
+ ])
+
+ schematic = addelement!(main, "schematic")
+ schematic["classid"] = "UserCanvas"
+ _pscad_canvas_params!(schematic; user = true)
+ addelement!(schematic, "grouping")
+ _pscad_wire!(
+ schematic,
+ _PSCAD_IDS.cable_wire,
+ _PSCAD_IDS.cable_instance,
+ "Cable",
+ "$namespace:CableSystem",
+ "$namespace:CableSystem",
+ "$namespace:CableSystem",
+ _pscad_main_instance_parameters(system, base_frequency);
+ x = 72,
+ y = 36,
+ width = 107,
+ height = 128,
+ crc = -1
+ )
+ return main
+end
+
+function _pscad_hierarchy!(project, namespace::AbstractString)
+ addelement!(project, "List")["classid"] = "Resource"
+ hierarchy = addelement!(project, "hierarchy")
+ station = addelement!(hierarchy, "call")
+ _pscad_attributes!(station,
+ (
+ link = _PSCAD_IDS.station,
+ name = "$namespace:DS",
+ z = -1,
+ view = "false",
+ instance = 0
+ ))
+ main = addelement!(station, "call")
+ _pscad_attributes!(main,
+ (
+ link = _PSCAD_IDS.main_instance,
+ name = "$namespace:Main",
+ z = -1,
+ view = "false",
+ instance = 0
+ ))
+ cable = addelement!(main, "call")
+ _pscad_attributes!(cable,
+ (
+ link = _PSCAD_IDS.cable_instance,
+ name = "$namespace:CableSystem",
+ z = -1,
+ view = "true",
+ instance = 0
+ ))
+ return hierarchy
+end
+
+function _pscad_part_parameters(component, index::Int, angular_frequency)
+ radii = (("R1", "R2", "R3"), ("R4", "R4", "R5"),
+ ("R6", "R6", "R7"), ("R8", "R8", "R9"))
+ resistivities = ("RHOC", "RHOS", "RHOA", "RHOO")
+ permeabilities = ("PERMC", "PERMS", "PERMA", "PERMO")
+ dielectric_permittivities = ("EPS1", "EPS2", "EPS3", "EPS4")
+ dielectric_permeabilities = ("PERM1", "PERM2", "PERM3", "PERM4")
+ loss_tangents = ("LT1", "LT2", "LT3", "LT4")
+ conductor_name = "CONNAM$index"
+ inner_name, outer_name, insulation_name = radii[index]
+ conductor = component.conductor
+ dielectric = component.dielectric
+ capacitance = dielectric.shunt_capacitance
+ conductance = dielectric.shunt_conductance
+ loss = iszero(capacitance) ? zero(capacitance) :
+ conductance / (angular_frequency * capacitance)
+
+ parameters = Pair{String, String}[
+ conductor_name => uppercasefirst(String(component.name)),
+ outer_name => _pscad_value(conductor.r_ex),
+ resistivities[index] => _pscad_value(conductor.material.rho),
+ permeabilities[index] => _pscad_value(conductor.material.mu_r),
+ insulation_name => _pscad_value(dielectric.r_ex),
+ dielectric_permittivities[index] => _pscad_value(dielectric.material.eps_r),
+ dielectric_permeabilities[index] => _pscad_value(dielectric.material.mu_r),
+ loss_tangents[index] => _pscad_value(
+ loss; maximum = 10)
+]
+ index == 1 && pushfirst!(
+ parameters,
+ inner_name => _pscad_value(conductor.r_in)
+ )
+ index > 1 && push!(parameters, "elim$(index - 1)" => "0")
+ push!(parameters, "T$(2index + 1)" => "0.0000")
+ index == 1 && append!(parameters, [
+ "SemiCL" => "0", "SL2" => "0.0000", "SL1" => "0.0000"
+ ])
+ return parameters
+end
+
+function _empty_pscad_part(index::Int)
+ radii = (("R1", "R2", "R3"), ("R4", "R4", "R5"),
+ ("R6", "R6", "R7"), ("R8", "R8", "R9"))
+ resistivities = ("RHOC", "RHOS", "RHOA", "RHOO")
+ permeabilities = ("PERMC", "PERMS", "PERMA", "PERMO")
+ epsilons = ("EPS1", "EPS2", "EPS3", "EPS4")
+ mus = ("PERM1", "PERM2", "PERM3", "PERM4")
+ losses = ("LT1", "LT2", "LT3", "LT4")
+ _, outer_name, insulation_name = radii[index]
+ parameters = Pair{String, String}[
+ "CONNAM$index" => "none",
+ outer_name => "0.0",
+ resistivities[index] => "0.0",
+ permeabilities[index] => "0.0",
+ insulation_name => "0.0",
+ epsilons[index] => "0.0",
+ mus[index] => "0.0",
+ losses[index] => "0.0000",
+ "T$(2index + 1)" => "0.0000"
+]
+ index > 1 && push!(parameters, "elim$(index - 1)" => "0")
+ return parameters
+end
+
+function _pscad_components(blueprint::Engine.CableBlueprint, frequency, formulation, temperature)
+ T = eltype(blueprint)
+ omega = 2 * (one(T) * pi) * convert(T, frequency)
+ epsilon0 = vacuum_permittivity(T)
+ return map(eachindex(blueprint.conductors)) do index
+ conductor = blueprint.conductors[index]
+ material = conductor.material
+ operating = temperature === nothing ? material.T0 : convert(T, temperature)
+ rho = constitutive(formulation.methods.temperature_dependence, material, operating)
+ metal = Material(material.kind, rho, material.eps_r, material.mu_r,
+ material.T0, material.alpha; rho_thermal = material.rho_thermal,
+ theta_max = material.theta_max, tan_delta = material.tan_delta,
+ sigma_solar = material.sigma_solar)
+ layers = @view blueprint.dielectrics[blueprint.dielectric_ranges[index]]
+ impedance = zero(Complex{T})
+ for layer in layers
+ source = layer.material
+ operating = temperature === nothing ? source.T0 : convert(T, temperature)
+ relation = source.kind === :semicon ? formulation.methods.semicon_admittance :
+ formulation.methods.insulation_admittance
+ kappa = constitutive(relation, source, frequency, operating;
+ temperature_dependence = formulation.methods.temperature_dependence)
+ impedance += inv(Engine.layer_admittance(layer.r_in, layer.r_ex, kappa))
+ end
+ admittance = isempty(layers) ? zero(Complex{T}) : inv(impedance)
+ capacitance = imag(admittance) / omega
+ outer = isempty(layers) ? conductor.r_ex : last(layers).r_ex
+ epsilon = isempty(layers) ? zero(T) :
+ capacitance * log(outer / conductor.r_ex) /
+ (2 * (one(T) * pi) * epsilon0)
+ permeability = isempty(layers) ? one(T) :
+ DataModel.equivalent_dielectric_permeability(
+ layers, conductor.turns_per_length, conductor.r_ex, outer)
+ dielectric = Material(:insulator, oftype(epsilon, Inf), epsilon, permeability)
+ return (name = conductor.terminal,
+ conductor = (r_in = conductor.r_in, r_ex = conductor.r_ex, material = metal),
+ dielectric = (r_ex = outer, material = dielectric,
+ shunt_capacitance = capacitance, shunt_conductance = real(admittance)))
+ end
+end
+
+function _pscad_cable_parameters(
+ design,
+ components,
+ position,
+ connections,
+ index::Int,
+ base_frequency
+)
+ length(components) <= 4 || throw(ArgumentError(
+ "PSCAD Cable_Coax supports at most four concentric components",
+ ))
+ length(connections) == length(components) || throw(DimensionMismatch(
+ "PSCAD phase mapping must match the cable component count",
+ ))
+ # LL=0 is PSCAD's bare-conductor configuration. LL=1 includes insulation.
+ bare = length(components) == 1 &&
+ only(components).dielectric.r_ex == only(components).conductor.r_ex
+ parameters = Pair{String, String}[
+ "CABNUM" => string(index),
+ "Name" => design.cable_id,
+ "X" => _pscad_value(position.x),
+ "OHC" => position.y < 0 ?
+ "0" :
+ "1",
+ "Y" => position.y < 0 ?
+ _pscad_value(abs(position.y)) :
+ "0.0",
+ "Y2" => position.y > 0 ?
+ _pscad_value(position.y) :
+ "0.0",
+ "ShuntA" => "1.0e-38 [mho/m]",
+ "FLT" => _pscad_value(base_frequency),
+ "RorT" => "0",
+ "LL" => bare ?
+ "0" :
+ string(2length(components) - 1),
+ "CROSSBOND" => "0",
+ "GROUPNO" => "1",
+ "CBC1" => "1",
+ "CBC2" => "0",
+ "CBC3" => "0",
+ "CBC4" => "0",
+ "SHRad" => "1",
+ "LC" => "3"
+]
+ angular_frequency = 2 * pi * base_frequency
+ for (component_index, component) in enumerate(components)
+ append!(parameters, _pscad_part_parameters(
+ component, component_index, angular_frequency
+ ))
+ if component_index > 1
+ elimination = "elim$(component_index - 1)"
+ parameter_index = findlast(pair -> first(pair) == elimination, parameters)
+ terminal_index = only(findall(==(component.name), design.terminal_order))
+ parameters[parameter_index] = elimination => (connections[terminal_index] ==
+ 0 ? "1" : "0")
+ end
+ end
+ for component_index in (length(components) + 1):4
+ append!(parameters, _empty_pscad_part(component_index))
+ end
+ return parameters
+end
+
+function _pscad_project(system::LineCableSystem, earth::EarthModel, base_frequency, components;
+ native_settings::NamedTuple = (;))
+ isempty(setdiff(keys(native_settings), (:ground, :frequency))) || throw(ArgumentError(
+ "PSCAD native settings require ground and frequency fields"))
+ document = XMLDocument()
+ project = ElementNode("project")
+ setroot!(document, project)
+ _pscad_attributes!(project, (
+ name = system.system_id,
+ version = "5.0.2",
+ schema = "",
+ Target = "EMTDC"
+ ))
+ _pscad_project_settings!(project)
+ _pscad_project_scaffolding!(project)
+
+ definitions = addelement!(project, "definitions")
+ _pscad_station_definition!(definitions, system.system_id)
+ _pscad_main_definition!(definitions, system, base_frequency)
+
+ definition = addelement!(definitions, "Definition")
+ _pscad_attributes!(definition,
+ (
+ id = _PSCAD_IDS.cable_system,
+ classid = "RowDefn",
+ name = "CableSystem",
+ group = "",
+ url = "",
+ version = "RowDefn",
+ build = "RowDefn",
+ crc = -1,
+ key = "",
+ view = "false",
+ date = _PSCAD_DEFINITION_DATE
+ ))
+ _pscad_paramlist!(definition, ["Description" => "", "type" => "Cable"])
+ schematic = addelement!(definition, "schematic")
+ schematic["classid"] = "RowCanvas"
+ _pscad_paramlist!(schematic, [
+ "show_grid" => "0",
+ "size" => "0",
+ "orient" => "1",
+ "show_border" => "0"
+ ])
+
+ identifier = _PSCAD_IDS.first_physical_component
+ frequency_parameters = [
+ "FS" => "0.5", "FE" => "1.0E6", "Numf" => "100",
+ "DCCOR" => "1", "CPASS" => "0", "enablf" => "1", "shntcab" => "0.0"]
+ for (field, setting) in pairs(get(native_settings, :frequency, (;)))
+ index = findfirst(pair -> first(pair) == string(field), frequency_parameters)
+ index === nothing &&
+ throw(ArgumentError("unsupported PSCAD frequency field $field"))
+ frequency_parameters[index] = string(field) => _pscad_value(setting.value)
+ end
+ _pscad_user!(schematic, identifier, _PSCAD_FREQUENCY_BINDING,
+ frequency_parameters; x = 576, y = 180)
+ addelement!(schematic, "grouping")
+ identifier += 1
+ for (index, (design, position, connections)) in enumerate(zip(
+ system.designs,
+ system.positions,
+ system.connections
+ ))
+ _pscad_user!(
+ schematic,
+ identifier,
+ _PSCAD_CABLE_BINDING,
+ _pscad_cable_parameters(
+ design,
+ components[index],
+ position,
+ connections,
+ index,
+ base_frequency
+ );
+ x = 234 + (index - 1) * 400,
+ y = 612
+ )
+ identifier += 1
+ end
+ ground = last(earth.layers)
+ ground_parameters = [
+ "EarthForm2" => "0", "EarthForm" => "3", "EarthForm3" => "2",
+ "GrRho" => "0", "GRRES" => _pscad_value(ground.rho),
+ "GPERM" => _pscad_value(ground.mu_r), "K0" => "0.001",
+ "K1" => "0.01", "alpha" => "0.7", "GRP" => _pscad_value(ground.eps_r)]
+ for (field, setting) in pairs(get(native_settings, :ground, (;)))
+ index = findfirst(pair -> first(pair) == string(field), ground_parameters)
+ index === nothing && throw(ArgumentError("unsupported PSCAD ground field $field"))
+ ground_parameters[index] = string(field) => _pscad_value(setting.value)
+ end
+ _pscad_user!(schematic, identifier, _PSCAD_GROUND_BINDING,
+ ground_parameters; x = 504, y = 288)
+ _pscad_hierarchy!(project, system.system_id)
+ return document
+end
diff --git a/src/pscad/importexport/pscad.jl b/src/pscad/importexport/pscad.jl
new file mode 100644
index 000000000..d27593217
--- /dev/null
+++ b/src/pscad/importexport/pscad.jl
@@ -0,0 +1,79 @@
+include("schema.jl")
+include("project.jl")
+include("import.jl")
+
+"""
+$(TYPEDSIGNATURES)
+
+Export a [`LineCableSystem`](@ref) as a minimal PSCAD project.
+
+The generated project preserves PSCAD's `master:Line_FrePhase_Options`,
+`master:Cable_Coax`, and `master:Line_Ground` component bindings. Cable
+geometry, material properties, dielectric losses, phase eliminations, line
+length, base frequency, and static earth properties are emitted as component
+parameters. PSCAD may normalize the deterministic placeholder identifiers when
+it opens the project.
+
+# Arguments
+
+- `system`: materialized line and cable geometry.
+- `earth`: physical air and one infinite homogeneous soil half-space.
+- `base_freq`: base frequency in hertz.
+- `file_name`: destination `.pscx` file. The system identifier is prepended to
+ an explicitly supplied basename.
+- `formulation`: selected line-parameter or cable-constant formulation.
+ The default routes to the lossless dielectric relation. Request `:lossy`
+ explicitly to include the supplied material losses.
+- `native_settings=(;)`: optional validated native `ground` and `frequency`
+ field records supplied by the PSCAD formula adapter. Each field includes its
+ `value` and expected `readback`. The same record accompanies native execution.
+- `temperature=nothing`: optional operating temperature \\[°C\\]. Correction is
+ applied here during export, not in geometric flattening. `nothing` retains
+ the material reference temperatures.
+
+!!! note
+ PSCAD uses a reference-frequency equivalent loss tangent, bounded at ten.
+ It does not reproduce an arbitrary broadband constitutive law. Exporting
+ the selected relation matches its radial admittance at `base_freq` before
+ that cap, not necessarily at every frequency in a later PSCAD scan.
+
+# Returns
+
+The written path. Filesystem errors are propagated to the caller.
+"""
+function export_data(
+ ::Val{:pscad},
+ system::LineCableSystem,
+ earth::EarthModel;
+ formulation::Union{Engine.LineParametersFormulation,
+ Engine.CableConstantsFormulation, PSCADFormulation} = Engine.Formulation(),
+ base_freq::Real = formulation isa PSCADFormulation ?
+ formulation.options.data.base_frequency : 50.0,
+ temperature::Union{Nothing, Real} = nothing,
+ file_name::Union{AbstractString, Nothing} = nothing,
+ native_settings::NamedTuple = (;)
+)
+ _pscad_deterministic(eltype(system), eltype(earth), typeof(base_freq), typeof(temperature))
+ isfinite(base_freq) && base_freq > zero(base_freq) || throw(DomainError(
+ base_freq, "PSCAD base frequency must be positive and finite"
+ ))
+ validate(earth, PSCADFormulation)
+ path = _pscad_output_path(system, file_name)
+ #! explicit-imports: off
+ # EzXML does not mark XMLError public, but this exporter preserves the
+ # established IOError behavior for invalid XML output destinations.
+ isdir(path) && throw(EzXML.XMLError(
+ 8,
+ 0,
+ "PSCAD output path is a directory: $path",
+ 2,
+ 0
+ ))
+ #! explicit-imports: on
+ blueprints = _pscad_blueprints(system)
+ components = [_pscad_components(blueprint, base_freq, formulation, temperature)
+ for blueprint in blueprints]
+ document = _pscad_project(system, earth, base_freq, components; native_settings)
+ write(path, document)
+ return path
+end
diff --git a/src/pscad/importexport/schema.jl b/src/pscad/importexport/schema.jl
new file mode 100644
index 000000000..55f9ece92
--- /dev/null
+++ b/src/pscad/importexport/schema.jl
@@ -0,0 +1,133 @@
+const _PSCAD_FREQUENCY_BINDING = "master:Line_FrePhase_Options"
+const _PSCAD_CABLE_BINDING = "master:Cable_Coax"
+const _PSCAD_GROUND_BINDING = "master:Line_Ground"
+
+function _pscad_attributes!(node, attributes)
+ for (name, value) in pairs(attributes)
+ node[string(name)] = string(value)
+ end
+ return node
+end
+
+function _pscad_paramlist!(
+ parent,
+ parameters;
+ name = nothing,
+ link = nothing,
+ crc = nothing
+)
+ list = addelement!(parent, "paramlist")
+ name === nothing || (list["name"] = string(name))
+ link === nothing || (list["link"] = string(link))
+ crc === nothing || (list["crc"] = string(crc))
+ for (name, value) in parameters
+ parameter = addelement!(list, "param")
+ parameter["name"] = string(name)
+ parameter["value"] = string(value)
+ end
+ return list
+end
+
+function _pscad_params!(parent, parameters)
+ return _pscad_paramlist!(parent, parameters; name = "", link = -1, crc = -1)
+end
+
+function _pscad_user!(
+ parent,
+ id::Integer,
+ binding::AbstractString,
+ parameters;
+ x::Integer = 0,
+ y::Integer = 0
+)
+ user = addelement!(parent, "User")
+ _pscad_attributes!(user,
+ (
+ id = id,
+ name = binding,
+ classid = "UserCmp",
+ x = x,
+ y = y,
+ w = 0,
+ h = 0,
+ z = -1,
+ orient = 0,
+ defn = binding,
+ link = -1,
+ q = 4,
+ disable = "false"
+ ))
+ _pscad_params!(user, parameters)
+ return user
+end
+
+function _pscad_wire!(
+ parent,
+ wire_id::Integer,
+ user_id::Integer,
+ kind::AbstractString,
+ name::AbstractString,
+ definition::AbstractString,
+ user_binding::AbstractString,
+ parameters;
+ x::Integer,
+ y::Integer,
+ width::Integer,
+ height::Integer,
+ crc = nothing
+)
+ wire = addelement!(parent, "Wire")
+ attributes = (
+ id = wire_id,
+ name,
+ classid = kind,
+ x,
+ y,
+ w = width,
+ h = height,
+ orient = 0,
+ defn = definition,
+ recv = -1,
+ send = -1,
+ back = -1,
+ disable = "false"
+ )
+ _pscad_attributes!(wire, attributes)
+ crc === nothing || (wire["crc"] = string(crc))
+ for (vertex_x, vertex_y) in ((0, 0), (0, 18), (54, 54), (54, 72))
+ vertex = addelement!(wire, "vertex")
+ _pscad_attributes!(vertex, (x = vertex_x, y = vertex_y))
+ end
+ _pscad_user!(wire, user_id, user_binding, parameters)
+ return wire
+end
+
+function _pscad_canvas_params!(canvas; user::Bool = false)
+ parameters = Pair{String, String}[
+ "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"
+]
+ user && append!(parameters, [
+ "show_terminals" => "0",
+ "virtual_filter" => "",
+ "animation_freq" => "500"
+ ])
+ return _pscad_paramlist!(canvas, parameters)
+end
+
+function _pscad_value(value; sigdigits::Integer = 6, minimum = -Inf, maximum = Inf)
+ _pscad_deterministic(typeof(value))
+ scalar = clamp(round(value; sigdigits), minimum, maximum)
+ iszero(scalar) && return "0.0"
+ return string(scalar)
+end
diff --git a/src/pscad/remote/Manifest.toml b/src/pscad/remote/Manifest.toml
new file mode 100644
index 000000000..532198ade
--- /dev/null
+++ b/src/pscad/remote/Manifest.toml
@@ -0,0 +1,318 @@
+# This file is machine-generated - editing it directly is not advised
+
+julia_version = "1.12.7"
+manifest_format = "2.0"
+project_hash = "140fc71a07663969a7fa7c65059b100c6443e145"
+
+[[deps.ArgTools]]
+uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f"
+version = "1.1.2"
+
+[[deps.Artifacts]]
+uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33"
+version = "1.11.0"
+
+[[deps.Base64]]
+uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
+version = "1.11.0"
+
+[[deps.CompilerSupportLibraries_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae"
+version = "1.3.1+2"
+
+[[deps.CondaPkg]]
+deps = ["JSON", "Markdown", "MicroMamba", "Pidfile", "Pkg", "Preferences", "Scratch", "TOML", "pixi_jll"]
+git-tree-sha1 = "2b1afb8ae65a0758795b00adafb37f97e67ef0e9"
+uuid = "992eb4ea-22a4-4c89-a5bb-47a3300528ab"
+version = "0.2.36"
+
+[[deps.DataAPI]]
+git-tree-sha1 = "abe83f3a2f1b857aac70ef8b269080af17764bbe"
+uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a"
+version = "1.16.0"
+
+[[deps.DataValueInterfaces]]
+git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6"
+uuid = "e2d170a0-9d28-54be-80f0-106bbe20a464"
+version = "1.0.0"
+
+[[deps.Dates]]
+deps = ["Printf"]
+uuid = "ade2ca70-3891-5945-98fb-dc099432e06a"
+version = "1.11.0"
+
+[[deps.Downloads]]
+deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"]
+uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6"
+version = "1.7.0"
+
+[[deps.FileWatching]]
+uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee"
+version = "1.11.0"
+
+[[deps.InteractiveUtils]]
+deps = ["Markdown"]
+uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240"
+version = "1.11.0"
+
+[[deps.IteratorInterfaceExtensions]]
+git-tree-sha1 = "a3f24677c21f5bbe9d2a714f95dcd58337fb2856"
+uuid = "82899510-4779-5014-852e-03e436cf321d"
+version = "1.0.0"
+
+[[deps.JLLWrappers]]
+deps = ["Artifacts", "Preferences"]
+git-tree-sha1 = "7204148362dafe5fe6a273f855b8ccbe4df8173e"
+uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210"
+version = "1.8.0"
+
+[[deps.JSON]]
+deps = ["Dates", "Logging", "Parsers", "PrecompileTools", "StructUtils", "UUIDs", "Unicode"]
+git-tree-sha1 = "c7345ab1a7ca4dc8a02c9f6510da0d9857bbe513"
+uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
+version = "1.7.1"
+
+ [deps.JSON.extensions]
+ JSONArrowExt = ["ArrowTypes"]
+
+ [deps.JSON.weakdeps]
+ ArrowTypes = "31f734f8-188a-4ce0-8406-c8a06bd891cd"
+
+[[deps.JuliaSyntaxHighlighting]]
+deps = ["StyledStrings"]
+uuid = "ac6e5ff7-fb65-4e79-a425-ec3bc9c03011"
+version = "1.12.0"
+
+[[deps.LazyArtifacts]]
+deps = ["Artifacts", "Pkg"]
+uuid = "4af54fe1-eca0-43a8-85a7-787d91b784e3"
+version = "1.11.0"
+
+[[deps.LibCURL]]
+deps = ["LibCURL_jll", "MozillaCACerts_jll"]
+uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21"
+version = "0.6.4"
+
+[[deps.LibCURL_jll]]
+deps = ["Artifacts", "LibSSH2_jll", "Libdl", "OpenSSL_jll", "Zlib_jll", "nghttp2_jll"]
+uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0"
+version = "8.15.0+0"
+
+[[deps.LibGit2]]
+deps = ["LibGit2_jll", "NetworkOptions", "Printf", "SHA"]
+uuid = "76f85450-5226-5b5a-8eaa-529ad045b433"
+version = "1.11.0"
+
+[[deps.LibGit2_jll]]
+deps = ["Artifacts", "LibSSH2_jll", "Libdl", "OpenSSL_jll"]
+uuid = "e37daf67-58a4-590a-8e99-b0245dd2ffc5"
+version = "1.9.0+0"
+
+[[deps.LibSSH2_jll]]
+deps = ["Artifacts", "Libdl", "OpenSSL_jll"]
+uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8"
+version = "1.11.3+1"
+
+[[deps.Libdl]]
+uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb"
+version = "1.11.0"
+
+[[deps.Logging]]
+uuid = "56ddb016-857b-54e1-b83d-db4d58db5568"
+version = "1.11.0"
+
+[[deps.MacroTools]]
+git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522"
+uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09"
+version = "0.5.16"
+
+[[deps.Markdown]]
+deps = ["Base64", "JuliaSyntaxHighlighting", "StyledStrings"]
+uuid = "d6f4376e-aef5-505a-96c1-9c027394607a"
+version = "1.11.0"
+
+[[deps.MicroMamba]]
+deps = ["Pkg", "Scratch", "micromamba_jll"]
+git-tree-sha1 = "535656ce55266bfed0575cd051acc4f36dc869a0"
+uuid = "0b3b1443-0f03-428d-bdfb-f27f9c1191ea"
+version = "0.1.15"
+
+[[deps.MozillaCACerts_jll]]
+uuid = "14a3606d-f60d-562e-9121-12d972cd8159"
+version = "2025.11.4"
+
+[[deps.NetworkOptions]]
+uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908"
+version = "1.3.0"
+
+[[deps.OpenSSL_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95"
+version = "3.5.6+0"
+
+[[deps.OrderedCollections]]
+git-tree-sha1 = "05f45c2e0de6259db764adbfd2f1dc6d3f8de13c"
+uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
+version = "2.0.1"
+
+[[deps.Parsers]]
+deps = ["Dates", "PrecompileTools", "UUIDs"]
+git-tree-sha1 = "3de8f5e6e90ebfa8d6d1f86997d6cdcd6a912ff3"
+uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0"
+version = "2.8.7"
+
+[[deps.Pidfile]]
+deps = ["FileWatching", "Test"]
+git-tree-sha1 = "2d8aaf8ee10df53d0dfb9b8ee44ae7c04ced2b03"
+uuid = "fa939f87-e72e-5be4-a000-7fc836dbe307"
+version = "1.3.0"
+
+[[deps.Pkg]]
+deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "Random", "SHA", "TOML", "Tar", "UUIDs", "p7zip_jll"]
+uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
+version = "1.12.1"
+
+ [deps.Pkg.extensions]
+ REPLExt = "REPL"
+
+ [deps.Pkg.weakdeps]
+ REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
+
+[[deps.PrecompileTools]]
+deps = ["Preferences"]
+git-tree-sha1 = "edbeefc7a4889f528644251bdb5fc9ab5348bc2c"
+uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a"
+version = "1.3.4"
+
+[[deps.Preferences]]
+deps = ["TOML"]
+git-tree-sha1 = "8b770b60760d4451834fe79dd483e318eee709c4"
+uuid = "21216c6a-2e73-6563-6e65-726566657250"
+version = "1.5.2"
+
+[[deps.Printf]]
+deps = ["Unicode"]
+uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7"
+version = "1.11.0"
+
+[[deps.PythonCall]]
+deps = ["CondaPkg", "Dates", "Libdl", "MacroTools", "Markdown", "Preferences", "Serialization", "Tables", "UnsafePointers"]
+git-tree-sha1 = "2b67e030054dd9438a00e3d7f59927e839b00569"
+uuid = "6099a3de-0909-46bc-b1f4-468b9a2dfc0d"
+version = "0.9.35"
+
+ [deps.PythonCall.extensions]
+ CategoricalArraysExt = "CategoricalArrays"
+ PyCallExt = "PyCall"
+
+ [deps.PythonCall.weakdeps]
+ CategoricalArrays = "324d7699-5711-5eae-9e2f-1d82baa6b597"
+ PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0"
+
+[[deps.Random]]
+deps = ["SHA"]
+uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
+version = "1.11.0"
+
+[[deps.SHA]]
+uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce"
+version = "0.7.0"
+
+[[deps.Scratch]]
+deps = ["Dates"]
+git-tree-sha1 = "9b81b8393e50b7d4e6d0a9f14e192294d3b7c109"
+uuid = "6c6a2e73-6563-6170-7368-637461726353"
+version = "1.3.0"
+
+[[deps.Serialization]]
+uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
+version = "1.11.0"
+
+[[deps.StructUtils]]
+deps = ["Dates", "UUIDs"]
+git-tree-sha1 = "2d0fc55c61321ba245c47be599570d11bac50303"
+uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42"
+version = "2.8.5"
+
+ [deps.StructUtils.extensions]
+ StructUtilsMeasurementsExt = ["Measurements"]
+ StructUtilsStaticArraysCoreExt = ["StaticArraysCore"]
+ StructUtilsTablesExt = ["Tables"]
+
+ [deps.StructUtils.weakdeps]
+ Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7"
+ StaticArraysCore = "1e83bf80-4336-4d27-bf5d-d5a4f845583c"
+ Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c"
+
+[[deps.StyledStrings]]
+uuid = "f489334b-da3d-4c2e-b8f0-e476e12c162b"
+version = "1.11.0"
+
+[[deps.TOML]]
+deps = ["Dates"]
+uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76"
+version = "1.0.3"
+
+[[deps.TableTraits]]
+deps = ["IteratorInterfaceExtensions"]
+git-tree-sha1 = "c06b2f539df1c6efa794486abfb6ed2022561a39"
+uuid = "3783bdb8-4a98-5b6b-af9a-565f29a5fe9c"
+version = "1.0.1"
+
+[[deps.Tables]]
+deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "OrderedCollections", "TableTraits"]
+git-tree-sha1 = "0f38a06c83f0007bbab3cf911262841c9a0f07e0"
+uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c"
+version = "1.13.0"
+
+[[deps.Tar]]
+deps = ["ArgTools", "SHA"]
+uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e"
+version = "1.10.0"
+
+[[deps.Test]]
+deps = ["InteractiveUtils", "Logging", "Random", "Serialization"]
+uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+version = "1.11.0"
+
+[[deps.UUIDs]]
+deps = ["Random", "SHA"]
+uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
+version = "1.11.0"
+
+[[deps.Unicode]]
+uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5"
+version = "1.11.0"
+
+[[deps.UnsafePointers]]
+git-tree-sha1 = "c81331b3b2e60a982be57c046ec91f599ede674a"
+uuid = "e17b2a0c-0bdf-430a-bd0c-3a23cae4ff39"
+version = "1.0.0"
+
+[[deps.Zlib_jll]]
+deps = ["Libdl"]
+uuid = "83775a58-1f1d-513f-b197-d71354ab007a"
+version = "1.3.1+2"
+
+[[deps.micromamba_jll]]
+deps = ["Artifacts", "JLLWrappers", "LazyArtifacts", "Libdl"]
+git-tree-sha1 = "717df6f6892af4ee13279a73aa58474e58a88667"
+uuid = "f8abcde7-e9b7-5caa-b8af-a437887ae8e4"
+version = "2.3.1+0"
+
+[[deps.nghttp2_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
+version = "1.64.0+1"
+
+[[deps.p7zip_jll]]
+deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"]
+uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0"
+version = "17.7.0+0"
+
+[[deps.pixi_jll]]
+deps = ["Artifacts", "JLLWrappers", "LazyArtifacts", "Libdl"]
+git-tree-sha1 = "56c56fede8f01e1be7e2fdf1eb911487640619a0"
+uuid = "4d7b5844-a134-5dcd-ac86-c8f19cd51bed"
+version = "0.76.2+0"
diff --git a/src/pscad/remote/Project.toml b/src/pscad/remote/Project.toml
new file mode 100644
index 000000000..339654085
--- /dev/null
+++ b/src/pscad/remote/Project.toml
@@ -0,0 +1,7 @@
+[deps]
+PythonCall = "6099a3de-0909-46bc-b1f4-468b9a2dfc0d"
+TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76"
+
+[compat]
+PythonCall = "0.9"
+julia = "1.12"
diff --git a/src/pscad/remote/configuration.jl b/src/pscad/remote/configuration.jl
new file mode 100644
index 000000000..fcb6d828b
--- /dev/null
+++ b/src/pscad/remote/configuration.jl
@@ -0,0 +1,135 @@
+const PSCAD_TIMING_SCOPE = "PSCAD compile call; excludes output-readiness wait and transfer"
+
+"""
+$(TYPEDEF)
+
+Station connection and filesystem mapping for native PSCAD execution.
+`local_root` and `shared_root` name the same directory on the caller and station.
+`remote_root` is scratch space on the station. Construction from field values
+does not perform I/O. Construction from a TOML filename only reads that file.
+`timeout` is the remote execution limit \\[s\\], defaulting to 1800.
+
+$(TYPEDFIELDS)
+"""
+struct RemoteConfig
+ local_root::String
+ host::String
+ shared_root::String
+ remote_root::String
+ julia_executable::String
+ python_executable::String
+ pscad_version::String
+ transport::Symbol
+ "Remote execution timeout \\[s\\]."
+ timeout::Int
+ "Argument array for `:command` transport. Exact `{host}` arguments are replaced."
+ command::Vector{String}
+end
+
+function RemoteConfig(
+ host::AbstractString,
+ shared_root::AbstractString,
+ remote_root::AbstractString,
+ julia_executable::AbstractString,
+ python_executable::AbstractString;
+ local_root::AbstractString,
+ pscad_version::AbstractString = "5.1.0",
+ transport::Symbol = :ssh,
+ verbosity = nothing,
+ timeout::Integer = 1800,
+ command::AbstractVector{<:AbstractString} = String[]
+)
+ verbosity === nothing || throw(ArgumentError(
+ "RemoteConfig no longer owns verbosity; set it with " *
+ "options=(verbosity=(default=0, PSCAD=2),) in compute",
+ ))
+ isempty(strip(host)) && throw(ArgumentError("PSCAD host cannot be empty"))
+ isempty(strip(local_root)) && throw(ArgumentError("PSCAD local root cannot be empty"))
+ isempty(strip(shared_root)) && throw(ArgumentError(
+ "PSCAD shared root cannot be empty",
+ ))
+ isempty(strip(remote_root)) && throw(ArgumentError("PSCAD remote root cannot be empty"))
+ isempty(strip(julia_executable)) && throw(ArgumentError(
+ "PSCAD-host Julia executable cannot be empty",
+ ))
+ isempty(strip(python_executable)) && throw(ArgumentError(
+ "PSCAD-host Python executable cannot be empty",
+ ))
+ pscad_version == "5.1.0" || throw(ArgumentError(
+ "this PSCAD adapter supports version 5.1.0 only",
+ ))
+ timeout > 0 || throw(ArgumentError(
+ "PSCAD timeout must be positive",
+ ))
+ if transport === :command
+ isempty(command) && throw(ArgumentError("PSCAD command transport requires an argument array"))
+ isempty(strip(first(command))) && throw(ArgumentError("PSCAD command executable cannot be empty"))
+ else
+ isempty(command) || throw(ArgumentError("PSCAD command arguments require transport=:command"))
+ end
+ return RemoteConfig(
+ abspath(local_root),
+ String(host),
+ String(shared_root),
+ String(remote_root),
+ String(julia_executable),
+ String(python_executable),
+ String(pscad_version),
+ transport,
+ Int(timeout),
+ String.(command)
+ )
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Read a user-selected TOML file into a station configuration.
+
+# Arguments
+
+- `path`: TOML filename. Required fields are `host`, `local_root`, `shared_root`,
+ `remote_root`, `julia_executable`, and `python_executable`. Optional fields are
+ `pscad_version`, `transport`, `timeout`, and `command`.
+
+# Returns
+
+- A `RemoteConfig`. A relative `local_root` is resolved against the file's
+ directory. Station-side paths are retained verbatim.
+
+# Notes
+
+`transport` is a string identifying a transport method. For `"command"`, `command`
+is an argument array, with exact `{host}` arguments replaced by `host`.
+Encoded PowerShell arguments are appended without shell evaluation.
+"""
+function RemoteConfig(path::AbstractString)
+ values = TOML.parsefile(path)
+ required = ("host", "local_root", "shared_root", "remote_root",
+ "julia_executable", "python_executable")
+ optional = ("pscad_version", "transport", "timeout", "command")
+ unknown = setdiff(keys(values), (required..., optional...))
+ isempty(unknown) || throw(ArgumentError("unknown PSCAD configuration fields: $(sort!(collect(unknown)))"))
+ for name in required
+ get(values, name, nothing) isa AbstractString || throw(ArgumentError(
+ "PSCAD configuration requires string field $name"))
+ end
+ for name in ("pscad_version", "transport")
+ !haskey(values, name) || values[name] isa AbstractString || throw(ArgumentError(
+ "PSCAD configuration field $name must be a string"))
+ end
+ timeout = get(values, "timeout", 1800)
+ timeout isa Integer && !(timeout isa Bool) || throw(ArgumentError(
+ "PSCAD timeout must be an integer"))
+ command = get(values, "command", String[])
+ command isa AbstractVector && all(value -> value isa AbstractString, command) ||
+ throw(ArgumentError("PSCAD command must be an array of strings"))
+ local_root = values["local_root"]
+ isempty(strip(local_root)) && throw(ArgumentError("PSCAD local root cannot be empty"))
+ return RemoteConfig(values["host"], values["shared_root"], values["remote_root"],
+ values["julia_executable"], values["python_executable"];
+ local_root = isabspath(local_root) ? local_root : joinpath(dirname(abspath(path)), local_root),
+ pscad_version = get(values, "pscad_version", "5.1.0"),
+ transport = Symbol(get(values, "transport", "ssh")),
+ timeout = timeout, command = String[value for value in command])
+end
diff --git a/src/pscad/remote/files.jl b/src/pscad/remote/files.jl
new file mode 100644
index 000000000..8bc945550
--- /dev/null
+++ b/src/pscad/remote/files.jl
@@ -0,0 +1,62 @@
+function _output_matches(roots, suffix)
+ matches=String[]
+ for root in unique(filter(isdir, roots))
+ for (directory, _, files) in walkdir(root), file in files
+
+ endswith(lowercase(file), suffix) &&
+ push!(matches, realpath(joinpath(directory, file)))
+ end
+ end
+ unique!(matches)
+ return matches
+end
+
+function _data_rows(path::AbstractString)
+ return count(eachline(path)) do line
+ fields=split(strip(line))
+ length(fields) >= 2 || return false
+ return tryparse(Float64, fields[1]) !== nothing &&
+ tryparse(Float64, fields[2]) !== nothing
+ end
+end
+
+"""
+Wait for complete output rows for at most `timeout` \\[s\\], checking every
+`poll_interval` \\[s\\]. Require the requested row count before returning a path.
+"""
+function _wait_output(
+ roots,
+ suffix::AbstractString,
+ expected_rows::Integer;
+ timeout::Real = 30,
+ poll_interval::Real = 0.1
+)
+ expected_rows > 0 || throw(ArgumentError("expected_rows must be positive"))
+ timeout > 0 || throw(ArgumentError("timeout must be positive"))
+ poll_interval > 0 || throw(ArgumentError("poll_interval must be positive"))
+ deadline=time() + timeout
+ previous=nothing
+ observed="no matching file"
+ while time() < deadline
+ matches=_output_matches(roots, suffix)
+ length(matches) <= 1 || throw(ArgumentError(
+ "PSCAD emitted $(length(matches)) files ending in $suffix",
+ ))
+ if length(matches) == 1
+ path=only(matches)
+ rows=_data_rows(path)
+ rows <= expected_rows || throw(ArgumentError(
+ "PSCAD output $path contains $rows rows; expected $expected_rows",
+ ))
+ state=(path, filesize(path), rows)
+ observed="$rows of $expected_rows rows in $path"
+ state == previous && rows == expected_rows && return path
+ previous=state
+ end
+ sleep(poll_interval)
+ end
+ throw(ArgumentError(
+ "PSCAD output $suffix did not become complete within $timeout seconds; " *
+ "last observed $observed",
+ ))
+end
diff --git a/src/pscad/remote/identity.py b/src/pscad/remote/identity.py
new file mode 100644
index 000000000..2515cb5bd
--- /dev/null
+++ b/src/pscad/remote/identity.py
@@ -0,0 +1,57 @@
+"""Identify PSCAD's selected line-constants implementation without solving a model."""
+
+import hashlib
+import platform
+from importlib import metadata
+from pathlib import Path
+
+from mhi.common import process
+import mhi.pscad
+
+
+def identify(version, app=None):
+ if version != "5.1.0":
+ raise ValueError("The LCM PSCAD adapter supports version 5.1.0")
+ executable = Path(process.find_exe("PSCAD", version=version, x64=True)).resolve()
+ root = executable.parents[2]
+ automation_version = metadata.version("mhi.pscad")
+ if automation_version != "3.1.2":
+ raise ValueError("The LCM PSCAD adapter requires mhi.pscad 3.1.2")
+ owned = app is None
+ try:
+ if owned:
+ app = mhi.pscad.launch(version=version, x64=True, minimize=True,
+ splash=False, silence=True, timeout=60)
+ if str(app.version) != version:
+ raise ValueError("PSCAD launched an unexpected application version")
+ # Version-bound workaround: mhi.pscad 3.1.2 settings() initializes its
+ # Fortran codec even for a read, raising "Unable to retrieve detected
+ # software" on this 5.1 station. This is its own underlying read-only
+ # settings call; bypass only that unrelated decoder, not solver settings.
+ # Revisit when the required automation version fixes that reader.
+ selected = app._settings({})["file_lcp"]
+ expanded = str(selected).replace("$(HomeDir)", str(root))
+ solver = Path(expanded)
+ if "$(" in expanded or not solver.is_absolute():
+ raise ValueError("Cannot identify PSCAD's selected LCP executable: " + str(selected))
+ files = {
+ "pscad": executable,
+ "line_constants": solver.resolve(),
+ "master_library": root / "master.pslx",
+ }
+ result = {
+ "schema": "1",
+ "version": version,
+ "python_version": platform.python_version(),
+ "automation_version": automation_version,
+ "common_version": metadata.version("mhi.common"),
+ "profile": "saved profile; numerical inputs supplied explicitly",
+ }
+ for name, path in files.items():
+ result[name + "_path"] = str(path)
+ with path.open("rb") as stream:
+ result[name + "_sha256"] = hashlib.file_digest(stream, "sha256").hexdigest()
+ return result
+ finally:
+ if owned and app is not None:
+ app.quit()
diff --git a/src/pscad/remote/remote.jl b/src/pscad/remote/remote.jl
new file mode 100644
index 000000000..2b9f003d7
--- /dev/null
+++ b/src/pscad/remote/remote.jl
@@ -0,0 +1,414 @@
+function _powershell_argv(powershell::AbstractString)
+ command="\$ProgressPreference='SilentlyContinue'; $(String(powershell))"
+ utf16 = htol.(transcode(UInt16, command))
+ encoded = base64encode(reinterpret(UInt8, utf16))
+ return [
+ "powershell.exe", "-NoProfile", "-NonInteractive",
+ "-EncodedCommand", encoded
+ ]
+end
+
+function remote_command(::Val{:ssh}, config::RemoteConfig, powershell::AbstractString)
+ return Cmd(vcat(["ssh", config.host], _powershell_argv(powershell)))
+end
+
+function remote_command(::Val{:local}, ::RemoteConfig, powershell::AbstractString)
+ return Cmd(_powershell_argv(powershell))
+end
+
+function remote_command(::Val{:command}, config::RemoteConfig, powershell::AbstractString)
+ arguments = [value == "{host}" ? config.host : value for value in config.command]
+ return Cmd(vcat(arguments, _powershell_argv(powershell)))
+end
+
+function remote_command(config::RemoteConfig, powershell::AbstractString)
+ # A caller may supply a transport method after the package was compiled.
+ return Base.invokelatest(
+ remote_command,
+ Val(config.transport),
+ config,
+ powershell
+ )
+end
+
+function _ps_quote(value::AbstractString)
+ return "'" * replace(String(value), "'" => "''") * "'"
+end
+
+function _remote_path(root::AbstractString, parts::AbstractString...)
+ clean = rstrip(String(root), ('\\', '/'))
+ return join((clean, parts...), '\\')
+end
+
+function _remote_project_name(path::AbstractString)
+ return splitext(basename(replace(String(path), '\\' => '/')))[1]
+end
+
+function _run_remote(
+ config::RemoteConfig,
+ powershell::AbstractString;
+ stdout_path::Union{Nothing, AbstractString} = nothing,
+ stderr_path::Union{Nothing, AbstractString} = nothing,
+ stream::Bool = false,
+ on_interrupt::Function = () -> nothing,
+ timeout::Real = config.timeout
+)
+ isfinite(timeout) && timeout > 0 || throw(ArgumentError(
+ "PSCAD transport timeout must be positive and finite"))
+ stdout_path === nothing || mkpath(dirname(stdout_path))
+ stderr_path === nothing || mkpath(dirname(stderr_path))
+ output = Pipe()
+ errors = Pipe()
+ process = run(
+ pipeline(remote_command(config, powershell); stdout = output, stderr = errors);
+ wait = false
+ )
+ close(output.in)
+ close(errors.in)
+ output_buffer = IOBuffer()
+ error_buffer = IOBuffer()
+ output_file = stdout_path === nothing ? nothing : open(stdout_path, "w")
+ error_file = stderr_path === nothing ? nothing : open(stderr_path, "w")
+ output_task = @async for line in eachline(output)
+ println(output_buffer, line)
+ output_file === nothing || println(output_file, line)
+ output_file === nothing || flush(output_file)
+ if stream
+ println(stdout, line)
+ flush(stdout)
+ end
+ end
+ error_task = @async for line in eachline(errors)
+ println(error_buffer, line)
+ error_file === nothing || println(error_file, line)
+ error_file === nothing || flush(error_file)
+ if stream
+ println(stderr, line)
+ flush(stderr)
+ end
+ end
+ interrupted = nothing
+ try
+ finished = timedwait(() -> istaskfailed(output_task) || istaskfailed(error_task) ||
+ (process_exited(process) && istaskdone(output_task) && istaskdone(error_task)),
+ timeout; pollint = min(0.1, timeout / 10))
+ finished === :ok || throw(ErrorException(
+ "PSCAD transport exceeded its timeout of $timeout seconds"))
+ istaskfailed(output_task) && wait(output_task)
+ istaskfailed(error_task) && wait(error_task)
+ wait(process)
+ catch error
+ interrupted = error
+ # SIGKILL (9): a transport must not keep the caller waiting in its own
+ # signal handler after timeout or interruption.
+ process_running(process) && kill(process, 9)
+ if error isa InterruptException
+ try
+ on_interrupt()
+ catch cancellation_error
+ @warn "Remote PSCAD cancellation could not be confirmed" exception = (
+ cancellation_error, catch_backtrace())
+ end
+ end
+ try
+ wait(process)
+ catch wait_error
+ @warn "PSCAD transport wait failed during cleanup" exception = (
+ wait_error, catch_backtrace())
+ end
+ finally
+ if interrupted !== nothing
+ close(output)
+ close(errors)
+ end
+ for (stream_name, reader) in ((:stdout, output_task), (:stderr, error_task))
+ try
+ wait(reader)
+ catch reader_error
+ if interrupted === nothing
+ interrupted = reader_error
+ elseif !(interrupted isa TaskFailedException && interrupted.task === reader)
+ @warn "PSCAD output reader failed during cleanup" stream = stream_name exception = (
+ reader_error, catch_backtrace())
+ end
+ end
+ end
+ for (stream_name, file) in ((:stdout, output_file), (:stderr, error_file))
+ file === nothing && continue
+ try
+ close(file)
+ catch close_error
+ if interrupted === nothing
+ interrupted = close_error
+ else
+ @warn "PSCAD output log could not be closed" stream = stream_name exception = (
+ close_error, catch_backtrace())
+ end
+ end
+ end
+ end
+ stdout_value = String(take!(output_buffer))
+ stderr_value = String(take!(error_buffer))
+ interrupted === nothing || throw(interrupted)
+ success(process) || throw(ErrorException(
+ "remote PSCAD command failed with exit code $(process.exitcode)\n" *
+ "stdout:\n$stdout_value\nstderr:\n$stderr_value",
+ ))
+ return stdout_value
+end
+
+function _supervisor_command(
+ config::RemoteConfig,
+ shared_case::AbstractString,
+ remote_case::AbstractString,
+ project_name::AbstractString,
+ formulation::PSCADFormulation,
+ frequencies_value::AbstractVector;
+ output_stem::AbstractString,
+ verbosity::Integer = 0
+)
+ validate(frequencies_value, PSCADFormulation)
+ label = only(description([formulation];roles=[:none]))
+ increments = length(frequencies_value) - 1
+ shared_supervisor = _remote_path(shared_case, "toolkit", "supervisor.ps1")
+ return join(
+ (
+ "& $(_ps_quote(shared_supervisor))",
+ "-SharedCase $(_ps_quote(shared_case))",
+ "-LocalCase $(_ps_quote(remote_case))",
+ "-Julia $(_ps_quote(config.julia_executable))",
+ "-Python $(_ps_quote(config.python_executable))",
+ "-ProjectName $(_ps_quote(project_name))",
+ "-OutputStem $(_ps_quote(output_stem))",
+ "-Formulation $(_ps_quote(label))",
+ "-FrequencyStart $(_ps_quote(string(first(frequencies_value))))",
+ "-FrequencyEnd $(_ps_quote(string(last(frequencies_value))))",
+ "-FrequencyIncrements $(_ps_quote(string(increments)))",
+ "-PSCADVersion $(_ps_quote(config.pscad_version))",
+ "-Verbosity $(_ps_quote(string(verbosity)))",
+ "-TimeoutSeconds $(_ps_quote(string(config.timeout)))"
+ ),
+ ' ')
+end
+
+function _cancel_command(remote_case::AbstractString)
+ owner_path = _remote_path(remote_case, "owner.txt")
+ return "\$ownerPath=$(_ps_quote(owner_path)); " *
+ "if (-not (Test-Path -LiteralPath \$ownerPath -PathType Leaf)) { exit 0 }; " *
+ "\$owner=@(Get-Content -LiteralPath \$ownerPath); " *
+ "if (\$owner.Count -ne 2) { throw 'invalid PSCAD runner owner file' }; " *
+ "\$runner=[IO.Path]::GetFullPath(\$owner[1]); " *
+ "\$root=[IO.Path]::GetFullPath($(_ps_quote(remote_case))).TrimEnd('\\')+'\\'; " *
+ "if (-not \$runner.StartsWith(\$root,[StringComparison]::OrdinalIgnoreCase)) " *
+ "{ throw 'refusing to stop a process outside the PSCAD case directory' }; " *
+ "\$runnerPid=0; if (-not [int]::TryParse(\$owner[0],[ref]\$runnerPid)) " *
+ "{ throw 'invalid PSCAD runner PID' }; " *
+ "\$process=Get-CimInstance Win32_Process -Filter \"ProcessId = \$runnerPid\"; " *
+ "if (\$null -eq \$process) { Remove-Item -LiteralPath \$ownerPath -Force; exit 0 }; " *
+ "if (\$null -eq \$process.CommandLine -or " *
+ "\$process.CommandLine.IndexOf(\$runner,[StringComparison]::OrdinalIgnoreCase) -lt 0) " *
+ "{ throw 'recorded PID no longer belongs to the PSCAD runner' }; " *
+ "& taskkill.exe /PID \$runnerPid /T /F | Out-Null; " *
+ "if (\$LASTEXITCODE -ne 0) { throw 'could not stop PSCAD runner process tree' }; " *
+ "Remove-Item -LiteralPath \$ownerPath -Force -ErrorAction SilentlyContinue"
+end
+
+function _cancel_remote(
+ config::RemoteConfig,
+ remote_case::AbstractString;
+ verbosity::Integer = 0
+)
+ _run_remote(config, _cancel_command(remote_case); stream = verbosity >= 2,
+ timeout = min(config.timeout, 30))
+ return nothing
+end
+
+# Keep remotely executed code paired with the loaded Julia adapter. Later
+# invocations must not pick up working-tree edits mid-run.
+const PSCAD_REMOTE_SOURCES = Dict(name => let
+ path = joinpath(@__DIR__, name)
+ Base.include_dependency(path)
+ read(path, String)
+ end
+for name in ("Project.toml", "Manifest.toml", "files.jl",
+ "runner.jl", "supervisor.ps1", "identity.py"))
+
+"""
+$(TYPEDSIGNATURES)
+
+Read the remote PSCAD installation identity without launching a simulation.
+
+# Arguments
+
+- `config`: station connection and selected PSCAD installation.
+
+# Returns
+
+- A string dictionary containing application, line-constants executable and
+ master-library paths and SHA-256 digests, automation versions, and the
+ selected line-constants implementation. A temporary application instance
+ reads the station settings and closes without loading a project.
+
+# Notes
+
+Passing this record as the `solver_identity` computation option requires that
+installation. Fresh computations verify identity inside their own application
+instance. Completed-run reuse verifies the current station before acceptance.
+"""
+function identify(config::RemoteConfig)
+ code = PSCAD_REMOTE_SOURCES["identity.py"] * "\nimport json\n" *
+ "for key, value in identify(" * repr(config.pscad_version) * ").items():\n" *
+ " print(json.dumps(key) + ' = ' + json.dumps(value))\n"
+ # Use the existing shared work directory. Embedding a whole script inside
+ # an encoded PowerShell command exceeds Windows' command-line limit.
+ directory = mktempdir(mkpath(config.local_root); prefix = "pscad-identity-")
+ result = try
+ write(joinpath(directory, "identify.py"), code)
+ remote = _remote_path(config.shared_root, basename(directory), "identify.py")
+ command = "& " * _ps_quote(config.python_executable) * " " * _ps_quote(remote) *
+ "; if (\$LASTEXITCODE -ne 0) { exit \$LASTEXITCODE }"
+ TOML.parse(_run_remote(config, command))
+ finally
+ rm(directory; recursive = true)
+ end
+ return Dict{String, String}(validate(result, config))
+end
+
+# The solver identity that a PSCAD station reports for the requested configuration.
+function validate(result::AbstractDict, config::RemoteConfig)
+ all(pair -> first(pair) isa AbstractString && last(pair) isa AbstractString, result) ||
+ throw(ArgumentError("PSCAD solver identity must contain string fields"))
+ get(result, "version", nothing) == config.pscad_version || throw(ArgumentError(
+ "PSCAD station did not return the requested solver identity"))
+ get(result, "schema", nothing) == "1" &&
+ all(name -> occursin(r"^[0-9a-f]{64}$", get(result, name * "_sha256", "")),
+ ("pscad", "line_constants", "master_library")) || throw(ArgumentError(
+ "PSCAD station returned an incomplete solver identity"))
+ return result
+end
+
+function _stage_toolkit(local_project::AbstractString, local_output::AbstractString)
+ isfile(local_project) || throw(ArgumentError(
+ "local PSCAD input is missing: $local_project",
+ ))
+ run_directory = dirname(local_output)
+ expected_project = joinpath(run_directory, "generated.pscx")
+ abspath(local_project) == abspath(expected_project) || throw(ArgumentError(
+ "PSCAD project must be staged as $expected_project",
+ ))
+ toolkit_stage = joinpath(run_directory, "toolkit")
+ isdir(toolkit_stage) && rm(toolkit_stage; recursive = true)
+ mkpath(toolkit_stage)
+ for (name, source) in PSCAD_REMOTE_SOURCES
+ write(joinpath(toolkit_stage, name), source)
+ end
+ return toolkit_stage
+end
+
+function _diagnostic_tail(path::AbstractString; count::Integer = 12)
+ isfile(path) || return "PSCAD produced no diagnostic log."
+ lines=filter(!isempty, strip.(readlines(path)))
+ isempty(lines) && return "PSCAD diagnostic log is empty."
+ return join(last(lines, min(count, length(lines))), '\n')
+end
+
+function run_remote_pscad(
+ config::RemoteConfig,
+ local_project::AbstractString,
+ local_output::AbstractString,
+ formulation::PSCADFormulation,
+ frequencies_value::AbstractVector;
+ output_stem::AbstractString,
+ verbosity::Integer = 0
+)
+ verbosity in 0:2 || throw(ArgumentError("PSCAD verbosity must be 0, 1, or 2"))
+ validate(frequencies_value, PSCADFormulation)
+ isdir(local_output) && !isempty(readdir(local_output)) &&
+ throw(ArgumentError(
+ "PSCAD output directory is not empty; select a new run directory: $local_output"))
+ mkpath(local_output)
+ relative = relpath(dirname(abspath(local_output)), config.local_root)
+ work_parts = splitpath(relative)
+ first(work_parts) == ".." && throw(ArgumentError(
+ "PSCAD native run directory must be inside remote.local_root"))
+ run_id = last(work_parts)
+ _stage_toolkit(local_project, local_output)
+ shared_case = _remote_path(config.shared_root, work_parts...)
+ remote_case = _remote_path(config.remote_root, work_parts..., output_stem)
+ stdout_path = joinpath(local_output, "stdout.txt")
+ stderr_path = joinpath(local_output, "stderr.txt")
+ transport_stdout = joinpath(local_output, "transport-stdout.txt")
+ transport_stderr = joinpath(local_output, "transport-stderr.txt")
+ command = _supervisor_command(
+ config,
+ shared_case,
+ remote_case,
+ _remote_project_name(local_project),
+ formulation,
+ frequencies_value;
+ output_stem,
+ verbosity
+ )
+ verbosity >= 2 && @debug "Executing PSCAD frequency scan" host=config.host run_id formulation=only(description([formulation];roles=[:none])) frequencies=length(frequencies_value) timeout=config.timeout
+ execution_error = try
+ _run_remote(
+ config,
+ command;
+ stdout_path = transport_stdout,
+ stderr_path = transport_stderr,
+ stream = verbosity >= 2,
+ timeout = config.timeout + 60,
+ on_interrupt = () -> _cancel_remote(config, remote_case; verbosity)
+ )
+ nothing
+ catch error
+ if !(error isa InterruptException)
+ try
+ _cancel_remote(config, remote_case; verbosity)
+ catch cancellation_error
+ @warn "Remote PSCAD cancellation could not be confirmed" host=config.host run_id exception=(
+ cancellation_error, catch_backtrace())
+ end
+ end
+ error isa InterruptException && rethrow()
+ error
+ end
+ if execution_error !== nothing
+ console_path = joinpath(local_output, "pscad-console.txt")
+ if !isfile(console_path)
+ write(
+ console_path,
+ "PSCAD did not produce a console log before the remote failure.\n"
+ )
+ end
+ summary=first(split(sprint(showerror, execution_error), '\n'))
+ throw(ErrorException(
+ "$summary\nLast PSCAD diagnostics:\n$(_diagnostic_tail(console_path))" *
+ "\nFull PSCAD diagnostics: $console_path" *
+ "\nTransport stdout: $transport_stdout" *
+ "\nTransport stderr: $transport_stderr" *
+ "\nRemote scratch: $remote_case",
+ ))
+ end
+ verbosity >= 2 && @debug "Checking PSCAD outputs" host=config.host run_id destination=local_output
+ required = (
+ "pscad-console.txt", "timing.txt", "result_zm.out", "result_zp.out",
+ "result_ym.out", "result_yp.out"
+ )
+ for name in required
+ path = joinpath(local_output, name)
+ isfile(path) || throw(ArgumentError("required PSCAD output is missing: $path"))
+ filesize(path) > 0 || throw(ArgumentError("required PSCAD output is empty: $path"))
+ end
+ elapsed = parse(Float64, strip(read(joinpath(local_output, "timing.txt"), String)))
+ verbosity >= 2 && @debug "PSCAD frequency scan completed" host=config.host run_id compile_call_seconds=elapsed timing_scope=PSCAD_TIMING_SCOPE
+ return (
+ elapsed_seconds = elapsed,
+ elapsed_scope = PSCAD_TIMING_SCOPE,
+ exit_code = 0,
+ stdout_path,
+ stderr_path,
+ console_path = joinpath(local_output, "pscad-console.txt"),
+ output_dir = String(local_output)
+ )
+end
diff --git a/src/pscad/remote/runner.jl b/src/pscad/remote/runner.jl
new file mode 100644
index 000000000..4aa681deb
--- /dev/null
+++ b/src/pscad/remote/runner.jl
@@ -0,0 +1,312 @@
+using PythonCall
+using TOML
+
+include(joinpath(@__DIR__, "files.jl"))
+
+const MHI_PSCAD_VERSION = "3.1.2"
+const OUTPUT_SUFFIXES = (
+ "_zm.out" => "result_zm.out",
+ "_zp.out" => "result_zp.out",
+ "_ym.out" => "result_ym.out",
+ "_yp.out" => "result_yp.out"
+)
+_string(value) = pyconvert(String, pybuiltins.str(value))
+_components(value) = pyconvert(Vector{Py}, pybuiltins.list(value))
+
+function _definition(component)
+ return lowercase(_string(component.defn_name))
+end
+
+function _single_component(components, definition)
+ matches = filter(component -> _definition(component) == lowercase(definition), components)
+ length(matches) == 1 || throw(ArgumentError(
+ "PSCAD project exposes $(length(matches)) components named $definition",
+ ))
+ return only(matches)
+end
+
+function _parameters(component)
+ raw = pyconvert(Dict, component.parameters())
+ return Dict(string(key) => value for (key, value) in raw)
+end
+
+_same_value(observed::Number, requested::Number) = observed == requested
+_same_value(observed, requested) = string(observed) == string(requested)
+
+function _set!(component, name::AbstractString, value; readback = value)
+ haskey(_parameters(component), name) || throw(KeyError(
+ "PSCAD component $(_string(component.defn_name)) has no field $name",
+ ))
+ component.parameters(; Dict(Symbol(name) => value)...)
+ observed = _parameters(component)[name]
+ _same_value(observed, readback) || throw(ArgumentError(
+ "PSCAD field $name rejected $(repr(value)); expected readback " *
+ "$(repr(readback)), found $(repr(observed))",
+ ))
+ return value
+end
+
+function _disabled(value)
+ value isa Number && return iszero(value)
+ return uppercase(strip(string(value))) in (
+ "0", "NO", "FALSE", "DISABLE", "DISABLED", "RETAIN", "NONE")
+end
+
+# The generated PSCAD project retains every requested cable port.
+function validate(components)
+ cables = filter(
+ component -> _definition(component) == "master:cable_coax",
+ components
+ )
+ isempty(cables) && throw(ArgumentError(
+ "generated PSCAD project exposes no master:Cable_Coax components",
+ ))
+ for cable in cables
+ parameters = _parameters(cable)
+ elimination_fields = sort!(filter(
+ field -> occursin(r"^elim\d+$", field),
+ collect(keys(parameters))
+ ))
+ for field in elimination_fields
+ _disabled(parameters[field]) || throw(ArgumentError(
+ "PSCAD field $field requests conductor elimination; requested ports must be retained",
+ ))
+ end
+ end
+ return components
+end
+
+function _messages(project)
+ messages = try
+ _components(project.messages())
+ catch error
+ return "Unable to read PSCAD messages: $(sprint(showerror, error))\n"
+ end
+ return join((_string(message) for message in messages), '\n') * "\n"
+end
+
+function _report(console::IO, verbosity::Int, level::Int, message::AbstractString)
+ line = "[$(round(time(); digits = 3))] $(String(message))"
+ println(console, line)
+ flush(console)
+ if verbosity >= level
+ println(stdout, line)
+ flush(stdout)
+ end
+ return nothing
+end
+
+function _project_diagnostics(project)
+ parts=String["PSCAD messages:\n$(strip(_messages(project)))"]
+ try
+ output=strip(_string(project.output()))
+ isempty(output) || push!(parts, "PSCAD project output:\n$output")
+ catch error
+ push!(parts, "Unable to read PSCAD project output: $(sprint(showerror, error))")
+ end
+ return join(parts, '\n')
+end
+
+function _record_diagnostics(
+ console::IO,
+ project,
+ verbosity::Int,
+ heading::AbstractString;
+ error_stream::Bool = false
+)
+ text="$(String(heading))\n$(_project_diagnostics(project))"
+ println(console, text)
+ flush(console)
+ if verbosity >= 2
+ stream=error_stream ? stderr : stdout
+ println(stream, text)
+ flush(stream)
+ end
+ return text
+end
+
+function main(arguments)
+ length(arguments) == 10 || throw(ArgumentError(
+ "runner expects project, output, project name, output stem, formulation, " *
+ "FS, FE, Numf, PSCAD version, " *
+ "and verbosity",
+ ))
+ project_path, output, project_name, output_stem, formulation,
+ fs, fe, numf, pscad_version,
+ verbosity_text = arguments
+ verbosity = parse(Int, verbosity_text)
+ verbosity in 0:2 || throw(ArgumentError("verbosity must be 0, 1, or 2"))
+ pscad_version == "5.1.0" || throw(ArgumentError("PSCAD 5.1.0 is required"))
+ occursin(r"^[A-Za-z0-9][A-Za-z0-9_]{0,19}$", output_stem) ||
+ throw(ArgumentError("invalid PSCAD output stem $output_stem"))
+ mkpath(output)
+ app = nothing
+ project = nothing
+ phase = "initialization"
+ input_path = joinpath(dirname(project_path), "computation.toml")
+ input = TOML.parsefile(input_path)
+ get(input, "schema_version", nothing) == 4 || throw(ArgumentError(
+ "PSCAD runner requires the complete version-4 numerical input record"))
+ identify = pyimport("runpy").run_path(joinpath(@__DIR__, "identity.py"))["identify"]
+ console = open(joinpath(output, "pscad-console.txt"), "w")
+ try
+ metadata = pyimport("importlib.metadata")
+ observed = _string(metadata.version("mhi.pscad"))
+ observed == MHI_PSCAD_VERSION || throw(ArgumentError(
+ "mhi.pscad $MHI_PSCAD_VERSION is required; found $observed",
+ ))
+ pscad = pyimport("mhi.pscad")
+ phase = "PSCAD launch"
+ _report(console, verbosity, 1, "Launching PSCAD 5.1.0")
+ app = cd(abspath(output)) do
+ pscad.launch(;
+ version = pscad_version,
+ x64 = true,
+ minimize = true,
+ splash = false,
+ silence = true,
+ timeout = 60
+ )
+ end
+ pyconvert(Bool, app.licensed()) || error("PSCAD refused the configured license")
+ _string(app.version) == pscad_version ||
+ error("PSCAD launched an unexpected version")
+ initial_identity = pyconvert(Dict{String, String}, identify(pscad_version, app))
+ expected_identity = get(input, "expected_solver", nothing)
+ expected_identity === nothing || initial_identity == expected_identity ||
+ error("PSCAD installation does not match the expected solver identity")
+ phase = "project load"
+ _report(console, verbosity, 1, "Loading generated project $project_name")
+ app.load(abspath(project_path))
+ project = app.project(project_name)
+ lines = vcat(
+ _components(project.find_all("TLine")),
+ _components(project.find_all("Cable"))
+ )
+ length(lines) == 1 || throw(ArgumentError(
+ "generated PSCAD project must expose exactly one line-data row; found $(length(lines))",
+ ))
+ line = only(lines)
+ _set!(line, "Name", output_stem)
+ _report(
+ console,
+ verbosity,
+ 2,
+ "Found line-data row $(_string(line.defn_name)); output stem is $output_stem"
+ )
+ canvas = try
+ project.canvas(last(split(_string(line.defn_name), ':'; limit = 2)))
+ catch
+ line.canvas()
+ end
+ components = _components(canvas.components())
+ phase = "input configuration"
+ frequency = _single_component(components, "master:line_frephase_options")
+ ground = _single_component(components, "master:line_ground")
+ _set!(frequency, "FS", parse(Float64, fs))
+ _set!(frequency, "FE", parse(Float64, fe))
+ _set!(frequency, "Numf", parse(Int, numf))
+ _set!(frequency, "Output", 1; readback = "YES")
+ readbacks = Dict{String, Any}()
+ for (name, component) in
+ (("ground", ground), ("frequency", frequency), ("configuration", line))
+ controls = input["native_settings"][name]
+ observed = Dict{String, Any}()
+ for (field, setting) in controls
+ _set!(component, field, setting["value"]; readback = setting["readback"])
+ observed[field] = _parameters(component)[field]
+ end
+ readbacks[name] = observed
+ end
+ open(joinpath(output, "native-settings.toml"), "w") do io
+ TOML.print(io, readbacks; sorted = true)
+ end
+ _report(
+ console,
+ verbosity,
+ 2,
+ "Applied frequency range $fs Hz to $fe Hz and formulation $formulation"
+ )
+ validate(components)
+ _report(console, verbosity, 2, "Verified that all cable terminals are retained")
+ project.save()
+ _record_diagnostics(console, project, verbosity, "PSCAD diagnostics before computation")
+ phase = "line-constants computation"
+ _report(console, verbosity, 1, "Starting PSCAD line-constants computation")
+ started = time_ns()
+ line.compile()
+ elapsed = (time_ns() - started)*1e-9
+ _report(
+ console,
+ verbosity,
+ 1,
+ "PSCAD compile call returned in $(round(elapsed; digits = 3)) seconds"
+ )
+ _record_diagnostics(console, project, verbosity, "PSCAD diagnostics after compile call")
+ write(joinpath(output, "timing.txt"), string(elapsed))
+ roots = [
+ abspath(output),
+ abspath(dirname(project_path)),
+ abspath(_string(project.temp_folder))
+ ]
+ phase = "detailed-output completion"
+ expected_rows=parse(Int, numf) + 1
+ _report(
+ console,
+ verbosity,
+ 1,
+ "Waiting for $expected_rows rows in each detailed PSCAD matrix file"
+ )
+ for (suffix, name) in OUTPUT_SUFFIXES
+ source=_wait_output(roots, suffix, expected_rows)
+ destination=joinpath(output, name)
+ cp(source, destination; force = true)
+ _data_rows(destination) == expected_rows || throw(ArgumentError(
+ "copied PSCAD output $destination is incomplete",
+ ))
+ _report(console, verbosity, 2, "Validated $name with $expected_rows rows")
+ end
+ _report(console, verbosity, 1, "Collected detailed PSCAD Z and Y outputs")
+ observed = pyconvert(Dict{String, String}, identify(pscad_version, app))
+ observed == initial_identity ||
+ error("PSCAD installation changed during computation")
+ open(joinpath(output, "solver.toml"), "w") do io
+ TOML.print(io, observed; sorted = true)
+ end
+ catch error
+ project === nothing || _record_diagnostics(
+ console,
+ project,
+ verbosity,
+ "PSCAD diagnostics at failure";
+ error_stream = true
+ )
+ message="PSCAD runner failed during $phase: $(sprint(showerror, error))"
+ println(console, message)
+ flush(console)
+ throw(ErrorException(message))
+ finally
+ project === nothing || try
+ project.unload()
+ catch error
+ println(console, "PSCAD project unload failed: ", sprint(showerror, error))
+ end
+ app === nothing || try
+ app.quit()
+ catch error
+ println(console, "PSCAD application close failed: ", sprint(showerror, error))
+ end
+ flush(console)
+ close(console)
+ end
+ return nothing
+end
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ try
+ main(ARGS)
+ catch error
+ println(stderr, sprint(showerror, error))
+ exit(1)
+ end
+end
diff --git a/src/pscad/remote/supervisor.ps1 b/src/pscad/remote/supervisor.ps1
new file mode 100644
index 000000000..339734859
--- /dev/null
+++ b/src/pscad/remote/supervisor.ps1
@@ -0,0 +1,313 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string] $SharedCase,
+ [Parameter(Mandatory = $true)]
+ [string] $LocalCase,
+ [Parameter(Mandatory = $true)]
+ [string] $Julia,
+ [Parameter(Mandatory = $true)]
+ [string] $Python,
+ [Parameter(Mandatory = $true)]
+ [string] $ProjectName,
+ [Parameter(Mandatory = $true)]
+ [string] $OutputStem,
+ [Parameter(Mandatory = $true)]
+ [string] $Formulation,
+ [Parameter(Mandatory = $true)]
+ [double] $FrequencyStart,
+ [Parameter(Mandatory = $true)]
+ [double] $FrequencyEnd,
+ [Parameter(Mandatory = $true)]
+ [int] $FrequencyIncrements,
+ [Parameter(Mandatory = $true)]
+ [string] $PSCADVersion,
+ [Parameter(Mandatory = $true)]
+ [ValidateRange(0, 2)]
+ [int] $Verbosity,
+ [Parameter(Mandatory = $true)]
+ [ValidateRange(1, 2147483647)]
+ [int] $TimeoutSeconds
+)
+
+$ErrorActionPreference = "Stop"
+$ProgressPreference = "SilentlyContinue"
+
+function Stop-OwnedRunner {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $OwnerPath,
+ [Parameter(Mandatory = $true)]
+ [string] $OwnedRoot
+ )
+
+ if (-not (Test-Path -LiteralPath $OwnerPath -PathType Leaf)) {
+ return
+ }
+
+ $owner = @(Get-Content -LiteralPath $OwnerPath)
+ if ($owner.Count -ne 2) {
+ throw "Invalid PSCAD runner owner file: $OwnerPath"
+ }
+
+ $processId = 0
+ if (-not [int]::TryParse($owner[0], [ref] $processId)) {
+ throw "Invalid PSCAD runner PID in $OwnerPath"
+ }
+
+ $runner = [IO.Path]::GetFullPath($owner[1])
+ $root = [IO.Path]::GetFullPath($OwnedRoot).TrimEnd('\') + '\'
+ if (-not $runner.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "Refusing to stop a process outside the PSCAD case directory: $runner"
+ }
+
+ $process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId"
+ if ($null -eq $process) {
+ Remove-Item -LiteralPath $OwnerPath -Force
+ return
+ }
+
+ if ($null -eq $process.CommandLine -or
+ $process.CommandLine.IndexOf($runner, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
+ throw "PID $processId no longer belongs to the recorded PSCAD runner"
+ }
+
+ & taskkill.exe /PID $processId /T /F | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ throw "Could not stop PSCAD runner process tree $processId"
+ }
+ Remove-Item -LiteralPath $OwnerPath -Force -ErrorAction SilentlyContinue
+}
+
+function Remove-DirectoryWithRetry {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Path,
+ [int] $Attempts = 30,
+ [int] $DelayMilliseconds = 500,
+ [switch] $BestEffort
+ )
+
+ for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
+ if (-not (Test-Path -LiteralPath $Path)) {
+ return
+ }
+ try {
+ Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
+ return
+ } catch {
+ if ($attempt -eq $Attempts) {
+ if ($BestEffort) {
+ return
+ }
+ throw
+ }
+ Start-Sleep -Milliseconds $DelayMilliseconds
+ }
+ }
+}
+
+function Copy-Result {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Source,
+ [Parameter(Mandatory = $true)]
+ [string] $Destination
+ )
+
+ New-Item -ItemType Directory -Path $Destination -Force | Out-Null
+ if (Test-Path -LiteralPath $Source -PathType Container) {
+ Get-ChildItem -LiteralPath $Source -Force |
+ Copy-Item -Destination $Destination -Recurse -Force
+ }
+}
+
+function Write-NewLines {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Path,
+ [Parameter(Mandatory = $true)]
+ [ref] $Count,
+ [Parameter(Mandatory = $true)]
+ [bool] $ErrorStream
+ )
+
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
+ return
+ }
+ $lines = @(Get-Content -LiteralPath $Path)
+ for ($index = $Count.Value; $index -lt $lines.Count; $index++) {
+ if ($ErrorStream) {
+ [Console]::Error.WriteLine($lines[$index])
+ } else {
+ [Console]::Out.WriteLine($lines[$index])
+ }
+ }
+ $Count.Value = $lines.Count
+}
+
+if (-not (Test-Path -LiteralPath $SharedCase -PathType Container)) {
+ throw "PSCAD exchange directory is unavailable: $SharedCase"
+}
+if (-not (Test-Path -LiteralPath $Julia -PathType Leaf)) {
+ throw "Julia executable is unavailable: $Julia"
+}
+if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) {
+ throw "Python executable is unavailable: $Python"
+}
+
+$sharedFull = [IO.Path]::GetFullPath($SharedCase).TrimEnd('\') + '\'
+$localFull = [IO.Path]::GetFullPath($LocalCase).TrimEnd('\') + '\'
+if ($sharedFull.StartsWith($localFull, [StringComparison]::OrdinalIgnoreCase) -or
+ $localFull.StartsWith($sharedFull, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "PSCAD exchange and scratch directories must not overlap"
+}
+$ownerPath = Join-Path $LocalCase "owner.txt"
+Stop-OwnedRunner -OwnerPath $ownerPath -OwnedRoot $LocalCase
+if (Test-Path -LiteralPath $LocalCase) {
+ Remove-DirectoryWithRetry -Path $LocalCase
+}
+New-Item -ItemType Directory -Path $LocalCase -Force | Out-Null
+Get-ChildItem -LiteralPath $SharedCase -Force |
+ Where-Object Name -ne "outputs" |
+ Copy-Item -Destination $LocalCase -Recurse -Force
+
+$toolkit = Join-Path $LocalCase "toolkit"
+$runner = Join-Path $toolkit "runner.jl"
+$project = Join-Path $toolkit "Project.toml"
+$projectFile = Join-Path $LocalCase "generated.pscx"
+$result = Join-Path $LocalCase "result"
+$sharedOutput = Join-Path $SharedCase "outputs"
+$stdoutPath = Join-Path $result "stdout.txt"
+$stderrPath = Join-Path $result "stderr.txt"
+$exitPath = Join-Path $result "exit-code.txt"
+$invokePath = Join-Path $LocalCase "invoke-runner.cmd"
+
+foreach ($required in @($runner, $project, $projectFile)) {
+ if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
+ throw "Staged PSCAD input is missing: $required"
+ }
+}
+
+New-Item -ItemType Directory -Path $result -Force | Out-Null
+New-Item -ItemType Directory -Path $sharedOutput -Force | Out-Null
+$commandValues = @(
+ $Julia,
+ $Python,
+ $toolkit,
+ $runner,
+ $projectFile,
+ $result,
+ $ProjectName,
+ $OutputStem,
+ $Formulation,
+ $stdoutPath,
+ $stderrPath,
+ $exitPath
+)
+foreach ($value in $commandValues) {
+ if ($value.IndexOfAny(@([char] '"', [char] '%', [char] 10, [char] 13)) -ge 0) {
+ throw "PSCAD runner command value contains an unsupported character: $value"
+ }
+}
+$runnerArguments = @(
+ "`"--project=$toolkit`"",
+ "--startup-file=no",
+ "`"$runner`"",
+ "`"$projectFile`"",
+ "`"$result`"",
+ "`"$ProjectName`"",
+ "`"$OutputStem`"",
+ "`"$Formulation`"",
+ "`"$FrequencyStart`"",
+ "`"$FrequencyEnd`"",
+ "`"$FrequencyIncrements`"",
+ "`"$PSCADVersion`"",
+ "`"$Verbosity`""
+)
+$invokeLines = @(
+ "@echo off",
+ "setlocal",
+ "set `"JULIA_PYTHONCALL_EXE=$Python`"",
+ "`"$Julia`" $($runnerArguments -join ' ') 1>`"$stdoutPath`" 2>`"$stderrPath`"",
+ "set `"PSCAD_EXIT=%ERRORLEVEL%`"",
+ ">`"$exitPath`" echo %PSCAD_EXIT%",
+ "exit /b %PSCAD_EXIT%"
+)
+[IO.File]::WriteAllLines($invokePath, $invokeLines, [Text.Encoding]::ASCII)
+
+$process = $null
+$exitCode = 1
+$timedOut = $false
+$stdoutCount = 0
+$stderrCount = 0
+try {
+ $process = Start-Process `
+ -FilePath $env:ComSpec `
+ -ArgumentList @("/d", "/s", "/c", "`"$invokePath`"") `
+ -PassThru `
+ -WindowStyle Hidden
+ Set-Content -LiteralPath $ownerPath -Value @($process.Id, $invokePath)
+ if ($Verbosity -ge 2) {
+ [Console]::Out.WriteLine("Started PSCAD runner process $($process.Id)")
+ }
+
+ $stopwatch = [Diagnostics.Stopwatch]::StartNew()
+ while (-not $process.HasExited) {
+ if ($Verbosity -ge 2) {
+ Write-NewLines -Path $stdoutPath -Count ([ref] $stdoutCount) -ErrorStream $false
+ Write-NewLines -Path $stderrPath -Count ([ref] $stderrCount) -ErrorStream $true
+ }
+ if ($stopwatch.Elapsed.TotalSeconds -ge $TimeoutSeconds) {
+ $timedOut = $true
+ break
+ }
+ Start-Sleep -Milliseconds 500
+ $process.Refresh()
+ }
+
+ if ($timedOut) {
+ [Console]::Error.WriteLine(
+ "PSCAD runner exceeded the configured timeout of $TimeoutSeconds seconds"
+ )
+ Stop-OwnedRunner -OwnerPath $ownerPath -OwnedRoot $LocalCase
+ $exitCode = 124
+ } else {
+ $process.WaitForExit()
+ if (Test-Path -LiteralPath $exitPath -PathType Leaf) {
+ $recordedExit = (Get-Content -LiteralPath $exitPath -Raw).Trim()
+ if (-not [int]::TryParse($recordedExit, [ref] $exitCode)) {
+ [Console]::Error.WriteLine(
+ "PSCAD runner wrote an invalid exit code: $recordedExit"
+ )
+ $exitCode = 1
+ }
+ } else {
+ [Console]::Error.WriteLine("PSCAD runner did not write an exit code")
+ $exitCode = 1
+ }
+ if ($Verbosity -ge 2) {
+ [Console]::Out.WriteLine(
+ "PSCAD runner process $($process.Id) exited with code $exitCode"
+ )
+ }
+ }
+
+ if ($Verbosity -ge 2) {
+ Write-NewLines -Path $stdoutPath -Count ([ref] $stdoutCount) -ErrorStream $false
+ Write-NewLines -Path $stderrPath -Count ([ref] $stderrCount) -ErrorStream $true
+ }
+} catch {
+ [Console]::Error.WriteLine($_.Exception.ToString())
+ if ($null -ne $process -and -not $process.HasExited) {
+ Stop-OwnedRunner -OwnerPath $ownerPath -OwnedRoot $LocalCase
+ }
+ $exitCode = 1
+} finally {
+ Copy-Result -Source $result -Destination $sharedOutput
+ Remove-Item -LiteralPath $ownerPath -Force -ErrorAction SilentlyContinue
+}
+
+if ($exitCode -eq 0) {
+ Remove-DirectoryWithRetry -Path $LocalCase -BestEffort
+}
+exit $exitCode
diff --git a/src/pscad/results.jl b/src/pscad/results.jl
new file mode 100644
index 000000000..03c42bf2a
--- /dev/null
+++ b/src/pscad/results.jl
@@ -0,0 +1,122 @@
+struct DetailedTable
+ frequency::Vector{Float64}
+ values::Matrix{Float64}
+end
+
+const PSCAD_FREQUENCY_RTOL = 5.0e-8
+
+function _pscad_number(text::AbstractString)
+ value = replace(strip(text), 'D' => 'E', 'd' => 'e')
+ value = replace(value, r"^([+-])\." => s"\g<1>0.")
+ value = replace(value, r"^\." => "0.")
+ value = replace(
+ value,
+ r"^([+-]?(?:\d+(?:\.\d*)?|\.\d+))([+-]\d+)$" => s"\g<1>E\g<2>"
+ )
+ parsed = tryparse(Float64, value)
+ parsed === nothing && throw(ArgumentError("invalid PSCAD numeric field $(repr(text))"))
+ return parsed
+end
+
+function _float_row(line::AbstractString)
+ return _pscad_number.(split(strip(line)))
+end
+
+function _read_detailed(path::AbstractString)
+ lines = collect(eachline(path))
+ header = findfirst(line -> occursin("LOG10(FN)", line), lines)
+ header === nothing && throw(ArgumentError(
+ "missing PSCAD detailed-output header in $path",
+ ))
+ rows = [_float_row(line)
+ for line in @view(lines[(header + 1):end]) if !isempty(strip(line))]
+ isempty(rows) && throw(ArgumentError("PSCAD detailed output contains no data: $path"))
+ width = length(first(rows))
+ width >= 3 || throw(DimensionMismatch("PSCAD detailed output needs data columns"))
+ all(row -> length(row) == width, rows) || throw(DimensionMismatch(
+ "PSCAD detailed output has inconsistent row widths: $path",
+ ))
+ table = reduce(vcat, permutedims.(rows))
+ all(isfinite, table) || throw(DomainError(
+ path,
+ "PSCAD detailed output contains nonfinite data"
+ ))
+ return DetailedTable(table[:, 2], table[:, 3:end])
+end
+
+function _combine_polar(magnitude::DetailedTable, phase::DetailedTable)
+ magnitude.frequency == phase.frequency || throw(ArgumentError(
+ "PSCAD magnitude and phase frequencies differ",
+ ))
+ size(magnitude.values) == size(phase.values) || throw(DimensionMismatch(
+ "PSCAD magnitude and phase table shapes differ",
+ ))
+ flat = magnitude.values .* cispi.(phase.values ./ 180)
+ dimension = isqrt(size(flat, 2))
+ dimension^2 == size(flat, 2) || throw(DimensionMismatch(
+ "PSCAD detailed table does not contain a square matrix",
+ ))
+ result = Array{ComplexF64, 3}(undef, dimension, dimension, size(flat, 1))
+ for frequency in axes(flat, 1), row in 1:dimension, column in 1:dimension
+ result[row, column, frequency] = flat[frequency, (row - 1) * dimension + column]
+ end
+ return result
+end
+
+function _result_path(directory::AbstractString, name::AbstractString)
+ path = joinpath(directory, name)
+ isfile(path) || throw(ArgumentError("required PSCAD result is missing: $path"))
+ return path
+end
+
+function _case_frequencies(observed::AbstractVector, expected::AbstractVector)
+ length(observed) == length(expected) || throw(DimensionMismatch(
+ "PSCAD emitted $(length(observed)) frequencies; expected $(length(expected))",
+ ))
+ for index in eachindex(observed, expected)
+ isapprox(
+ observed[index], expected[index];
+ rtol = max(PSCAD_FREQUENCY_RTOL, 8Float64(eps(float(one(eltype(expected)))))),
+ atol = 0.0
+ ) || throw(ArgumentError(
+ "PSCAD frequency at row $index is $(observed[index]); " *
+ "expected $(expected[index]) within the PSCAD text-output resolution",
+ ))
+ end
+ return Float64.(expected)
+end
+
+function read_pscad_result(
+ output_dir::AbstractString,
+ expected_frequencies::AbstractVector,
+ expected_size::NTuple{3, Int}
+)
+ isdir(output_dir) ||
+ throw(ArgumentError("PSCAD output directory is missing: $output_dir"))
+ zm = _read_detailed(_result_path(output_dir, "result_zm.out"))
+ zp = _read_detailed(_result_path(output_dir, "result_zp.out"))
+ ym = _read_detailed(_result_path(output_dir, "result_ym.out"))
+ yp = _read_detailed(_result_path(output_dir, "result_yp.out"))
+ observed_frequencies = zm.frequency
+ zp.frequency == observed_frequencies && ym.frequency == observed_frequencies &&
+ yp.frequency == observed_frequencies || throw(ArgumentError(
+ "PSCAD detailed outputs use different frequency samples",
+ ))
+ frequencies_value = _case_frequencies(observed_frequencies, expected_frequencies)
+ impedance = _combine_polar(zm, zp)
+ admittance = _combine_polar(ym, yp)
+ size(impedance) == expected_size || throw(DimensionMismatch(
+ "PSCAD impedance has size $(size(impedance)); expected $expected_size",
+ ))
+ size(admittance) == expected_size || throw(DimensionMismatch(
+ "PSCAD admittance has size $(size(admittance)); expected $expected_size",
+ ))
+ return LineParameters(
+ PhaseDomain,
+ impedance,
+ admittance,
+ frequencies_value;
+ basis = :pul,
+ details = ComputationDetails(; native_frequencies = copy(observed_frequencies))
+ )
+end
diff --git a/src/pscad/validate.jl b/src/pscad/validate.jl
new file mode 100644
index 000000000..3b148096c
--- /dev/null
+++ b/src/pscad/validate.jl
@@ -0,0 +1,54 @@
+"""
+$(TYPEDSIGNATURES)
+
+Check the physical earth inventory accepted by PSCAD model export and execution.
+The supported model consists of air and one infinite horizontal soil half-space.
+"""
+function validate(model::EarthModel, ::Type{<:PSCADFormulation})
+ _pscad_deterministic(eltype(model))
+ validate(model)
+ !model.vertical_layers && length(model.layers) == 2 &&
+ all(layer -> isinf(layer.thickness), model.layers) || throw(ArgumentError(
+ "PSCAD supports physical air and one homogeneous horizontal soil half-space"))
+ return model
+end
+
+function _pscad_deterministic(types::Type...)
+ any(Engine.has_uncertainty_type, types) && throw(ArgumentError(
+ "PSCAD does not support Measurement values. Supply deterministic inputs."))
+ return nothing
+end
+
+function _pscad_blueprints(system::LineCableSystem)
+ _pscad_deterministic(eltype(system))
+ T = eltype(system)
+ return Engine.CableBlueprint{T}[
+ Engine.flatten(LineCableModelsCoaxial(), design, T) for design in system.designs]
+end
+
+# Computation frequencies of the PSCAD phase-scan adapter.
+function validate(values::AbstractVector, ::Type{<:PSCADFormulation})
+ _pscad_deterministic(eltype(values))
+ isempty(values) && throw(ArgumentError("PSCAD requires computation frequencies"))
+ invalid = findall(value -> !isfinite(value) || value < 0.1, values)
+ isempty(invalid) || throw(DomainError(values[invalid],
+ "PSCAD computation frequencies must be finite and at least 0.1 Hz; invalid indices: $invalid"))
+ issorted(values) && allunique(values) || throw(ArgumentError(
+ "PSCAD frequencies must be strictly increasing"))
+ length(values) - 1 in (100, 200, 500, 1000) || throw(ArgumentError(
+ "PSCAD phase-scan adapter supports 101, 201, 501 or 1001 logarithmic samples; requested $(length(values))"))
+ expected = 10.0 .^ range(log10(Float64(first(values))),
+ log10(Float64(last(values))); length = length(values))
+ tolerance = max(32eps(Float64), 8Float64(eps(float(one(eltype(values))))))
+ all(isapprox(a, b; rtol = tolerance, atol = 0) for (a, b) in zip(values, expected)) ||
+ throw(ArgumentError("PSCAD phase-scan adapter requires logarithmic samples; arbitrary-grid execution is not implemented"))
+ return values
+end
+
+function validate(problem::LineParametersProblem, formulation::PSCADFormulation)
+ _pscad_deterministic(eltype(problem), typeof(formulation.options.data.base_frequency))
+ validate(problem.frequencies, PSCADFormulation)
+ _pscad_size(problem)
+ _pscad_inputs(problem, formulation, _pscad_blueprints(problem.system))
+ return problem
+end
diff --git a/src/reportbuilder/ReportBuilder.jl b/src/reportbuilder/ReportBuilder.jl
new file mode 100644
index 000000000..361cc648d
--- /dev/null
+++ b/src/reportbuilder/ReportBuilder.jl
@@ -0,0 +1,43 @@
+"""
+ LineCableModels.ReportBuilder
+
+Build human-facing tables and optional plot artifacts from published scientific
+observations.
+"""
+module ReportBuilder
+
+export AbstractReportDefinition, ReportArtifact
+export TableReportDefinition, CableConstantsTableDefinition
+export LineParametersTableDefinition, BenchmarkTableDefinition
+export MonteCarloTableDefinition, XLSXReportDefinition, report
+export select, tabulate, illustrate, encode, write
+
+using DocStringExtensions: TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
+import Statistics
+import DataFrames
+import DataFrames: DataFrame, metadata, metadata!, Not
+import ..Commons: observables
+import ..Commons: ObservedResult
+import ..Commons
+import ..Units
+import ..DataModel
+import ..Engine
+import ..Commons: AbstractUncertaintyResult, request_identity, request_quantity, request_indices
+import ..LineCableModels: validate, description
+import ..LineCableModels
+import ..PlotBuilder
+import ..UQ
+import ..TextDisplay
+import ..LineCableModels: Z, Y, R, X, L, G, B, C
+
+include("grammar.jl")
+include("tables.jl")
+include("comparisons.jl")
+include("montecarlo.jl")
+include("performance.jl")
+include("xlsx.jl")
+include("textdisplay.jl")
+
+public observation_columns, encode_cell, XLSXSheet, XLSXWorkbook
+
+end
diff --git a/src/reportbuilder/comparisons.jl b/src/reportbuilder/comparisons.jl
new file mode 100644
index 000000000..cd70ffa01
--- /dev/null
+++ b/src/reportbuilder/comparisons.jl
@@ -0,0 +1,173 @@
+function _selected_errors(definition,point,reference)
+ rows=point.errors
+ isempty(rows) && throw(ArgumentError("this observation has no completed comparisons"))
+ if reference!==nothing
+ rows=filter(row -> row.reference_id==reference.gridpoint.id,rows)
+ end
+ settings=definition.settings
+ for key in definition.requested
+ if key===:requests
+ rows=filter(row -> row.request in settings.requests,rows)
+ elseif key===:bands
+ rows=filter(row -> row.band in settings.bands,rows)
+ elseif key===:normalizations
+ rows=filter(row -> row.normalization in settings.normalizations,rows)
+ elseif key!==:pairing
+ all(row -> isequal(get(row.settings,key===:atol ? :requested_atol : key,nothing),getproperty(settings,key)),rows) ||
+ throw(ArgumentError("requested $key differs from the retained comparison; run compare explicitly"))
+ end
+ end
+ isempty(rows) && throw(ArgumentError("requested comparison was not retained"))
+ requests=:requests in definition.requested ? settings.requests : unique(row.request for row in rows)
+ bands=:bands in definition.requested ? settings.bands : unique(row.band for row in rows)
+ normalizations=:normalizations in definition.requested ? settings.normalizations : unique(row.normalization for row in rows)
+ for request in requests,band in bands,normalization in normalizations
+ any(row -> row.request==request && isequal(row.band,band) && row.normalization==normalization,rows) ||
+ throw(ArgumentError("some requested comparison products were not retained"))
+ end
+ return rows
+end
+
+function _point_information(points,role,labels=Commons.observation_labels(points))
+ computations=NamedTuple[]
+ formulations=NamedTuple[]
+ formula_details=NamedTuple[]
+ for (index,point) in enumerate(points)
+ id=point.gridpoint.id
+ push!(computations,(role,identity=id,label=labels[index],physical_inputs=point.gridpoint.inputs))
+ push!(formulations,(role,identity=id,label=labels[index],selections=point.gridpoint.formulations))
+ for (key,value) in pairs(something(point.gridpoint.formulations,(;)))
+ push!(formula_details,(role,identity=id,selection=key,value))
+ end
+ end
+ return (;computations,formulations,formula_details)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Tabulate completed comparisons, independent maxima, sampling information, and
+recorded performance evidence. Every result remains represented. Display
+features consume the same observation-side groups as plots. No source access,
+comparison, sampling estimate, or timing measurement occurs here.
+"""
+select(definition::BenchmarkTableDefinition,observed::ObservedResult;reference=nothing) =
+ _selected_errors(definition,observed,reference)
+
+function tabulate(definition::BenchmarkTableDefinition,
+ observed::Union{ObservedResult,AbstractVector{<:ObservedResult}},selected;reference=nothing)
+ points=collect(_observed_points(observed))
+ population=reference===nothing ? points : [points;reference]
+ labels=Commons.observation_labels(population)
+ rows=NamedTuple[];terms=NamedTuple[];maxima=NamedTuple[]
+ selected_errors=observed isa ObservedResult ? (selected,) : selected
+ for (index,point) in enumerate(points),error in selected_errors[index]
+ id=point.gridpoint.id
+ settings=error.settings
+ identity=(result_id=error.result_id,reference_id=error.reference_id,
+ problem_index=id.problem_index,formulation_index=id.formulation_index,
+ result_point=index,method=labels[index],request=error.request,
+ quantity=Symbol(Units.symbol(error.quantity)),statistic=error.statistic,
+ band=error.band,normalization=error.normalization,
+ absolute_unit=Units.label(error.absolute_unit),samples=settings.sample_count,
+ requested_bounds_Hz=get(settings,:requested_bounds,nothing),actual_bounds_Hz=settings.actual_bounds)
+ absolute=error.absolute
+ relative=error.relative.*100
+ status=get(settings,:status,fill(:not_recorded,size(absolute)))
+ reasons=get(settings,:normalization_reason,fill(get(settings,:reason,nothing),size(absolute)))
+ push!(rows,merge(identity,(absolute_rms=Commons.detach(absolute),relative_rms_percent=relative,
+ status=Commons.detach(status),reason=Commons.detach(reasons),settings)))
+ for i in axes(absolute,1),j in axes(absolute,2)
+ push!(terms,merge(identity,(row=i,column=j,response=error.coordinates[i],excitation=error.coordinates[j],
+ absolute_rms=absolute[i,j],relative_rms_percent=relative[i,j],status=status[i,j],reason=reasons[i,j])))
+ end
+ push!(maxima,merge(identity,(maximum_absolute_rms=error.maxima.absolute.value,
+ absolute_term=error.maxima.absolute.index,maximum_relative_rms_percent=error.maxima.relative.value*100,
+ relative_term=error.maxima.relative.index,
+ absolute_term_response=ismissing(error.maxima.absolute.index) ? missing : error.coordinates[error.maxima.absolute.index[1]],
+ absolute_term_excitation=ismissing(error.maxima.absolute.index) ? missing : error.coordinates[error.maxima.absolute.index[2]],
+ relative_term_response=ismissing(error.maxima.relative.index) ? missing : error.coordinates[error.maxima.relative.index[1]],
+ relative_term_excitation=ismissing(error.maxima.relative.index) ? missing : error.coordinates[error.maxima.relative.index[2]],
+ unavailable=count(ismissing,relative),
+ term_count=length(relative),compared=count(!ismissing,relative),
+ reasons=unique(filter(!isnothing,vec(reasons))))))
+ end
+ features=NamedTuple[]
+ groups=NamedTuple[]
+ partitions=unique((row.request,row.normalization,row.reference_id,row.problem_index) for row in rows)
+ for (request,normalization,reference_id,problem_index) in partitions
+ selected=filter(row -> row.request==request && row.normalization==normalization && row.reference_id==reference_id && row.problem_index==problem_index,maxima)
+ bands=unique(row.band for row in selected)
+ active=unique(row.result_point for row in selected)
+ display_groups=[Commons.observation_groups(points[active];request,band,normalization,reference=reference_id) for band in bands]
+ representatives=sort(unique(vcat(([active[group.representative] for group in entries] for entries in display_groups)...)))
+ push!(groups,(request,normalization,reference_id,bands,groups=display_groups,points=active))
+ quantity_labels=Commons.observation_labels(population;request)
+ absolute=DataFrame(formula=quantity_labels[representatives]);relative=copy(absolute)
+ for band in bands
+ name=band isa Symbol ? band : Symbol(string(band))
+ for (frame,field) in ((absolute,:maximum_absolute_rms),(relative,:maximum_relative_rms_percent))
+ frame[!,name]=[begin
+ matches=filter(row -> row.result_point==index && isequal(row.band,band),selected)
+ isempty(matches) ? missing : getproperty(only(matches),field)
+ end for index in representatives]
+ end
+ end
+ push!(features,(request,quantity=first(selected).quantity,statistic=first(selected).statistic,
+ normalization,reference_id,problem_index,absolute_unit=first(selected).absolute_unit,absolute,relative))
+ end
+ result_info=_point_information(points,:result,labels)
+ reference_info=reference===nothing ? (computations=NamedTuple[],formulations=NamedTuple[],formula_details=NamedTuple[]) :
+ _point_information([reference],:reference,[last(labels)])
+ info=map((a,b) -> DataFrame(vcat(a,b)),result_info,reference_info)
+ timing=_timing_tables(points,reference)
+ sampling=_sampling_tables(points,reference)
+ coverage=DataFrame([(result_source=string(row.result_id.source_id),reference_source=string(row.reference_id.source_id),
+ point=row.problem_index,formulation_index=row.formulation_index,band=row.band isa Tuple ? string(row.band) : row.band,
+ frequency_count=row.samples,first_Hz=row.actual_bounds_Hz===nothing ? missing : Commons.nominal(first(row.actual_bounds_Hz)),
+ last_Hz=row.actual_bounds_Hz===nothing ? missing : Commons.nominal(last(row.actual_bounds_Hz))) for row in maxima])
+ coverage=unique(coverage)
+ overview=(coverage,execution=timing.execution,performance=timing.performance,
+ timing_ratio=timing.performance_comparison,source_timings=timing.source_timings,
+ sampling=sampling.sampling,cdf_precision=isempty(sampling.sampling) ? DataFrame() : DataFrames.select(sampling.sampling,
+ :role,:point,:method,:trials,:confidence,:marginal_count,:target_cdf,:cdf_bound,:target_supported,:scope))
+ maxima_table=DataFrame(maxima)
+ metadata!(maxima_table,"comparison_records",Commons.detach(maxima);style=:note)
+ return merge(info,(quantities=map(tabulate,points),comparisons=DataFrame(rows),terms=DataFrame(terms),
+ maxima=maxima_table,summary=copy(maxima_table),features,groups),timing,sampling,(;overview))
+end
+
+function illustrate(definition::BenchmarkTableDefinition,observed,tables;reference=nothing)
+ illustration=definition.illustration
+ (illustration===nothing || illustration===false) && return nothing
+ callable=illustration===true ? PlotBuilder.plot : illustration
+ return callable(observed;reference,definition.plot_options...)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Convenience for raw benchmark operands. Explicit comparison completes before
+observation construction. Already completed products supplied as `comparisons`
+are joined by original identities. The reference remains a separate observation.
+"""
+function report(definition::BenchmarkTableDefinition,source::NamedTuple;requests::Tuple=(),observation_options::NamedTuple=(;))
+ all(key -> haskey(source,key),(:reference,:result)) || throw(ArgumentError("benchmark operands require reference and result"))
+ reference=source.reference isa NamedTuple && haskey(source.reference,:result) ? source.reference.result : source.reference
+ result=source.result isa NamedTuple && haskey(source.result,:result) ? source.result.result : source.result
+ result isa Union{ObservedResult,AbstractVector{<:ObservedResult}} && return report(definition,result;reference)
+ settings=definition.settings
+ completed=haskey(source,:comparisons) ? source.comparisons : Engine.compare(reference,result,collect(settings.requests);
+ bands=settings.bands,normalizations=settings.normalizations,pairing=settings.pairing,
+ atol=settings.atol,fundamental=settings.fundamental,harmonics=settings.harmonics,unsupported=settings.unsupported)
+ timings=(measurements=get(source,:measurements,nothing),context=get(source,:context,(;)))
+ result isa AbstractUncertaintyResult && isempty(requests) && (requests=settings.requests)
+ observed=observables(result,requests;comparisons=completed,timings,clip=definition.clip,atol=settings.atol,complete_pairs=true,observation_options...)
+ observed_reference=reference isa AbstractUncertaintyResult ?
+ (length(reference)==1 ? ObservedResult(reference,1,requests;clip=definition.clip,atol=settings.atol,complete_pairs=true,observation_options...) :
+ throw(ArgumentError("a benchmark report retains one separate reference point"))) :
+ ObservedResult(reference,requests;clip=definition.clip,atol=settings.atol,complete_pairs=true,observation_options...)
+ return report(definition,observed;reference=observed_reference)
+end
+
+description(::BenchmarkTableDefinition,band) = band isa Symbol ? string(band) : string(first(band),"–",last(band)," Hz")
diff --git a/src/reportbuilder/grammar.jl b/src/reportbuilder/grammar.jl
new file mode 100644
index 000000000..a4405e3d6
--- /dev/null
+++ b/src/reportbuilder/grammar.jl
@@ -0,0 +1,376 @@
+"""
+$(TYPEDEF)
+
+Supertype for definitions consumed by [`report`](@ref).
+"""
+abstract type AbstractReportDefinition end
+
+"""
+$(TYPEDEF)
+
+Retain the observed inputs, separate observed reference, tables, illustration,
+and written destinations of one completed report.
+
+Plain and HTML display show the completed quantity tables. `artifact[R]`
+retrieves the reported resistance DataFrame, or an ordered vector of DataFrames
+for a collection. `artifact[i, R]` retrieves the table for result position `i`.
+Lookup and display use completed tables without further observation or computation.
+
+$(TYPEDFIELDS)
+"""
+struct ReportArtifact{T,I,O}
+ "One atomic observation or an ordinary vector of observations."
+ observed::Union{ObservedResult,Vector{ObservedResult}}
+ "Separate observed reference, when supplied."
+ reference::Union{Nothing,ObservedResult}
+ "Quantity-wise tables and other retained scientific summaries."
+ tables::T
+ "Optional rendered illustration."
+ illustration::I
+ "Written destinations, or nothing for an in-memory report."
+ output::O
+end
+
+# Only ordinary report containers are traversed. DataFrame cells are leaves.
+_reported_leaves(table::DataFrame) = (table,)
+_reported_leaves(tables::Union{NamedTuple,Tuple,AbstractVector}) =
+ Iterators.flatten(_reported_leaves(child) for child in tables)
+_reported_leaves(_) = ()
+
+function _reported_tables(artifact::ReportArtifact)
+ output=artifact.tables
+ benchmark=output isa NamedTuple && haskey(output,:features)
+ benchmark && (output=output.quantities)
+ collection=artifact.observed isa AbstractVector
+ # A specialized definition may return one aggregate table. Its display is
+ # still native, but a collection does not associate it with one gridpoint.
+ output isa DataFrame && return [(point_index=collection ? nothing : 1,tables=[output])]
+ return map(eachindex(_observed_points(artifact.observed))) do index
+ tables=benchmark || collection ? output[index] : output
+ (point_index=index,tables=collect(DataFrame,_reported_leaves(tables)))
+ end
+end
+
+function _reported_table(artifact,groups,request,index)
+ identity=Commons.normalize_observation_selector(request_identity(request))
+ indices=request_indices(request)
+ tables=collect(DataFrame,Iterators.flatten(group.tables for group in groups
+ if group.point_index==index))
+ matches=filter(tables) do table
+ retained=metadata(table,"request",nothing)
+ retained===nothing && return false
+ isequal(Commons.normalize_observation_selector(request_identity(retained)),identity) &&
+ (isempty(indices) || isequal(request_indices(retained),indices))
+ end
+ length(matches)==1 && return only(matches)
+ unassociated=any(group -> group.point_index===nothing,groups)
+ available_tables=unassociated ? Iterators.flatten(group.tables for group in groups) : tables
+ available=join((repr(metadata(table,"request",nothing)) for table in available_tables),", ")
+ problem=isempty(matches) ? "absent" : "ambiguous"
+ unassociated && (problem="not associated with a gridpoint")
+ id=_observed_points(artifact.observed)[index].gridpoint.id
+ location="reported result $index"*(id===nothing ? "" : " (gridpoint $(repr(id)))")
+ throw(ArgumentError("reported request $(repr(request)) is $problem for $location; " *
+ "available reported requests: [$available] (nothing means absent quantity descriptors). " *
+ "Use report(...; values=...) for a different selection."))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Retrieve an already-produced quantity DataFrame using an observation request.
+For an atomic report, the result is one DataFrame. Collections return an ordered vector,
+including for one result. `artifact[i, request]` returns the table for result
+position `i`, independently of its recorded scientific gridpoint identifier.
+
+An unindexed request returns the product with its reported selection intact.
+An indexed request must match that original selection exactly. Complete
+transformation and statistical identities remain distinct.
+
+# Arguments
+
+- `artifact`: a completed report.
+- `request`: a quantity selector or complete observation request.
+
+# Returns
+
+The stored DataFrame itself, or a vector of those DataFrames. Use `copy` for an
+independent table. Editing a returned table does not edit retained observations.
+
+# Errors
+
+Absent or ambiguous products, missing descriptors, and Boolean point indices
+raise `ArgumentError`. Invalid result positions raise `BoundsError`.
+
+# Examples
+
+```julia
+resistance = constants_report[R]
+resistance_tables = collection_report[R]
+second_resistance = collection_report[2, R]
+```
+"""
+function Base.getindex(artifact::ReportArtifact,request)
+ groups=_reported_tables(artifact)
+ tables=map(eachindex(_observed_points(artifact.observed))) do index
+ _reported_table(artifact,groups,request,index)
+ end
+ return artifact.observed isa ObservedResult ? only(tables) : tables
+end
+
+function Base.getindex(artifact::ReportArtifact,index::Integer,request)
+ index isa Bool && throw(ArgumentError("result position must be an integer, not Bool"))
+ points=_observed_points(artifact.observed)
+ index in eachindex(points) || throw(BoundsError(artifact,(index,request)))
+ return _reported_table(artifact,_reported_tables(artifact),request,index)
+end
+
+"""
+$(TYPEDEF)
+
+Select retained quantities for separate tables. Raw-input conveniences first
+construct [`ObservedResult`](@ref). Illustrations consume those same observations.
+
+$(TYPEDFIELDS)
+"""
+struct TableReportDefinition{R<:Tuple,U<:Tuple,P,O<:NamedTuple} <: AbstractReportDefinition
+ "Quantity requests. An empty tuple selects all retained quantities."
+ requests::R
+ "Display units used only when constructing a raw-input observation."
+ units::U
+ "True, a plotting callable, or nothing."
+ illustration::P
+ "Options passed to the illustration call."
+ plot_options::O
+ "Set unresolved observed values to exact zero, including zero uncertainty."
+ clip::Bool
+end
+TableReportDefinition(requests::Tuple=();units::Tuple=(),illustration=nothing,
+ plot_options::NamedTuple=(;),clip::Bool=true) =
+ TableReportDefinition(requests,units,illustration,plot_options,clip)
+
+"""Select retained products without extracting or recomputing numerical values."""
+function select end
+"""Build quantity-wise tables from detached observations."""
+function tabulate end
+"""Render an optional illustration from detached observations."""
+function illustrate end
+"""Encode observed tables for a requested output format."""
+function encode end
+"""Write already encoded report output."""
+function write end
+
+_observed_points(observed::ObservedResult) = (observed,)
+_observed_points(observed::AbstractVector{<:ObservedResult}) = observed
+
+select(definition::AbstractReportDefinition,observed::AbstractVector{<:ObservedResult};reference=nothing) =
+ map(point -> select(definition,point;reference),observed)
+
+function select(definition::TableReportDefinition,observed::ObservedResult;reference=nothing)
+ isempty(definition.requests) && return observed.quantities
+ return [Commons.observation_product(observed,request)
+ for request in Commons.observation_requests(observed,definition.requests).retained]
+end
+
+tabulate(definition::AbstractReportDefinition,observed;reference=nothing) =
+ tabulate(definition,observed,select(definition,observed;reference);reference)
+
+illustrate(::AbstractReportDefinition,observed,tables;reference=nothing) = nothing
+encode(::AbstractReportDefinition,observed,tables,illustration;reference=nothing) = nothing
+write(::AbstractReportDefinition,::Nothing) = nothing
+
+function illustrate(definition::TableReportDefinition,observed,tables;reference=nothing)
+ illustration=definition.illustration
+ (illustration===nothing || illustration===false) && return nothing
+ options=merge(definition.plot_options,isempty(definition.requests) ? (;) : (ydata=definition.requests,))
+ callable=illustration===true ? PlotBuilder.plot : illustration
+ return reference===nothing ? callable(observed;options...) : callable(observed;reference,options...)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Select retained products, build tables, render an optional illustration, encode
+output, and write artifacts, in that order. Only observations enter these report
+stages. `reference` is an atomic
+observation kept outside the reported-result collection.
+"""
+function report(definition::AbstractReportDefinition,
+ observed::Union{ObservedResult,AbstractVector{<:ObservedResult}};
+ reference::Union{Nothing,ObservedResult}=nothing)
+ selected=select(definition,observed;reference)
+ tables=tabulate(definition,observed,selected;reference)
+ illustration=illustrate(definition,observed,tables;reference)
+ encoded=encode(definition,observed,tables,illustration;reference)
+ written=write(definition,encoded)
+ points=observed isa ObservedResult ? observed : collect(ObservedResult,observed)
+ return ReportArtifact(points,reference,tables,illustration,written)
+end
+
+function report(definition::TableReportDefinition,source;kwargs...)
+ return report(source;values=definition.requests,units=definition.units,clip=definition.clip,
+ illustration=definition.illustration,plot_options=definition.plot_options,kwargs...)
+end
+
+"""Return the quantity and unit metadata of an observed table's columns."""
+observation_columns(table::DataFrame) = metadata(table,"observation_columns")
+
+# Resolve the Julia dispatch intersection between a generic raw convenience and
+# the common observed workflow without adding a second execution path.
+function report(definition::TableReportDefinition,
+ observed::Union{ObservedResult,AbstractVector{<:ObservedResult}};reference=nothing)
+ return invoke(report,Tuple{AbstractReportDefinition,typeof(observed)},definition,observed;reference)
+end
+
+# These keywords control acquisition. Observation defaults remain with the source.
+function _report_observation_options(options; retained = false)
+ for key in keys(options)
+ key in (:ydata, :rdata, :requests, :quantities) && throw(ArgumentError(
+ "use values to select report quantities; $key is not a reporting keyword"))
+ key in (:units, :length_unit, :quantity_units, :frequency_unit, :freq_unit,
+ :clip, :atol, :frequencies) || throw(ArgumentError(
+ "unknown reporting keyword $key; illustration options belong in plot_options"))
+ retained && key in (:clip, :atol, :frequencies) &&
+ throw(ArgumentError(
+ "$key requires a raw result; retained reports only select or re-express recorded values"))
+ end
+ haskey(options, :frequency_unit) && haskey(options, :freq_unit) &&
+ throw(ArgumentError(
+ "use frequency_unit or freq_unit, not both"))
+ return (;
+ (key===:freq_unit ? :frequency_unit=>value : key=>value
+ for (key, value) in options)...)
+end
+
+function _report_plot_options(source, requests, illustration, options::NamedTuple)
+ isempty(options) || !(illustration===nothing || illustration===false) ||
+ throw(ArgumentError(
+ "plot_options requires an explicit illustration"))
+ for key in (:values, :rdata, :requests, :quantities)
+ haskey(options, key) &&
+ throw(ArgumentError("select report and illustration quantities with values"))
+ end
+ if haskey(options, :ydata)
+ selected=Commons.observation_selection(source, options.ydata)
+ expected=Commons.observation_requests(source, requests; complete_pairs = true).displayed
+ Commons.observation_requests(source, selected; complete_pairs = true).displayed==expected ||
+ throw(ArgumentError("plot_options.ydata conflicts with the report's values selection"))
+ end
+ return (; (key=>value for (key, value) in pairs(options) if key!==:ydata)...)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build separate in-memory quantity tables from a completed result or collection.
+The same scientific selection used by plotting's `ydata` is named `values` here.
+Raw results are observed first. Retained observations preserve their recorded
+units and numerical eligibility unless compatible display units are requested.
+
+# Arguments
+
+- `source`: completed data supplied as a primary result or standalone Z/Y tensor.
+ A result space, ordinary collection or retained observation is also accepted.
+- `selection`: optional positional alternative to `values`. Do not supply both.
+
+# Keywords
+
+- `values=nothing`: a selector, one `@observe` request or a tuple of requests.
+ `nothing` and `()` use the source owner's defaults or all retained products.
+- `units`, `length_unit`, `quantity_units`, `frequency_unit`: observation unit
+ options. Raw defaults belong to the observation owner. Omitted retained
+ options preserve recorded units. `freq_unit` is an alternative spelling of
+ `frequency_unit`. Supplying both is an error.
+- `clip`, `atol`, `frequencies`: raw observation options. Cutoffs use native
+ units. Frequencies for a standalone tensor are in \\[Hz\\]. These keywords
+ cannot be supplied for retained inputs.
+- `reference=nothing`: a separate atomic raw or observed reference, stored in
+ `artifact.reference`. Numerical comparisons are supplied as completed records
+ in the observed inputs.
+- `illustration=nothing`: `true` or a plotting callable requests an illustration
+ of the retained observations with the matching `ydata` selection.
+- `plot_options=(;)`: options for an explicitly requested illustration.
+
+# Returns
+
+- A [`ReportArtifact`](@ref) containing observed inputs and separate quantity
+ DataFrames in memory.
+
+# Errors
+
+Unknown options, competing selection keywords, conflicting illustration
+selections, and acquisition options on retained inputs raise `ArgumentError`.
+Specialized definitions remain available through `report(definition, observed)`.
+
+# Examples
+
+```julia
+report(constants)
+report(constants; values=(R, L, G, C), length_unit=:kilo,
+ quantity_units=(R=:base, L=:milli, G=:micro, C=:micro))
+report(line_parameters; values=@observe(R[1, 1, 1:12]))
+```
+"""
+function report(
+ source::Union{Commons.AbstractCoreResult, Commons.AbstractResultSpace,
+ Engine.SeriesImpedance, Engine.ShuntAdmittance, AbstractVector, Tuple};
+ values = nothing, reference = nothing, illustration = nothing,
+ plot_options::NamedTuple = (;), kwargs...)
+ collection=source isa Union{AbstractVector, Tuple, Commons.AbstractParametricResult}
+ collection && isempty(source) &&
+ throw(ArgumentError("report requires at least one result"))
+ acquisition=_report_observation_options(kwargs;
+ retained = collection && any(point -> point isa ObservedResult, source))
+ point=collection ? first(source) : source
+ requests=Commons.observation_selection(point, values)
+ displayed=isempty(requests) ? () :
+ Commons.observation_requests(point, requests; complete_pairs = true).displayed
+ options=_report_plot_options(point, displayed, illustration, plot_options)
+ reference===nothing ||
+ reference isa Union{ObservedResult, Commons.AbstractCoreResult,
+ Engine.SeriesImpedance, Engine.ShuntAdmittance} ||
+ throw(ArgumentError(
+ "reference must be an atomic result or ObservedResult"))
+ observed=observables(source, requests; complete_pairs = true, acquisition...)
+ if reference isa ObservedResult
+ display_units=(;
+ (key=>value
+ for (key, value) in pairs(acquisition)
+ if key in (:units, :length_unit, :quantity_units, :frequency_unit))...)
+ isempty(display_units) || (reference=ObservedResult(reference; display_units...))
+ elseif reference!==nothing
+ reference=ObservedResult(reference, requests; complete_pairs = true, acquisition...)
+ end
+ return report(
+ observed; values = displayed, reference, illustration, plot_options = options)
+end
+
+function report(observed::Union{ObservedResult, AbstractVector{<:ObservedResult}};
+ values = nothing, reference::Union{Nothing, ObservedResult} = nothing,
+ illustration = nothing, plot_options::NamedTuple = (;), kwargs...)
+ display_units=_report_observation_options(kwargs; retained = true)
+ observed isa AbstractVector && isempty(observed) &&
+ throw(ArgumentError("report requires at least one observation"))
+ point=observed isa ObservedResult ? observed : first(observed)
+ requests=Commons.observation_selection(point, values)
+ displayed=Commons.observation_requests(point, requests).displayed
+ options=_report_plot_options(point, displayed, illustration, plot_options)
+ if !isempty(display_units)
+ observed=observables(observed, requests; display_units...)
+ reference===nothing ||
+ (reference=ObservedResult(reference, requests; display_units...))
+ end
+ definition=TableReportDefinition(requests; illustration,
+ plot_options = illustration===nothing || illustration===false ? options :
+ merge(options, (ydata = displayed,)))
+ return report(definition, observed; reference)
+end
+
+function report(
+ source::Union{Commons.AbstractCoreResult, Commons.AbstractResultSpace,
+ Engine.SeriesImpedance, Engine.ShuntAdmittance, ObservedResult, AbstractVector, Tuple},
+ selection; kwargs...)
+ haskey(kwargs, :values) &&
+ throw(ArgumentError("use positional selection or values, not both"))
+ return report(source; values = selection, kwargs...)
+end
diff --git a/src/reportbuilder/montecarlo.jl b/src/reportbuilder/montecarlo.jl
new file mode 100644
index 000000000..abb36b571
--- /dev/null
+++ b/src/reportbuilder/montecarlo.jl
@@ -0,0 +1,61 @@
+"""
+$(TYPEDEF)
+
+Select retained UQ statistical products for quantity-wise tables.
+
+$(TYPEDFIELDS)
+"""
+struct MonteCarloTableDefinition{U} <: AbstractReportDefinition
+ "Length prefix used during raw-input observation construction."
+ length_unit::Symbol
+ "Optional quantity-unit overrides."
+ quantity_units::U
+ "Set unresolved primary quantities to exact zero, including zero uncertainty."
+ clip::Bool
+end
+MonteCarloTableDefinition(length_unit::Symbol=:kilo,quantity_units=nothing) =
+ MonteCarloTableDefinition(length_unit,quantity_units,true)
+
+function report(definition::MonteCarloTableDefinition,source::AbstractUncertaintyResult)
+ requests=Tuple((UQ.statistics,selector,statistic) for selector in (R,L,G,C) for statistic in (Statistics.mean,Statistics.std))
+ observed=observables(source,requests;length_unit=definition.length_unit,
+ quantity_units=definition.quantity_units,clip=definition.clip)
+ return report(definition,observed)
+end
+select(::MonteCarloTableDefinition,observed::ObservedResult;reference=nothing) = observed.quantities
+function tabulate(::MonteCarloTableDefinition,observed,selected;reference=nothing)
+ observed isa ObservedResult && return _quantity_tables(selected;gridpoint_id=observed.gridpoint.id)
+ return map((point,products) -> _quantity_tables(products;gridpoint_id=point.gridpoint.id),
+ observed,selected)
+end
+
+function _sampling_tables(points,reference)
+ sampling=NamedTuple[];precision=NamedTuple[];statistics=NamedTuple[]
+ operands=reference===nothing ? ((:result,points),) : ((:result,points),(:reference,[reference]))
+ for (role,observations) in operands,point in observations
+ id=point.gridpoint.id
+ for quantity in point.quantities
+ quantity.family===:statistics || continue
+ push!(statistics,(role,identity=id,request=quantity.request,statistic=quantity.statistic,
+ unit=quantity.unit,values=Commons.detach(quantity.values)))
+ end
+ record=get(point.gridpoint,:sampling,nothing)
+ record===nothing && continue
+ scalar=(; (key=>(value===nothing ? missing : value isa Union{Number,Bool,Symbol,AbstractString,Missing} ? value : string(value))
+ for (key,value) in pairs(record) if !(key in (:mean_standard_error,:frequencies,:basis)))...)
+ push!(sampling,merge(scalar,(role,point=id===nothing ? record.point : id.problem_index,
+ formulation_index=id===nothing ? missing : id.formulation_index,
+ method=only(Commons.observation_labels([point])),std_sampling_precision=missing)))
+ for (quantity,values) in pairs(record.mean_standard_error),index in CartesianIndices(values)
+ coordinates=Tuple(index)
+ unit=Units.native_unit(Units.quantity(getfield(Engine,quantity)),record.basis)
+ push!(precision,(role,identity=id,point=id===nothing ? record.point : id.problem_index,
+ formulation_index=id===nothing ? missing : id.formulation_index,
+ method=only(Commons.observation_labels([point])),quantity,index=coordinates,
+ row=first(coordinates),column=length(coordinates)==3 ? coordinates[2] : missing,
+ frequency_Hz=Commons.nominal(record.frequencies[length(coordinates)==3 ? last(coordinates) : 1]),
+ standard_error=values[index],unit=Units.label(unit)))
+ end
+ end
+ return (statistics=DataFrame(statistics),sampling=DataFrame(sampling),mean_sampling_precision=DataFrame(precision))
+end
diff --git a/src/reportbuilder/performance.jl b/src/reportbuilder/performance.jl
new file mode 100644
index 000000000..66745882c
--- /dev/null
+++ b/src/reportbuilder/performance.jl
@@ -0,0 +1,116 @@
+"""
+$(TYPEDSIGNATURES)
+
+Tabulate completed timing records as scalar columns. Allocated bytes measure
+Julia allocation volume. Each record keeps its backend's timing scope.
+
+The optional `labels` are supplied by the same formulation descriptions used
+by the comparison report. Full workload and session records remain in the observation.
+"""
+function _timing_tables(measurements::Union{Nothing, NamedTuple};
+ labels=(reference="Reference",result="Result"))
+ execution=DataFrame()
+ source_timings=DataFrame()
+ performance=DataFrame()
+ performance_samples=DataFrame()
+ performance_environment=DataFrame()
+ performance_comparison=DataFrame()
+ if measurements !== nothing
+ for (role, record) in pairs(get(measurements,:execution,(;)))
+ method=getproperty(labels,role)
+ timing=get(record,:timing,(;))
+ session=get(record,:session,nothing)
+ push!(execution,(;role,method,point=missing,scope=get(timing,:scope,missing),
+ seconds=get(timing,:seconds,missing),reused=get(record,:reused,missing),
+ session_id=session===nothing ? missing : get(session,:id,missing),
+ execution_wall_seconds=get(record,:execution_wall_seconds,missing),
+ reused_points=get(timing,:reused_points,missing));cols=:union)
+ native=get(timing,:source_timings,(;))
+ sources=haskey(native,:points) ? enumerate(native.points) : ((1,native),)
+ for (point,measured) in sources
+ isempty(measured) && continue
+ # Source timing records are already scoped by their measurement
+ # owner. Non-scalar annotations are text, never hidden arrays.
+ scalar=(; (key => (value===nothing ? missing :
+ value isa Union{Number,Bool,Symbol,AbstractString,Missing} ? value : string(value))
+ for (key,value) in pairs(measured))...)
+ push!(source_timings,merge((;role,method,point),scalar);cols=:union)
+ end
+ for (point,measured) in enumerate(get(timing,:points,()))
+ push!(execution,(;role,method,point,scope=get(measured,:scope,missing),
+ seconds=get(measured,:seconds,missing),reused=get(measured,:reused,missing));cols=:union)
+ end
+ end
+ recorded=get(measurements,:performance,nothing)
+ if recorded !== nothing
+ session=get(measurements,:session,nothing)
+ for role in (:reference,:result)
+ method=getproperty(labels,role)
+ measured=getproperty(recorded,role)
+ observations=get(measured,:observations,())
+ push!(performance,(;role,method,scope=measured.scope,
+ median_seconds=measured.median_seconds,allocated_bytes=measured.bytes,
+ allocated_MiB=measured.bytes/2.0^20,
+ samples=measured.samples,requested_samples=recorded.settings.samples,
+ reused=any(row -> get(row,:reused,false),observations),
+ session_id=session===nothing ? missing : get(session,:id,missing),
+ checksum_verified=get(measurements,:checksum_verified,missing),
+ workload_verified=get(measurements,:workload_verified,missing));cols=:union)
+ for (sample,observation) in enumerate(observations)
+ push!(performance_samples,(;role,method,sample,scope=measured.scope,
+ seconds=get(observation,:seconds,missing),
+ allocated_bytes=get(observation,:bytes,missing),
+ reused=get(observation,:reused,missing));cols=:union)
+ end
+ for (key,value) in pairs(measured.environment)
+ # Full calculation and workload bindings remain retained,
+ # not printed as one opaque cell in the default table.
+ key in (:calculation,:workload,:settings) && continue
+ value isa NamedTuple && continue
+ push!(performance_environment,(;role,method,property=string(key),
+ value=value===nothing ? missing :
+ value isa Union{Number,Bool,Symbol,AbstractString,Missing} ? value : string(value));cols=:union)
+ end
+ end
+ push!(performance_comparison,(
+ reference_over_result=recorded.speedup,comparable=recorded.comparable,
+ requested_samples=recorded.settings.samples,time_budget_seconds=recorded.settings.seconds))
+ end
+ end
+ return (;execution,source_timings,performance,performance_samples,
+ performance_environment,performance_comparison)
+end
+
+# Attach each retained measurement to the results it describes. A batch-scoped
+# measurement keeps that scope even when several observations refer to it.
+# Equal numbers never establish that two measurements are the same event.
+function _timing_tables(points::AbstractVector,reference)
+ reference_id=reference===nothing ? nothing : reference.gridpoint.id
+ labels=Commons.observation_labels(reference===nothing ? points : [points;reference])
+ reference_label=reference===nothing ? "Reference" : last(labels)
+ combined=_timing_tables(nothing)
+ for (index,point) in enumerate(points)
+ record=point.timings
+ tables=_timing_tables(get(record,:measurements,nothing);
+ labels=(reference=reference_label,result=labels[index]))
+ if haskey(record,:seconds)
+ push!(tables.execution,(role=:result,method=labels[index],
+ point=point.gridpoint.id.problem_index,scope=get(record,:scope,missing),
+ seconds=record.seconds);cols=:union)
+ end
+ for (destination,table) in zip(combined,tables)
+ isempty(table) && continue
+ result_id=point.gridpoint.id
+ bindings=(result_source=string(result_id.source_id),
+ result_point=result_id.problem_index,result_formulation=result_id.formulation_index,
+ reference_source=reference_id===nothing ? missing : string(reference_id.source_id),
+ reference_point=reference_id===nothing ? missing : reference_id.problem_index,
+ reference_formulation=reference_id===nothing ? missing : reference_id.formulation_index)
+ for (name,value) in pairs(bindings)
+ table[!,name]=fill(value,size(table,1))
+ end
+ append!(destination,table;cols=:union)
+ end
+ end
+ return combined
+end
diff --git a/src/reportbuilder/tables.jl b/src/reportbuilder/tables.jl
new file mode 100644
index 000000000..963d861d8
--- /dev/null
+++ b/src/reportbuilder/tables.jl
@@ -0,0 +1,301 @@
+"""
+$(TYPEDEF)
+
+Publish an [`Engine.CableConstants`](@ref) result as separate R/L/C/G tables,
+each with one operating-frequency row and a named column per assembly.
+
+$(TYPEDFIELDS)
+"""
+struct CableConstantsTableDefinition <: AbstractReportDefinition
+ "Whether detached display residue is replaced with exact zero."
+ clip::Bool
+end
+CableConstantsTableDefinition() = CableConstantsTableDefinition(true)
+
+"""
+$(TYPEDEF)
+
+Define requests and display units for separate line-parameter quantity tables.
+Each full matrix table has one frequency column and every ordered coefficient.
+
+$(TYPEDFIELDS)
+"""
+struct LineParametersTableDefinition{Q <: Tuple, U} <: AbstractReportDefinition
+ "Explicit observable requests in quantity-table order."
+ requests::Q
+ "SI prefix used to display frequency."
+ frequency_unit::Symbol
+ "Length prefix used for per-length quantities."
+ length_unit::Symbol
+ "Optional display-unit overrides resolved from the requests."
+ quantity_units::U
+ "Whether detached display residue is replaced with exact zero."
+ clip::Bool
+end
+
+"""
+$(TYPEDEF)
+
+Select and publish per-term comparisons of scalar or formulation-space results.
+Retained products supply detailed tables and a compact summary.
+
+$(TYPEDFIELDS)
+"""
+struct BenchmarkTableDefinition{S <: NamedTuple, I, O <: NamedTuple} <: AbstractReportDefinition
+ "Normalized quantities, frequency bands and numerical comparison controls."
+ settings::S
+ "Explicitly supplied comparison controls, used when selecting retained products."
+ requested::Tuple{Vararg{Symbol}}
+ "Whether detached display residue is replaced with exact zero."
+ clip::Bool
+ "Optional explicit illustration request. Use `nothing` to omit the figure."
+ illustration::I
+ "Options for the explicitly requested illustration."
+ plot_options::O
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Request per-term comparisons grouped by formulation and frequency band.
+Quantities use the observation grammar. The default bands are the entire range,
+near DC, harmonic, narrowband and wideband. `fundamental` is in Hz. Creating a figure requires an explicit `illustration`. A benchmark report retains one scalar reference separately from its results.
+The explicit comparison operation also supports declared collection pairings.
+"""
+function BenchmarkTableDefinition(; clip::Bool=false, illustration=nothing, plot_options=(;), kwargs...)
+ haskey(kwargs,:requests) && any(key -> haskey(kwargs,key),(:quantities,:statistics)) &&
+ throw(ArgumentError("use requests or quantities/statistics, not both"))
+ statistics = Tuple(get(kwargs,:statistics,(:value,)))
+ quantities = Tuple(get(kwargs,:quantities,statistics == (:value,) ? (Z,Y,R,L,G,C) : (R,L,C,G)))
+ requests = get(kwargs,:requests,nothing)
+ if requests === nothing
+ selectors = map(quantities) do value
+ value isa Function && return value
+ matches = filter(selector -> nameof(selector) == value, (Z,Y,R,X,L,G,B,C))
+ length(matches) == 1 || throw(ArgumentError("unknown physical quantity $value"))
+ only(matches)
+ end
+ transforms = map(statistics) do entry
+ entry === :value && return nothing
+ entry isa Function && return entry
+ matches = filter(selector -> nameof(selector) == entry,
+ (Statistics.mean,Statistics.std,Statistics.median,minimum,maximum))
+ length(matches) == 1 || throw(ArgumentError("unknown statistic $entry"))
+ only(matches)
+ end
+ requests = Tuple(transform === nothing ? selector : (UQ.statistics,selector,transform)
+ for selector in selectors for transform in transforms)
+ end
+ controls = (; (key=>value for (key,value) in kwargs if !(key in (:requests,:quantities,:statistics)))...)
+ result = BenchmarkTableDefinition(Tuple(requests);clip,illustration,plot_options,controls...)
+ requested = Tuple(unique(key in (:quantities,:statistics) ? :requests : key for key in keys(kwargs)))
+ return BenchmarkTableDefinition(result.settings,requested,clip,illustration,plot_options)
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Select scientific requests for per-term RMS comparisons. Statistical requests
+use `(statistics, quantity, statistic)` function tuples. Bands apply equally to
+deterministic and statistical products. Absolute limits use native physical
+units. Relative errors are dimensionless. Plotting remains explicitly requested.
+"""
+function BenchmarkTableDefinition(requests::Tuple; clip::Bool=false, illustration=nothing,
+ plot_options=(;), kwargs...)
+ defaults=(bands=(:all,:dc,:harmonic,:narrow,:wide),
+ normalizations=(:reference_rms,), atol=nothing, fundamental=50.0, harmonics=50,
+ unsupported=(;), pairing=nothing)
+ isempty(setdiff(keys(kwargs),keys(defaults))) || throw(ArgumentError("unknown benchmark comparison controls"))
+ supplied=merge(defaults,(;kwargs...))
+ settings=merge((;requests),supplied,(
+ bands=Tuple(supplied.bands),
+ normalizations=Tuple(supplied.normalizations)))
+ return validate(BenchmarkTableDefinition(settings,(:requests,keys(kwargs)...),clip,illustration,plot_options))
+end
+
+function BenchmarkTableDefinition(clip::Bool; kwargs...)
+ return BenchmarkTableDefinition(; clip, kwargs...)
+end
+
+"""Validate comparison requests before numerical execution."""
+function validate(definition::BenchmarkTableDefinition)
+ settings=definition.settings
+ !isempty(settings.requests) && allunique(settings.requests) ||
+ throw(ArgumentError("benchmark requests must be nonempty and distinct"))
+ for request in settings.requests
+ request_quantity(request)
+ isempty(request_indices(request)) || throw(ArgumentError("benchmark point selection uses pairing"))
+ end
+ if settings.pairing !== nothing
+ settings.pairing isa Union{Tuple,AbstractVector} && !isempty(settings.pairing) &&
+ all(pair -> pair isa Tuple{Integer,Integer} && all(index -> !(index isa Bool) && index>0,pair),settings.pairing) &&
+ sort(last.(collect(settings.pairing))) == collect(1:length(settings.pairing)) ||
+ throw(ArgumentError("pairing must list positive reference/result indices with each result exactly once"))
+ end
+ !isempty(settings.bands) && allunique(settings.bands) ||
+ throw(ArgumentError("benchmark needs distinct frequency bands"))
+ !isempty(settings.normalizations) && allunique(settings.normalizations) ||
+ throw(ArgumentError("benchmark needs distinct RMS normalizations"))
+ for band in settings.bands, normalization in settings.normalizations
+ validate((; normalization, band, fundamental=settings.fundamental,
+ harmonics=settings.harmonics, atol=settings.atol,
+ unsupported=settings.unsupported), Engine.compare)
+ end
+ return definition
+end
+
+
+LineParametersTableDefinition(requests::Tuple=();frequency_unit::Symbol=:base,
+ length_unit::Symbol=:kilo,quantity_units=nothing,clip::Bool=true) =
+ LineParametersTableDefinition(requests,frequency_unit,length_unit,quantity_units,clip)
+
+_bound_name(selector::Base.Fix2,statistic) =
+ selector.f in (Engine.ModalAnalysis.H,Engine.ModalAnalysis.Zc,
+ Engine.ModalAnalysis.Yc) ?
+ string(nameof(selector.f),"_phase",
+ selector.f===Engine.ModalAnalysis.H ? "_$(selector.x.field)" : "") :
+ string(statistic)
+
+_quantity_name(product) = begin
+ identity=request_identity(product.request)
+ identity isa Function ? (identity isa Base.Fix2 ? Symbol(_bound_name(identity,product.statistic)) : nameof(identity)) :
+ Symbol(join([entry isa Base.Fix2 ? _bound_name(entry,product.statistic) : string(nameof(entry)) for entry in identity],"_"))
+end
+
+function _quantity_table(product; gridpoint_id=nothing)
+ coordinates=product.coordinates
+ values=product.values
+ scalar=values isa Number || ismissing(values)
+ if coordinates.kind in (:matrix,:diagonal,:vector)
+ diagonal=coordinates.kind===:diagonal
+ vector=coordinates.kind===:vector
+ dimensions=vector ? (length(coordinates.positions),length(coordinates.samples)) :
+ diagonal ? (length(coordinates.rows),length(coordinates.samples)) :
+ (length(coordinates.rows),length(coordinates.columns),length(coordinates.samples))
+ shaped=reshape(scalar ? [values] : values,dimensions...)
+ f=coordinates.frequencies
+ table=f===nothing ? DataFrame(sample=coordinates.samples) : DataFrame(frequency=f)
+ columns=Pair{Symbol,Any}[]
+ if vector
+ for (i,position) in enumerate(coordinates.positions)
+ name=Symbol(coordinates.axis_label," ",coordinates.labels[position])
+ table[!,name]=copy(shaped[i,:])
+ push!(columns,name=>(quantity=product.quantity,unit=product.unit,
+ axis=coordinates.axis,position,label=coordinates.labels[position]))
+ end
+ elseif diagonal
+ for (i,row) in enumerate(coordinates.rows)
+ name=Symbol("[",row,",",row,"]")
+ table[!,name]=copy(shaped[i,:])
+ push!(columns,name=>(quantity=product.quantity,unit=product.unit,row,column=row))
+ end
+ else
+ # Full matrices are deliberately row-major, including both off-diagonals.
+ for (i,row) in enumerate(coordinates.rows), (j,column) in enumerate(coordinates.columns)
+ name=get(coordinates,:column_domain,nothing)===:ModalDomain ?
+ Symbol("Conductor ",coordinates.labels[row],", Mode ",coordinates.column_labels[column]) :
+ Symbol("[",row,",",column,"]")
+ table[!,name]=copy(shaped[i,j,:])
+ push!(columns,name=>(quantity=product.quantity,unit=product.unit,row,column,
+ row_label=coordinates.labels[row],
+ column_label=get(coordinates,:column_labels,coordinates.labels)[column]))
+ end
+ end
+ first_column=f===nothing ? (:sample=>(quantity=nothing,unit=nothing)) :
+ (:frequency=>(quantity=Units.Quantity{:frequency}(),unit=coordinates.frequency_unit))
+ metadata!(table,"observation_columns",(; (first_column,columns...)...);style=:note)
+ elseif coordinates.kind===:assemblies
+ table=DataFrame(frequency=coordinates.frequencies)
+ columns=Pair{Symbol,Any}[:frequency=>(quantity=Units.Quantity{:frequency}(),unit=coordinates.frequency_unit)]
+ for (index,assembly) in enumerate(coordinates.assemblies)
+ name=Symbol(coordinates.labels[assembly])
+ name===:frequency && throw(ArgumentError("assembly name conflicts with the frequency column"))
+ table[!,name]=[scalar ? values : values[index]]
+ push!(columns,name=>(quantity=product.quantity,unit=product.unit,assembly))
+ end
+ metadata!(table,"observation_columns",(;columns...);style=:note)
+ elseif coordinates.kind===:samples
+ if haskey(coordinates,:rows)
+ dims=(length(coordinates.rows),length(coordinates.columns),length(coordinates.samples),length(coordinates.trials))
+ shaped=reshape(scalar ? [values] : values,dims...)
+ table=DataFrame([(frequency=coordinates.frequencies[k],row=coordinates.rows[i],column=coordinates.columns[j],
+ trial=coordinates.trials[t],value=shaped[i,j,k,t]) for t in 1:dims[4] for k in 1:dims[3] for i in 1:dims[1] for j in 1:dims[2]])
+ else
+ shaped=reshape(scalar ? [values] : values,length(coordinates.assemblies),length(coordinates.trials))
+ table=DataFrame([(assembly=coordinates.assemblies[i],trial=coordinates.trials[t],value=shaped[i,t])
+ for t in eachindex(coordinates.trials) for i in eachindex(coordinates.assemblies)])
+ end
+ metadata!(table,"observation_columns",(value=(quantity=product.quantity,unit=product.unit),);style=:note)
+ elseif values isa NamedTuple
+ table=DataFrame(values)
+ metadata!(table,"observation_columns",(;);style=:note)
+ else
+ vector=vec(scalar ? [values] : values)
+ table=DataFrame(index=collect(eachindex(vector)),value=copy(vector))
+ metadata!(table,"observation_columns",(value=(quantity=product.quantity,unit=product.unit),);style=:note)
+ end
+ coordinate_columns=coordinates.kind===:samples ? Tuple(filter(!=(:value),propertynames(table))) :
+ coordinates.kind in (:matrix,:diagonal,:vector,:assemblies,:array) ? (first(propertynames(table)),) : ()
+ metadata!(table,"coordinate_columns",coordinate_columns;style=:note)
+ metadata!(table,"coordinates",Commons.detach(coordinates);style=:note)
+ metadata!(table,"quantity",product.quantity;style=:note)
+ metadata!(table,"request",Commons.detach(product.request);style=:note)
+ metadata!(table,"statistic",Commons.detach(product.statistic);style=:note)
+ metadata!(table,"family",product.family;style=:note)
+ metadata!(table,"unit",product.unit;style=:note)
+ metadata!(table,"basis",product.basis;style=:note)
+ metadata!(table,"missing_reason",Commons.detach(product.missing_reason);style=:note)
+ gridpoint_id===nothing || metadata!(table,"gridpoint_id",gridpoint_id;style=:note)
+ return table
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Build one table per retained quantity, grouped by the defining physical family.
+Full matrices retain every coefficient in row-major order. Each row represents
+one retained frequency or sample coordinate.
+"""
+function _quantity_tables(products; gridpoint_id=nothing)
+ allunique((q.family,_quantity_name(q)) for q in products) || throw(ArgumentError(
+ "multiple retained products share a quantity name; select a complete request with tabulate(observed, request)"))
+ families=unique(q.family for q in products)
+ return (;(family=>(;(_quantity_name(q)=>_quantity_table(q;gridpoint_id) for q in products if q.family==family)...)
+ for family in families)...)
+end
+tabulate(observed::ObservedResult) = _quantity_tables(observed.quantities;gridpoint_id=observed.gridpoint.id)
+
+"""Build one table from a retained quantity request."""
+tabulate(observed::ObservedResult,request) = _quantity_table(Commons.observation_product(observed,request);gridpoint_id=observed.gridpoint.id)
+tabulate(observed::AbstractVector{<:ObservedResult}) = map(tabulate,observed)
+
+select(::CableConstantsTableDefinition,observed::ObservedResult;reference=nothing) = observed.quantities
+function select(definition::LineParametersTableDefinition,observed::ObservedResult;reference=nothing)
+ return [Commons.observation_product(observed,request)
+ for request in Commons.observation_requests(observed,definition.requests).retained]
+end
+function tabulate(::Union{TableReportDefinition,CableConstantsTableDefinition,LineParametersTableDefinition},
+ observed,selected;reference=nothing)
+ observed isa ObservedResult && return _quantity_tables(selected;gridpoint_id=observed.gridpoint.id)
+ return map((point,products) -> _quantity_tables(products;gridpoint_id=point.gridpoint.id),
+ observed,selected)
+end
+function report(definition::CableConstantsTableDefinition,source::Engine.CableConstants;kwargs...)
+ return report(definition,ObservedResult(source;clip=definition.clip,kwargs...))
+end
+function report(definition::LineParametersTableDefinition,source::Engine.LineParameters;kwargs...)
+ return report(definition,ObservedResult(source,definition.requests;complete_pairs=true,clip=definition.clip,
+ frequency_unit=definition.frequency_unit,length_unit=definition.length_unit,
+ quantity_units=definition.quantity_units,kwargs...))
+end
+
+"""
+$(TYPEDSIGNATURES)
+
+Reject aggregate conversion because an observation contains separate physical
+quantities. Select a quantity table with `ReportBuilder.tabulate(observed, R)`
+or a leaf such as `ReportBuilder.tabulate(observed).Z.R`.
+"""
+function DataFrame(::ObservedResult)
+ throw(ArgumentError("ObservedResult contains separate quantity tables; use ReportBuilder.tabulate(observed, R) or ReportBuilder.tabulate(observed).Z.R"))
+end
diff --git a/src/reportbuilder/textdisplay.jl b/src/reportbuilder/textdisplay.jl
new file mode 100644
index 000000000..739e3615f
--- /dev/null
+++ b/src/reportbuilder/textdisplay.jl
@@ -0,0 +1,294 @@
+_report_name(value) = String(nameof(typeof(value)))
+
+function Base.summary(io::IO, definition::AbstractReportDefinition)
+ print(io, _report_name(definition), " report definition")
+end
+function Base.show(io::IO, definition::AbstractReportDefinition)
+ print(io, _report_name(definition), "()")
+end
+Base.show(io::IO, ::MIME"text/plain", definition::AbstractReportDefinition) =
+ show(io, definition)
+
+Base.summary(io::IO, definition::TableReportDefinition) =
+ print(io, "Table report with $(length(definition.requests)) observations")
+function Base.show(io::IO, definition::TableReportDefinition)
+ print(io, "TableReportDefinition(requests=", length(definition.requests), ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", definition::TableReportDefinition)
+ get(io, :compact, false) && return show(io, definition)
+ children = Any[
+ (label = "requests $(length(definition.requests))", noun = "fields"),
+ (label = "clip $(definition.clip)", noun = "fields"),
+ ]
+ definition.illustration === nothing || push!(children,
+ (label = "illustration $(definition.illustration)", noun = "fields"))
+ return TextDisplay.tree(io, "Table report definition", Tuple(children))
+end
+
+Base.summary(io::IO, ::CableConstantsTableDefinition) =
+ print(io, "Cable-constants table definition")
+Base.show(io::IO, definition::CableConstantsTableDefinition) =
+ print(io, "CableConstantsTableDefinition(clip=", definition.clip, ")")
+Base.show(io::IO, ::MIME"text/plain", definition::CableConstantsTableDefinition) =
+ show(io, definition)
+
+Base.summary(io::IO, definition::LineParametersTableDefinition) =
+ print(io, "Line-parameters table with $(length(definition.requests)) observations")
+function Base.show(io::IO, definition::LineParametersTableDefinition)
+ print(io, "LineParametersTableDefinition(requests=", length(definition.requests),
+ ", length_unit=:", definition.length_unit, ")")
+end
+Base.show(io::IO, ::MIME"text/plain", definition::LineParametersTableDefinition) =
+ show(io, definition)
+
+Base.summary(io::IO, ::BenchmarkTableDefinition) = print(io, "Benchmark table definition")
+Base.show(io::IO, definition::BenchmarkTableDefinition) =
+ print(io, "BenchmarkTableDefinition(clip=", definition.clip, ")")
+Base.show(io::IO, ::MIME"text/plain", definition::BenchmarkTableDefinition) =
+ show(io, definition)
+
+Base.summary(io::IO, ::MonteCarloTableDefinition) = print(io, "Monte Carlo table definition")
+function Base.show(io::IO, definition::MonteCarloTableDefinition)
+ print(io, "MonteCarloTableDefinition(length_unit=:", definition.length_unit,
+ ", clip=", definition.clip, ")")
+end
+Base.show(io::IO, ::MIME"text/plain", definition::MonteCarloTableDefinition) =
+ show(io, definition)
+
+Base.summary(io::IO, ::XLSXReportDefinition) = print(io, "XLSX report definition")
+function Base.show(io::IO, definition::XLSXReportDefinition)
+ destination = definition.file_name === nothing ? "default path" : repr(definition.file_name)
+ print(io, "XLSXReportDefinition(", destination, "; clip=", definition.clip, ", overwrite=", definition.overwrite, ")")
+end
+Base.show(io::IO, ::MIME"text/plain", definition::XLSXReportDefinition) =
+ show(io, definition)
+
+function Base.summary(io::IO, artifact::ReportArtifact)
+ groups=_reported_tables(artifact)
+ point_count=length(_observed_points(artifact.observed))
+ table_count=sum(group -> length(group.tables),groups;init=0)
+ print(io,"Report")
+ point_count>1 && print(io," · ",point_count," gridpoints")
+ print(io," · ",table_count,table_count==1 ? " table" : " tables")
+end
+
+function Base.show(io::IO, artifact::ReportArtifact)
+ groups=_reported_tables(artifact)
+ point_count=length(_observed_points(artifact.observed))
+ print(io,"ReportArtifact(")
+ point_count>1 && print(io,"gridpoints=",point_count,", ")
+ print(io,"tables=",sum(group -> length(group.tables),groups;init=0))
+ artifact.illustration===nothing || print(io,", illustration=present")
+ artifact.output===nothing || print(io,", output=present")
+ print(io,")")
+end
+
+_report_escape(value)=replace(string(value),'&'=>"&",'<'=>"<",'>'=>">",'"'=>""",'\''=>"'")
+
+function _report_line(io,::MIME"text/plain",text;level=0,first=false)
+ first || print(io,'\n')
+ line=replace(string(text),'\n'=>' ','\r'=>' ')
+ print(io,get(io,:limit,true) ? TextDisplay.truncate_text(line,max(displaysize(io)[2],0)) : line)
+end
+function _report_line(io,::MIME"text/html",text;level=0,first=false)
+ tag=level==0 ? "p" : "h$(level)"
+ print(io,"<",tag,">",_report_escape(text),"",tag,">")
+end
+
+function _reported_heading(table;families=false,limited=false)
+ quantity=metadata(table,"quantity",nothing)
+ unit=metadata(table,"unit",nothing)
+ title=quantity===nothing ? "Table" : unit===nothing ? Units.label(quantity) : Units.label(quantity,unit)
+ statistic=metadata(table,"statistic",:value)
+ statistic===:value || (title*=" · "*string(statistic))
+ coordinates=metadata(table,"coordinates",(;))
+ get(coordinates,:kind,nothing)===:diagonal && (title*=" · diagonal")
+ family=metadata(table,"family",nothing)
+ families && family in (:Z,:Y) && (title=string(family," · ",title))
+ columns=metadata(table,"observation_columns",(;))
+ for name in metadata(table,"coordinate_columns",())
+ descriptor=get(columns,name,nothing)
+ text=descriptor===nothing || descriptor.quantity===nothing ? string(name) :
+ Units.label(descriptor.quantity,descriptor.unit)
+ title*=" · "*text
+ end
+ if limited
+ rows,columns=size(table)
+ title*=" · $rows row$(rows==1 ? "" : "s") × $columns column$(columns==1 ? "" : "s")"
+ end
+ return title
+end
+
+# Native DataFrames writers own cell formatting and whole-row and column omission.
+# A limited table is buffered only within its allotted display area, to account
+# for its actual line use. Unlimited inspection streams directly to the caller.
+function _report_table(io,mime::MIME"text/plain",table,height,width,limited)
+ if !limited
+ print(io,'\n')
+ show(IOContext(io,:limit=>false),mime,table;summary=false,eltypes=false,truncate=0)
+ return 0
+ end
+ # Native horizontal cropping can cut a numeric token. Measure a bounded
+ # native preview without cell clipping, dropping whole columns until it fits.
+ columns=min(DataFrames.ncol(table),max(width÷3,1))
+ while columns>0 || DataFrames.ncol(table)==0
+ buffer=IOBuffer()
+ context=IOContext(IOContext(buffer,io),:limit=>true,:displaysize=>(height,width))
+ show(context,mime,table;summary=false,eltypes=false,truncate=0,
+ reserved_display_lines=0,maximum_number_of_rows=max(height-3,1),
+ maximum_number_of_columns=columns,allrows=true,allcols=true,
+ show_omitted_cell_summary=false)
+ rendered=String(take!(buffer))
+ lines=split(rendered,'\n')
+ # Ignore terminal color escapes for width measurement only.
+ if all(line -> textwidth(replace(line,r"\e\[[0-9;:]*m"=>""))<=width,lines)
+ print(io,'\n',rendered)
+ return length(lines)
+ end
+ columns-=1
+ DataFrames.ncol(table)==0 && break
+ end
+ _report_line(io,mime,"… no complete column fits")
+ return 1
+end
+function _report_table(io,mime::MIME"text/html",table,height,width,limited)
+ show(IOContext(io,:limit=>limited),mime,table;summary=false,eltypes=false,
+ maximum_number_of_rows=limited ? max(height-3,1) : -1,
+ maximum_number_of_columns=limited ? max(width÷12,1) : -1,
+ show_omitted_cell_summary=!limited,new_line_at_end=false)
+ return limited ? min(height,DataFrames.nrow(table)+2) : 0
+end
+
+function _show_quantity_report(io,mime,artifact)
+ groups=_reported_tables(artifact)
+ total=sum(group -> length(group.tables),groups;init=0)
+ limited=get(io,:limit,true)
+ rows,width=displaysize(io)
+ point_count=length(_observed_points(artifact.observed))
+ table_noun=total==1 ? "table" : "tables"
+ context=point_count>1 ? "Report · $point_count gridpoints" : "Report"
+ _report_line(io,mime,"$context · $total $table_noun";level=2,first=true)
+ limited && rows<=1 && return nothing
+ # One final line remains available for honest omission counts.
+ remaining=limited ? max(rows-2,0) : typemax(Int)
+ points=collect(_observed_points(artifact.observed))
+ labels=Commons.observation_labels(artifact.reference===nothing ? points : [points;artifact.reference];fallback="")
+ if artifact.reference!==nothing && remaining>0
+ _report_line(io,mime,isempty(last(labels)) ? "Reference" : "Reference · $(last(labels))")
+ remaining-=1
+ end
+ shown=0
+ shown_points=0
+ for group in groups
+ index=group.point_index
+ tables=group.tables
+ isempty(tables) && continue
+ context=index===nothing ? join(unique(filter(!isempty,labels[1:point_count])),"; ") : labels[index]
+ if index!==nothing && point_count>1 && (isempty(context) || count(==(context),labels[1:point_count])>1)
+ context="[$index]"*(isempty(context) ? "" : " · $context")
+ end
+ context_lines=isempty(context) ? 0 : 1
+ # Heading, column labels, separator, at least one data row, and native
+ # omission information when needed. No gridpoint consumes the next one's
+ # place unless its own selected quantities have received a fair preview.
+ costs=[3+min(DataFrames.nrow(table),1)+(DataFrames.nrow(table)>1) for table in tables]
+ visible=limited ? count(<=(remaining-context_lines),cumsum(costs)) : length(tables)
+ (visible==0 || limited && width<12) && break
+ isempty(context) || _report_line(io,mime,context;level=3)
+ remaining-=context_lines
+ shown_points+=index===nothing ? point_count : 1
+ families=length(unique(metadata(table,"family",nothing) for table in tables))>1
+ for position in 1:visible
+ table=tables[position]
+ remaining-=1
+ height=limited ? max(costs[position]-1,remaining÷(visible-position+1)-1) : 0
+ _report_line(io,mime,_reported_heading(table;families,limited);level=4)
+ used=_report_table(io,mime,table,height,width,limited)
+ remaining-=used
+ shown+=1
+ end
+ visible1 ? "; $shown_points/$point_count gridpoints shown" : ""
+ _report_line(io,mime,"… $(total-shown) tables omitted$progress")
+ elseif remaining>0 && artifact.illustration!==nothing
+ _report_line(io,mime,"Illustration retained")
+ remaining-=1
+ end
+ if remaining>0 && artifact.output!==nothing
+ output=artifact.output isa AbstractString ? "Written output: $(artifact.output)" :
+ artifact.output isa AbstractVector ? "$(length(artifact.output)) written destinations" : "Written output retained"
+ _report_line(io,mime,output)
+ end
+ return nothing
+end
+
+function Base.show(io::IO, mime::MIME"text/plain", artifact::ReportArtifact;kwargs...)
+ get(io,:compact,false) && return show(io,artifact)
+ artifact.tables isa NamedTuple && haskey(artifact.tables,:features) &&
+ return _show_observed_benchmark(io,mime,artifact;kwargs...)
+ return _show_quantity_report(io,mime,artifact)
+end
+
+Base.summary(io::IO, sheet::XLSXSheet) = print(io, "XLSX sheet \"", sheet.name, "\"")
+function Base.show(io::IO, sheet::XLSXSheet)
+ print(io, "XLSXSheet(\"", sheet.name, "\"; cells=", join(size(sheet.cells), '×'), ")")
+end
+Base.show(io::IO, ::MIME"text/plain", sheet::XLSXSheet) = show(io, sheet)
+
+Base.summary(io::IO, workbook::XLSXWorkbook) =
+ print(io, "XLSX workbook with $(length(workbook.sheets)) sheets")
+function Base.show(io::IO, workbook::XLSXWorkbook)
+ print(io, "XLSXWorkbook(", repr(workbook.destination), "; sheets=",
+ length(workbook.sheets), ")")
+end
+function Base.show(io::IO, ::MIME"text/plain", workbook::XLSXWorkbook)
+ get(io, :compact, false) && return show(io, workbook)
+ sheets = Tuple((label = "$(sheet.name) · $(join(size(sheet.cells), '×')) cells",
+ noun = "sheets") for sheet in workbook.sheets)
+ return TextDisplay.tree(io, "XLSX workbook · $(length(workbook.sheets)) sheets", sheets;
+ noun = "sheets")
+end
+
+
+function _show_observed_benchmark(io,mime,artifact;metric=:relative,problem=nothing,native_timings=false)
+ metric in (:relative,:absolute) || throw(ArgumentError("metric must be :relative or :absolute"))
+ features=filter(row -> problem===nothing || row.problem_index==problem,artifact.tables.features)
+ isempty(features) && throw(ArgumentError("the selected point has no retained comparison features"))
+ html=mime isa MIME"text/html"
+ table_options=html ? (;) : (;truncate=0)
+ heading(title)=html ? println(io,"