From 3ceb722c40085eb37ab40140ca1159952f628505 Mon Sep 17 00:00:00 2001 From: AFeuerpfeil Date: Thu, 2 Apr 2026 11:27:01 -0400 Subject: [PATCH 1/6] =?UTF-8?q?feat!:=20v2.0.0=20=E2=80=94=20add=20imap,?= =?UTF-8?q?=20MappedRef,=20and=20modular=20source=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking changes: - PeriodicArray type signature: {T,N,A,F} → {T,N,A,F,G} (adds imap type param G) - Internal field renamed from .map to .fmap; new .imap field added - Source split into types.jl, indexing.jl, broadcast.jl, vector_interface.jl, repeat.jl, circshift.jl, reverse.jl, mapped_ref.jl New features: - PeriodicArray now accepts an explicit imap for setindex! (defaults to NegatedShiftMap(fmap) when omitted, preserving previous behaviour) - New MappedRef type and mapped_ref(arr, I...) for lazy mutable out-of-bounds element access, fixing iterated mutation (e.g. x[i][j] = v) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 31 ++- Project.toml | 2 +- README.md | 60 ++++-- src/PeriodicArrays.jl | 357 +------------------------------ src/broadcast.jl | 24 +++ src/circshift.jl | 44 ++++ src/indexing.jl | 66 ++++++ src/mapped_ref.jl | 59 +++++ src/repeat.jl | 43 ++++ src/reverse.jl | 27 +++ src/types.jl | 126 +++++++++++ src/vector_interface.jl | 22 ++ test/Project.toml | 2 - test/test_basics.jl | 2 +- test/test_nontrivial_boundary.jl | 2 +- 15 files changed, 497 insertions(+), 370 deletions(-) create mode 100644 src/broadcast.jl create mode 100644 src/circshift.jl create mode 100644 src/indexing.jl create mode 100644 src/mapped_ref.jl create mode 100644 src/repeat.jl create mode 100644 src/reverse.jl create mode 100644 src/types.jl create mode 100644 src/vector_interface.jl diff --git a/CHANGELOG.md b/CHANGELOG.md index 751d2d4..8bccd5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,4 +3,33 @@ 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.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). \ No newline at end of file +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [2.0.0] - 2026-04-02 + +### Breaking + +- `PeriodicArray` gained a fifth type parameter `G` for the inverse map (`imap`). + The type signature changed from `PeriodicArray{T,N,A,F}` to + `PeriodicArray{T,N,A,F,G}`. Any code with explicit type annotations, + dispatch rules, or type introspection on the four-parameter form will need + to be updated. +- The internal field previously named `.map` is now named `.fmap`; a new field + `.imap` holds the inverse map. Direct field access must be updated accordingly. +- The module was reorganised into separate source files + (`types.jl`, `indexing.jl`, `broadcast.jl`, `vector_interface.jl`, + `repeat.jl`, `circshift.jl`, `reverse.jl`, `mapped_ref.jl`). + +### Added + +- `PeriodicArray` now accepts an explicit `imap` argument (the inverse map used by + `setindex!`). When omitted, `imap` defaults to `NegatedShiftMap(fmap)`, i.e. + `(x, shifts...) -> fmap(x, -shifts...)`, preserving the previous behaviour. + Supplying a custom `imap` is useful when `fmap` is not self-inverse under shift + negation, or when mutation should be explicitly forbidden (pass an `imap` that throws). +- New `MappedRef` type and `mapped_ref(arr, I...)` function. `mapped_ref` returns a + lazy, mutable wrapper for an out-of-bounds element that applies the forward map on + reads and the inverse map on writes, solving the long-standing limitation where + iterated indexing for mutation silently did nothing (e.g. `x[i][j] = v`). \ No newline at end of file diff --git a/Project.toml b/Project.toml index aa5fad9..5151efe 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "PeriodicArrays" uuid = "343d6138-6384-4525-8bee-38906309ab36" authors = ["Andreas Feuerpfeil "] -version = "1.1.1" +version = "2.0.0" [compat] julia = "1.10" diff --git a/README.md b/README.md index 9fe6fcf..2259b30 100644 --- a/README.md +++ b/README.md @@ -29,20 +29,36 @@ [codestyle-img]: https://img.shields.io/badge/code_style-%E1%9A%B1%E1%9A%A2%E1%9A%BE%E1%9B%81%E1%9A%B2-black [codestyle-url]: https://github.com/fredrikekre/Runic.jl -`PeriodicArrays.jl` adds the `PeriodicArray` type which can be backed by any `AbstractArray`. The idea of this package is based on [`CircularArrays.jl`](https://github.com/Vexatos/CircularArrays.jl) and extends its functionality to support user-defined translation rules for periodic indexing. -A `PeriodicArray{T,N,A,F}` is an `AbstractArray{T,N}` backed by a data array of type `A<:AbstractArray{T,N}` and a map `f` of type `F`. -The map defines how data in out-of-bounds indices is translated to valid indices in the data array. +`PeriodicArrays.jl` adds the `PeriodicArray` type which can be backed by any `AbstractArray`. The idea of this package is based on [`CircularArrays.jl`](https://github.com/Vexatos/CircularArrays.jl) and extends its functionality to support user-defined translation rules for periodic indexing. +A `PeriodicArray{T,N,A,F,G}` is an `AbstractArray{T,N}` backed by a data array of type `A<:AbstractArray{T,N}`, a forward map `fmap` of type `F`, and an inverse map `imap` of type `G`. +The maps define how data at out-of-bounds indices is translated to and from valid indices in the data array. -`f` can be any callable object (e.g. a function or a struct), which defines -```julia -f(x, shift::Vararg{Int,N}) +`fmap` and `imap` can be any callable objects (e.g. functions or structs) that define +```julia +fmap(x, shift::Vararg{Int,N}) ``` -where `x` is an element of the array and shift encodes the unit cell, in which we index. -`f` has to satisfy the following properties, which are not checked at construction time: -- The output type of `f` has to be the same as the element type of the data array. -- `f` is invertible with inverse `f(x, -shift...)`, i.e. it satisfies `f(f(x, shift...), -shift...) == x`. - -If `f` is not provided, the identity map is used and the `PeriodicArray` behaves like a `CircularArray`. +where `x` is an element of the array and `shift` encodes the unit cell in which we index. + +`PeriodicArray` accepts the maps as `PeriodicArray(data, fmap)` or +`PeriodicArray(data, fmap, imap)`. +If neither map is provided, both default to the identity and the array behaves like a `CircularArray`. + +**Constraints on `fmap`** (not checked at construction time): +- The output type of `fmap` must be the same as the element type of the data array. + +**Constraints on `imap`** (the inverse map used by `setindex!`): +- `imap` must satisfy `imap(fmap(x, shift...), shift...) == x` for all valid `x` and + `shift`, so that round-tripping a value through `getindex`/`setindex!` is lossless. +- When `imap` is omitted, it defaults to `(x, shifts...) -> fmap(x, -shifts...)`. + This default is correct whenever `fmap` is self-inverse under shift negation, i.e. + `fmap(fmap(x, s...), -s...) == x`. +- If `fmap` does **not** satisfy the self-inverse property, supply a custom `imap`. + If mutation through out-of-bounds indices should be explicitly forbidden, pass an + `imap` that throws: + ```julia + imap_error(x, shift...) = error("mutation through out-of-bounds indices is not supported") + a = PeriodicArray(data, fmap, imap_error) + ``` This package is compatible with [`OffsetArrays.jl`](https://github.com/JuliaArrays/OffsetArrays.jl). @@ -110,13 +126,27 @@ x[out_of_bounds_index][i, j] = value silently does nothing to `x`. The reason is that `x[out_of_bounds_index]` applies the map and returns a *new, transformed copy* of the element; the subsequent assignment mutates only that temporary object, not the underlying data. For in-bounds indices the element is returned by reference and mutation works as expected. -As a workaround, operate directly on the underlying data: + +**Recommended workaround — `mapped_ref`:** + +```julia +ref = mapped_ref(x, out_of_bounds_index) +ref[i, j] = value # applies imap and writes back into parent(x) +``` + +`mapped_ref` returns a `MappedRef`: a lazy wrapper that applies the forward map on reads +and the inverse map on writes, so no temporary copy is created and the mutation propagates +correctly into the underlying data. + +**Alternative workarounds** (lower-level): + +Operate directly on the underlying data (bypasses the map entirely): ```julia -parent(x)[mod_index][i, j] = value # bypasses the map entirely +parent(x)[mod_index][i, j] = value ``` -or set the whole element at once (which goes through `setindex!` on `x` and correctly applies the inverse map): +Or set the whole element at once (goes through `setindex!` on `x` and applies `imap`): ```julia tmp = copy(x[out_of_bounds_index]) diff --git a/src/PeriodicArrays.jl b/src/PeriodicArrays.jl index 3f3b79b..68acf0f 100644 --- a/src/PeriodicArrays.jl +++ b/src/PeriodicArrays.jl @@ -5,354 +5,13 @@ module PeriodicArrays export PeriodicArray, PeriodicVector, PeriodicMatrix -identity_map(x, ::Vararg{Any}) = x -const _identity_map_type = typeof(identity_map) - -""" - PeriodicArray{T, N, A, F} <: AbstractArray{T, N} - -`N`-dimensional array backed by an `AbstractArray{T, N}` of type `A` with fixed size -and periodic indexing as defined by `map`. - - array[index...] == map(array[mod1.(index, size)...], fld.(index .- 1, size)...) -""" -struct PeriodicArray{T, N, A <: AbstractArray{T, N}, F} <: AbstractArray{T, N} - data::A - map::F - PeriodicArray{T}(data::A, map::F = identity_map) where {A <: AbstractArray{T, N}, F} where {T, N} = new{T, N, A, F}(data, map) - PeriodicArray{T, N}(data::A, map::F = identity_map) where {A <: AbstractArray{T, N}, F} where {T, N} = new{T, N, A, F}(data, map) - PeriodicArray{T, N, A}(data::A, map::F = identity_map) where {A <: AbstractArray{T, N}, F} where {T, N} = new{T, N, A, F}(data, map) -end - -""" - PeriodicArray(data, [map]) - -Create a `PeriodicArray` backed by `data`. -`map` is optional and defaults to the identity map. -""" -PeriodicArray(data::A, map::F = identity_map) where {A <: AbstractArray{T, N}, F} where {T, N} = PeriodicArray{T, N}(data, map) - - -PeriodicArray(arr::PeriodicArray, map::F = identity_map) where {F} = arr - -""" - PeriodicArray(def, size, [map]) - -Create a `PeriodicArray` of size `size` filled with value `def`. -`map` is optional and defaults to the identity map. -""" -PeriodicArray(def::T, size, map::F = identity_map) where {T, F} = PeriodicArray(fill(def, size), map) - -""" - PeriodicVector{T, A, F} <: AbstractVector{T} - -One-dimensional array backed by an `AbstractArray{T, 1}` of type `A` with fixed size and periodic indexing. -Alias for [`PeriodicArray{T, 1, A, F}`](@ref). - - array[index] == map(array[mod1(index, length)], fld(index - 1, length)) -""" -const PeriodicVector{T} = PeriodicArray{T, 1} - -""" - PeriodicMatrix{T, A, F} <: AbstractMatrix{T} - -Two-dimensional array backed by an `AbstractArray{T, 2}` of type `A` with fixed size and periodic indexing. -Alias for [`PeriodicArray{T, 2, A, F}`](@ref). -""" -const PeriodicMatrix{T} = PeriodicArray{T, 2} - -# Define constructors for PeriodicVector and PeriodicMatrix -PeriodicVector(args...) = PeriodicArray(args...) -PeriodicMatrix(args...) = PeriodicArray(args...) - - -Base.IndexStyle(::Type{PeriodicArray{T, N, A, F}}) where {T, N, A, F} = IndexCartesian() -Base.IndexStyle(::Type{<:PeriodicVector}) = IndexLinear() - -function cell_position(arr::AbstractArray{T, N}, I::Vararg{Integer, N}) where {T, N} - axs = axes(arr) - i_base = ntuple(N) do d - ax = axs[d] - len = length(ax) - lo = firstindex(ax) - # wrap I[d] into the axis range lo:lo+len-1 - lo + mod(I[d] - lo, len) - end - i_shift = ntuple(d -> fld(I[d] - i_base[d], length(axs[d])), N) - return i_base, i_shift -end -function inverse_cell_position(arr::AbstractArray{T, N}, I::Vararg{Integer, N}) where {T, N} - axs = axes(arr) - i_base = ntuple(N) do d - ax = axs[d] - len = length(ax) - lo = firstindex(ax) - # wrap I[d] into the axis range lo:lo+len-1 - lo + mod(I[d] - lo, len) - end - i_shift = ntuple(d -> -fld(I[d] - i_base[d], length(axs[d])), N) - return i_base, i_shift -end - -# Special case for trivial map (identical to CelledArrays.jl) -@inline function Base.getindex( - arr::PeriodicArray{T, N, A, _identity_map_type}, i::Int - ) where {A <: AbstractArray{T, N}} where {T, N} - return @inbounds getindex(parent(arr), mod(i, eachindex(IndexLinear(), parent(arr)))) -end -@inline function Base.setindex!( - arr::PeriodicArray{T, N, A, _identity_map_type}, v, i::Int - ) where {A <: AbstractArray{T, N}} where {T, N} - return @inbounds setindex!(parent(arr), v, mod(i, eachindex(IndexLinear(), parent(arr)))) -end - -@inline function Base.getindex( - arr::PeriodicArray{T, N, A, F}, I::Vararg{Int, N} - ) where {T, N, A, F} - i_base, i_shift = cell_position(arr, I...) - - @inbounds v = getindex(parent(arr), i_base...) - all(iszero, i_shift) && return v - return arr.map(v, i_shift...) -end -@inline function Base.setindex!( - arr::PeriodicArray{T, N, A, F}, v, I::Vararg{Int, N} - ) where {T, N, A, F} - i_base, i_shift = inverse_cell_position(arr, I...) - - all(iszero, i_shift) && return @inbounds setindex!(parent(arr), v, i_base...) - return @inbounds setindex!(parent(arr), arr.map(v, i_shift...), i_base...) -end - -# Linear indexing is not well-defined outside of the first unit-cell -function Base.getindex( - arr::PeriodicArray{T, N, A, F}, i::Int - ) where {A <: AbstractArray{T, N}, F} where {T, N} - if Base.checkbounds(Bool, parent(arr), i) - return @inbounds getindex(parent(arr), i) - end - throw(BoundsError(arr, i)) -end -function Base.setindex!( - arr::PeriodicArray{T, N, A, F}, v, i::Int - ) where {A <: AbstractArray{T, N}, F} where {T, N} - if Base.checkbounds(Bool, parent(arr), i) - return @inbounds setindex!(parent(arr), v, i) - end - throw(BoundsError(arr, i)) -end - -@inline Base.size(arr::PeriodicArray) = size(arr.data) -@inline Base.axes(arr::PeriodicArray) = axes(arr.data) -@inline Base.parent(arr::PeriodicArray) = arr.data - -@inline Base.iterate(arr::PeriodicArray, i...) = iterate(parent(arr), i...) - -@inline Base.in(x, arr::PeriodicArray) = in(x, parent(arr)) -@inline Base.copy(arr::PeriodicArray) = PeriodicArray(copy(parent(arr)), arr.map) - -@inline function Base.checkbounds(arr::PeriodicArray, I...) - J = Base.to_indices(arr, I) - length(J) == 1 || length(J) >= ndims(arr) || throw(BoundsError(arr, I)) - return nothing -end - -@inline function _similar(arr::PeriodicArray, ::Type{T}, dims) where {T} - return PeriodicArray(similar(parent(arr), T, dims), arr.map) -end -@inline function Base.similar( - arr::PeriodicArray, ::Type{T}, dims::Tuple{Base.DimOrInd, Vararg{Base.DimOrInd}} - ) where {T} - return _similar(arr, T, dims) -end -# Ambiguity resolution with Base -@inline function Base.similar(arr::PeriodicArray, ::Type{T}, dims::Dims) where {T} - return _similar(arr, T, dims) -end -@inline function Base.similar( - arr::PeriodicArray, ::Type{T}, dims::Tuple{Integer, Vararg{Integer}} - ) where {T} - return _similar(arr, T, dims) -end -@inline function Base.similar( - arr::PeriodicArray, ::Type{T}, - dims::Tuple{Union{Integer, Base.OneTo}, Vararg{Union{Integer, Base.OneTo}}} - ) where {T} - return _similar(arr, T, dims) -end - -struct PeriodicArrayStyle{N} <: Broadcast.AbstractArrayStyle{N} end -PeriodicArrayStyle{N}(::Val{M}) where {N, M} = PeriodicArrayStyle{M}() - -Broadcast.BroadcastStyle(::Type{<:PeriodicArray{T, N}}) where {T, N} = PeriodicArrayStyle{N}() -Broadcast.BroadcastStyle(::PeriodicArrayStyle{M}, ::PeriodicArrayStyle{N}) where {M, N} = PeriodicArrayStyle{max(M, N)}() -Broadcast.BroadcastStyle(::PeriodicArrayStyle{M}, ::Broadcast.DefaultArrayStyle{N}) where {M, N} = PeriodicArrayStyle{max(M, N)}() -Broadcast.BroadcastStyle(::Broadcast.DefaultArrayStyle{N}, ::PeriodicArrayStyle{M}) where {N, M} = PeriodicArrayStyle{max(N, M)}() - -_find_pa(bc::Broadcast.Broadcasted) = _find_pa(bc.args...) -_find_pa(a::Broadcast.Extruded, rest...) = _find_pa(a.x, rest...) -_find_pa() = nothing -_find_pa(a::PeriodicArray, rest...) = a -_find_pa(a::Broadcast.Broadcasted, rest...) = -let r = _find_pa(a) - r !== nothing ? r : _find_pa(rest...) -end -_find_pa(::Any, rest...) = _find_pa(rest...) - -@inline function Base.similar( - bc::Broadcast.Broadcasted{PeriodicArrayStyle{N}}, ::Type{ElType} - ) where {N, ElType} - pa = _find_pa(bc) - return PeriodicArray(similar(Array{ElType, N}, axes(bc)), pa.map) -end - -@inline Base.dataids(arr::PeriodicArray) = Base.dataids(parent(arr)) - -function Base.showarg(io::IO, arr::PeriodicArray, toplevel) - print(io, ndims(arr) == 1 ? "PeriodicVector(" : "PeriodicArray(") - Base.showarg(io, parent(arr), false) - return print(io, ')') - # toplevel && print(io, " with eltype ", eltype(arr)) -end - - -Base.empty(a::PeriodicVector{T}, ::Type{U} = T) where {T, U} = PeriodicVector{U}(U[], a.map) -Base.empty!(a::PeriodicVector) = (empty!(parent(a)); a) -Base.push!(a::PeriodicVector, x...) = (push!(parent(a), x...); a) -Base.append!(a::PeriodicVector, items) = (append!(parent(a), items); a) -Base.resize!(a::PeriodicVector, nl::Integer) = (resize!(parent(a), nl); a) -Base.pop!(a::PeriodicVector) = pop!(parent(a)) -Base.sizehint!(a::PeriodicVector, sz::Integer) = (sizehint!(parent(a), sz); a) - -function Base.deleteat!(a::PeriodicVector, i::Integer) - deleteat!(parent(a), mod(i, eachindex(IndexLinear(), parent(a)))) - return a -end - -function Base.deleteat!(a::PeriodicVector, inds) - deleteat!(parent(a), sort!(unique(map(i -> mod(i, eachindex(IndexLinear(), parent(a))), inds)))) - return a -end - -function Base.insert!(a::PeriodicVector, i::Integer, item) - insert!(parent(a), mod(i, eachindex(IndexLinear(), parent(a))), item) - return a -end - -function Base.repeat(A::PeriodicArray{T, N}; inner = nothing, outer = nothing) where {T, N} - map = A.map - # If no outer repetition is requested, just repeat the parent array as usual - A_new = repeat(parent(A); inner = inner) - - if !isnothing(outer) - # allow passing a single integer or a tuple/ntuple for per-dimension repeats - if isa(outer, Number) - outer = ntuple(i -> Int(outer), N) - else - outer = ntuple(i -> Int(outer[i]), N) - end - - # if `inner` was provided, A_new already contains the repeated parent - base = A_new - axs = axes(base) - ps = size(base) - newsize = ntuple(i -> ps[i] * outer[i], N) - - # create a tiled parent filled with translated values from `map` - A_tiled = similar(base, newsize) - tile_ranges = ntuple(i -> 0:(outer[i] - 1), N) - for tile in CartesianIndices(tile_ranges) - shifts = Tuple(Int(tile[i]) for i in 1:N) - for pos in CartesianIndices(base) - tgt = ntuple(i -> tile[i] * ps[i] + (pos[i] - firstindex(axs[i]) + 1), N) - @inbounds A_tiled[tgt...] = map(base[pos], shifts...) - end - end - - @inline function map_new(x, shift::Vararg{Integer, N}) - # shifts passed to this map refer to super-cell shifts; amplify - # by `outer` to convert them to original unit-cell shifts. - amplified = ntuple(i -> shift[i] * outer[i], N) - return map(x, amplified...) - end - - return PeriodicArray(A_tiled, map_new) - end - - return PeriodicArray(A_new, map) -end - -_circshift_amounts(::Val{N}, s::Integer) where {N} = ntuple(d -> d == 1 ? Int(s) : 0, N) -_circshift_amounts(::Val{N}, s) where {N} = ntuple(d -> d <= length(s) ? Int(s[d]) : 0, N) - -function _circshift_pa!( - dest::PeriodicArray{T, N}, src::PeriodicArray{T, N}, shifts - ) where {T, N} - s = _circshift_amounts(Val(N), shifts) - src_data = parent(src) - dest_data = parent(dest) - for k in CartesianIndices(dest_data) - i = ntuple(d -> k[d] - s[d], N) - i_base, i_shift = cell_position(src_data, i...) - v = src_data[i_base...] - dest_data[k] = src.map(v, i_shift...) - end - return dest -end - -# circshift: multiple signatures to disambiguate from Base methods -Base.circshift(arr::PeriodicArray{T, N}, shifts::NTuple{M, Integer}) where {T, N, M} = - _circshift_pa!(similar(arr), arr, shifts) -Base.circshift(arr::PeriodicArray{T, N}, shift::Real) where {T, N} = - _circshift_pa!(similar(arr), arr, shift) -Base.circshift(arr::PeriodicArray{T, N}, shifts::AbstractVector{<:Integer}) where {T, N} = - _circshift_pa!(similar(arr), arr, shifts) - -# circshift! 2-arg (in-place) -function Base.circshift!(arr::PeriodicArray{T, N}, shifts) where {T, N} - src = PeriodicArray(copy(parent(arr)), arr.map) - return _circshift_pa!(arr, src, shifts) -end -# disambiguate with Base.circshift!(::AbstractVector, ::Integer) -Base.circshift!(arr::PeriodicVector, shift::Integer) = circshift!(arr, (shift,)) - -# circshift! 3-arg: specific shift types to disambiguate from Base methods -Base.circshift!( - dest::PeriodicArray{T, N}, src::PeriodicArray{T, N}, shifts::NTuple{M, Integer} -) where {T, N, M} = _circshift_pa!(dest, src, shifts) -Base.circshift!( - dest::PeriodicArray{T, N}, src::PeriodicArray{T, N}, ::Tuple{} -) where {T, N} = _circshift_pa!(dest, src, ()) -Base.circshift!( - dest::PeriodicArray{T, N}, src::PeriodicArray{T, N}, shifts::AbstractVector{<:Integer} -) where {T, N} = _circshift_pa!(dest, src, shifts) - -function Base.reverse(arr::PeriodicArray{T, N, A, F}; dims = :) where {T, N, A, F} - dims == Colon() && return _reverse(arr) - return _reverse(arr, dims) -end - -function _reverse(arr::PeriodicArray{T, N, A, F}) where {T, N, A, F} - base = reverse(parent(arr)) - - @inline function map_rev(x, shifts::Vararg{Integer, N}) - neg = ntuple(i -> -shifts[i], N) - return arr.map(x, neg...) - end - - return PeriodicArray(base, map_rev) -end - -function _reverse(arr::PeriodicArray{T, N, A, F}, dims...) where {T, N, A, F} - base = reverse(parent(arr); dims = dims) - dimsset = Set(dims) - - @inline function map_rev(x, shifts::Vararg{Integer, N}) - adj = ntuple(i -> (i in dimsset) ? -shifts[i] : shifts[i], N) - return arr.map(x, adj...) - end - - return PeriodicArray(base, map_rev) -end +include("types.jl") +include("indexing.jl") +include("broadcast.jl") +include("vector_interface.jl") +include("repeat.jl") +include("circshift.jl") +include("reverse.jl") +include("mapped_ref.jl") end diff --git a/src/broadcast.jl b/src/broadcast.jl new file mode 100644 index 0000000..7df9003 --- /dev/null +++ b/src/broadcast.jl @@ -0,0 +1,24 @@ +struct PeriodicArrayStyle{N} <: Broadcast.AbstractArrayStyle{N} end +PeriodicArrayStyle{N}(::Val{M}) where {N, M} = PeriodicArrayStyle{M}() + +Broadcast.BroadcastStyle(::Type{<:PeriodicArray{T, N}}) where {T, N} = PeriodicArrayStyle{N}() +Broadcast.BroadcastStyle(::PeriodicArrayStyle{M}, ::PeriodicArrayStyle{N}) where {M, N} = PeriodicArrayStyle{max(M, N)}() +Broadcast.BroadcastStyle(::PeriodicArrayStyle{M}, ::Broadcast.DefaultArrayStyle{N}) where {M, N} = PeriodicArrayStyle{max(M, N)}() +Broadcast.BroadcastStyle(::Broadcast.DefaultArrayStyle{N}, ::PeriodicArrayStyle{M}) where {N, M} = PeriodicArrayStyle{max(N, M)}() + +_find_pa(bc::Broadcast.Broadcasted) = _find_pa(bc.args...) +_find_pa(a::Broadcast.Extruded, rest...) = _find_pa(a.x, rest...) +_find_pa() = nothing +_find_pa(a::PeriodicArray, rest...) = a +_find_pa(a::Broadcast.Broadcasted, rest...) = +let r = _find_pa(a) + r !== nothing ? r : _find_pa(rest...) +end +_find_pa(::Any, rest...) = _find_pa(rest...) + +@inline function Base.similar( + bc::Broadcast.Broadcasted{PeriodicArrayStyle{N}}, ::Type{ElType} + ) where {N, ElType} + pa = _find_pa(bc) + return PeriodicArray(similar(Array{ElType, N}, axes(bc)), pa.map, pa.imap) +end diff --git a/src/circshift.jl b/src/circshift.jl new file mode 100644 index 0000000..b08e7b4 --- /dev/null +++ b/src/circshift.jl @@ -0,0 +1,44 @@ +_circshift_amounts(::Val{N}, s::Integer) where {N} = ntuple(d -> d == 1 ? Int(s) : 0, N) +_circshift_amounts(::Val{N}, s) where {N} = ntuple(d -> d <= length(s) ? Int(s[d]) : 0, N) + +function _circshift_pa!( + dest::PeriodicArray{T, N}, src::PeriodicArray{T, N}, shifts + ) where {T, N} + s = _circshift_amounts(Val(N), shifts) + src_data = parent(src) + dest_data = parent(dest) + for k in CartesianIndices(dest_data) + i = ntuple(d -> k[d] - s[d], N) + i_base, i_shift = cell_position(src_data, i...) + v = src_data[i_base...] + dest_data[k] = src.map(v, i_shift...) + end + return dest +end + +# circshift: multiple signatures to disambiguate from Base methods +Base.circshift(arr::PeriodicArray{T, N}, shifts::NTuple{M, Integer}) where {T, N, M} = + _circshift_pa!(similar(arr), arr, shifts) +Base.circshift(arr::PeriodicArray{T, N}, shift::Real) where {T, N} = + _circshift_pa!(similar(arr), arr, shift) +Base.circshift(arr::PeriodicArray{T, N}, shifts::AbstractVector{<:Integer}) where {T, N} = + _circshift_pa!(similar(arr), arr, shifts) + +# circshift! 2-arg (in-place) +function Base.circshift!(arr::PeriodicArray{T, N}, shifts) where {T, N} + src = PeriodicArray(copy(parent(arr)), arr.map, arr.imap) + return _circshift_pa!(arr, src, shifts) +end +# disambiguate with Base.circshift!(::AbstractVector, ::Integer) +Base.circshift!(arr::PeriodicVector, shift::Integer) = circshift!(arr, (shift,)) + +# circshift! 3-arg: specific shift types to disambiguate from Base methods +Base.circshift!( + dest::PeriodicArray{T, N}, src::PeriodicArray{T, N}, shifts::NTuple{M, Integer} +) where {T, N, M} = _circshift_pa!(dest, src, shifts) +Base.circshift!( + dest::PeriodicArray{T, N}, src::PeriodicArray{T, N}, ::Tuple{} +) where {T, N} = _circshift_pa!(dest, src, ()) +Base.circshift!( + dest::PeriodicArray{T, N}, src::PeriodicArray{T, N}, shifts::AbstractVector{<:Integer} +) where {T, N} = _circshift_pa!(dest, src, shifts) diff --git a/src/indexing.jl b/src/indexing.jl new file mode 100644 index 0000000..0748344 --- /dev/null +++ b/src/indexing.jl @@ -0,0 +1,66 @@ +function cell_position(arr::AbstractArray{T, N}, I::Vararg{Integer, N}) where {T, N} + axs = axes(arr) + i_base = ntuple(N) do d + ax = axs[d] + len = length(ax) + lo = firstindex(ax) + # wrap I[d] into the axis range lo:lo+len-1 + lo + mod(I[d] - lo, len) + end + i_shift = ntuple(d -> fld(I[d] - i_base[d], length(axs[d])), N) + return i_base, i_shift +end + +# Special case for trivial map (identical to CelledArrays.jl) +@inline function Base.getindex( + arr::PeriodicArray{T, N, A, _identity_map_type, _identity_map_type}, i::Int + ) where {A <: AbstractArray{T, N}} where {T, N} + return @inbounds getindex(parent(arr), mod(i, eachindex(IndexLinear(), parent(arr)))) +end +@inline function Base.setindex!( + arr::PeriodicArray{T, N, A, _identity_map_type, _identity_map_type}, v, i::Int + ) where {A <: AbstractArray{T, N}} where {T, N} + return @inbounds setindex!(parent(arr), v, mod(i, eachindex(IndexLinear(), parent(arr)))) +end + +@inline function Base.getindex( + arr::PeriodicArray{T, N, A, F, G}, I::Vararg{Int, N} + ) where {T, N, A, F, G} + i_base, i_shift = cell_position(arr, I...) + + @inbounds v = getindex(parent(arr), i_base...) + all(iszero, i_shift) && return v + return arr.map(v, i_shift...) +end +@inline function Base.setindex!( + arr::PeriodicArray{T, N, A, F, G}, v, I::Vararg{Int, N} + ) where {T, N, A, F, G} + i_base, i_shift = cell_position(arr, I...) + + all(iszero, i_shift) && return @inbounds setindex!(parent(arr), v, i_base...) + return @inbounds setindex!(parent(arr), arr.imap(v, i_shift...), i_base...) +end + +# Linear indexing is not well-defined outside of the first unit-cell +function Base.getindex( + arr::PeriodicArray{T, N, A, F, G}, i::Int + ) where {T, N, A <: AbstractArray{T, N}, F, G} + if Base.checkbounds(Bool, parent(arr), i) + return @inbounds getindex(parent(arr), i) + end + throw(BoundsError(arr, i)) +end +function Base.setindex!( + arr::PeriodicArray{T, N, A, F, G}, v, i::Int + ) where {T, N, A <: AbstractArray{T, N}, F, G} + if Base.checkbounds(Bool, parent(arr), i) + return @inbounds setindex!(parent(arr), v, i) + end + throw(BoundsError(arr, i)) +end + +@inline function Base.checkbounds(arr::PeriodicArray, I...) + J = Base.to_indices(arr, I) + length(J) == 1 || length(J) >= ndims(arr) || throw(BoundsError(arr, I)) + return nothing +end diff --git a/src/mapped_ref.jl b/src/mapped_ref.jl new file mode 100644 index 0000000..85b5ab3 --- /dev/null +++ b/src/mapped_ref.jl @@ -0,0 +1,59 @@ +""" + MappedRef{E, N, T, S, F, G} <: AbstractArray{E, N} + +A mutable view over an element of a `PeriodicArray` that applies forward/inverse maps +lazily on element access and mutation. + +- `ref[I...]` returns `fmap(parent_element[I...], shift...)`. +- `ref[I...] = val` stores `imap(val, shift...)` back into `parent_element[I...]`, + propagating the mutation to the underlying `PeriodicArray` without any copying. + +Obtain a `MappedRef` via [`mapped_ref`](@ref) rather than constructing it directly. +""" +struct MappedRef{E, N, T <: AbstractArray{E, N}, S <: Tuple, F, G} <: AbstractArray{E, N} + ref::T # direct reference into parent(arr) — NOT a copy + shift::S # original periodic shift (same sign convention as PeriodicArray.map) + fmap::F # forward map: (scalar, shift...) -> mapped_scalar + imap::G # inverse map: (val, shift...) -> original_val +end + +Base.size(r::MappedRef) = size(r.ref) +Base.IndexStyle(::Type{<:MappedRef}) = IndexCartesian() +Base.parent(r::MappedRef) = r.ref + +@inline function Base.getindex(r::MappedRef{E, N}, I::Vararg{Int, N}) where {E, N} + return r.fmap(@inbounds(r.ref[I...]), r.shift...) +end + +@inline function Base.setindex!(r::MappedRef{E, N}, val, I::Vararg{Int, N}) where {E, N} + @inbounds r.ref[I...] = r.imap(val, r.shift...) + return val +end + +""" + mapped_ref(arr::PeriodicArray{T}, I...) -> MappedRef or element reference + +Return a lazy mutable wrapper for the element at periodic index `I...` in `arr`. + +For in-bounds indices (zero shift) the raw element is returned directly, so normal +Julia mutation semantics apply. For out-of-bounds (wrapped) indices a `MappedRef` is +returned: reading through it applies `arr.map` element-wise; writing through it applies +`arr.imap` element-wise and stores the result back into the underlying data. + +# Example +```julia +x = PeriodicVector([zeros(3, 3), zeros(3, 3)], (v, s) -> v .+ s) +# imap defaults to NegatedShiftMap: (v, s) -> v .- s + +ref = mapped_ref(x, 3) # wraps parent(x)[1] with shift = (1,) +ref[1, 1] = 100.0 # → parent(x)[1][1, 1] = imap(100, 1) = 99 +x[1][1, 1] # → 99.0 +x[3][1, 1] # → 100.0 (standard getindex still correct) +``` +""" +function mapped_ref(arr::PeriodicArray{T, N}, I::Vararg{Int, N}) where {T <: AbstractArray, N} + i_base, i_shift = cell_position(arr, I...) + ref = @inbounds parent(arr)[i_base...] + all(iszero, i_shift) && return ref + return MappedRef(ref, i_shift, arr.map, arr.imap) +end diff --git a/src/repeat.jl b/src/repeat.jl new file mode 100644 index 0000000..8c67718 --- /dev/null +++ b/src/repeat.jl @@ -0,0 +1,43 @@ +function Base.repeat(A::PeriodicArray{T, N}; inner = nothing, outer = nothing) where {T, N} + map = A.map + # If no outer repetition is requested, just repeat the parent array as usual + A_new = repeat(parent(A); inner = inner) + + if !isnothing(outer) + # allow passing a single integer or a tuple/ntuple for per-dimension repeats + if isa(outer, Number) + outer = ntuple(i -> Int(outer), N) + else + outer = ntuple(i -> Int(outer[i]), N) + end + + # if `inner` was provided, A_new already contains the repeated parent + base = A_new + axs = axes(base) + ps = size(base) + newsize = ntuple(i -> ps[i] * outer[i], N) + + # create a tiled parent filled with translated values from `map` + A_tiled = similar(base, newsize) + tile_ranges = ntuple(i -> 0:(outer[i] - 1), N) + for tile in CartesianIndices(tile_ranges) + shifts = Tuple(Int(tile[i]) for i in 1:N) + for pos in CartesianIndices(base) + tgt = ntuple(i -> tile[i] * ps[i] + (pos[i] - firstindex(axs[i]) + 1), N) + @inbounds A_tiled[tgt...] = map(base[pos], shifts...) + end + end + + @inline function map_new(x, shift::Vararg{Integer, N}) + # shifts passed to this map refer to super-cell shifts; amplify + # by `outer` to convert them to original unit-cell shifts. + amplified = ntuple(i -> shift[i] * outer[i], N) + return map(x, amplified...) + end + + imap_new = NegatedShiftMap(map_new) + return PeriodicArray(A_tiled, map_new, imap_new) + end + + return PeriodicArray(A_new, map, A.imap) +end diff --git a/src/reverse.jl b/src/reverse.jl new file mode 100644 index 0000000..cf02f85 --- /dev/null +++ b/src/reverse.jl @@ -0,0 +1,27 @@ +function Base.reverse(arr::PeriodicArray{T, N, A, F}; dims = :) where {T, N, A, F} + dims == Colon() && return _reverse(arr) + return _reverse(arr, dims) +end + +function _reverse(arr::PeriodicArray{T, N, A, F}) where {T, N, A, F} + base = reverse(parent(arr)) + + @inline function map_rev(x, shifts::Vararg{Integer, N}) + neg = ntuple(i -> -shifts[i], N) + return arr.map(x, neg...) + end + + return PeriodicArray(base, map_rev) +end + +function _reverse(arr::PeriodicArray{T, N, A, F}, dims...) where {T, N, A, F} + base = reverse(parent(arr); dims = dims) + dimsset = Set(dims) + + @inline function map_rev(x, shifts::Vararg{Integer, N}) + adj = ntuple(i -> (i in dimsset) ? -shifts[i] : shifts[i], N) + return arr.map(x, adj...) + end + + return PeriodicArray(base, map_rev) +end diff --git a/src/types.jl b/src/types.jl new file mode 100644 index 0000000..f916c47 --- /dev/null +++ b/src/types.jl @@ -0,0 +1,126 @@ +identity_map(x, ::Vararg{Any}) = x +const _identity_map_type = typeof(identity_map) + +struct NegatedShiftMap{F} + map::F +end +(m::NegatedShiftMap)(x, shifts::Vararg{Integer}) = m.map(x, ntuple(i -> -shifts[i], length(shifts))...) + +""" + PeriodicArray{T, N, A, F, G} <: AbstractArray{T, N} + +`N`-dimensional array backed by an `AbstractArray{T, N}` of type `A` with fixed size +and periodic indexing as defined by `map` and `imap`. + + array[index...] == map(array[mod1.(index, size)...], fld.(index .- 1, size)...) + +`imap` is the inverse map used for `setindex!`, defaulting to `map` with negated shifts. +""" +struct PeriodicArray{T, N, A <: AbstractArray{T, N}, F, G} <: AbstractArray{T, N} + data::A + map::F + imap::G + function PeriodicArray{T}(data::A, map::F, imap::G) where {A <: AbstractArray{T, N}, F, G} where {T, N} + new{T, N, A, F, G}(data, map, imap) + end + function PeriodicArray{T, N}(data::A, map::F, imap::G) where {A <: AbstractArray{T, N}, F, G} where {T, N} + new{T, N, A, F, G}(data, map, imap) + end + function PeriodicArray{T, N, A}(data::A, map::F, imap::G) where {A <: AbstractArray{T, N}, F, G} where {T, N} + new{T, N, A, F, G}(data, map, imap) + end +end + +PeriodicArray{T}(data::A, map::F = identity_map) where {A <: AbstractArray{T, N}, F} where {T, N} = + PeriodicArray{T}(data, map, _default_imap(map)) +PeriodicArray{T, N}(data::A, map::F = identity_map) where {A <: AbstractArray{T, N}, F} where {T, N} = + PeriodicArray{T, N}(data, map, _default_imap(map)) +PeriodicArray{T, N, A}(data::A, map::F = identity_map) where {A <: AbstractArray{T, N}, F} where {T, N} = + PeriodicArray{T, N, A}(data, map, _default_imap(map)) + +_default_imap(map::_identity_map_type) = map +_default_imap(map) = NegatedShiftMap(map) + +""" + PeriodicArray(data, [map, [imap]]) + +Create a `PeriodicArray` backed by `data`. +`map` defaults to the identity map. `imap` defaults to `map` with negated shifts. +""" +PeriodicArray(data::A, map::F = identity_map, imap::G = _default_imap(map)) where {A <: AbstractArray{T, N}, F, G} where {T, N} = PeriodicArray{T, N}(data, map, imap) + +PeriodicArray(arr::PeriodicArray, map::F = identity_map) where {F} = arr + +""" + PeriodicArray(def, size, [map]) + +Create a `PeriodicArray` of size `size` filled with value `def`. +`map` is optional and defaults to the identity map. +""" +PeriodicArray(def::T, size, map::F = identity_map) where {T, F} = PeriodicArray(fill(def, size), map) + +""" + PeriodicVector{T, A, F, G} <: AbstractVector{T} + +One-dimensional array backed by an `AbstractArray{T, 1}` of type `A` with fixed size and periodic indexing. +Alias for [`PeriodicArray{T, 1, A, F, G}`](@ref). + + array[index] == map(array[mod1(index, length)], fld(index - 1, length)) +""" +const PeriodicVector{T} = PeriodicArray{T, 1} + +""" + PeriodicMatrix{T, A, F, G} <: AbstractMatrix{T} + +Two-dimensional array backed by an `AbstractArray{T, 2}` of type `A` with fixed size and periodic indexing. +Alias for [`PeriodicArray{T, 2, A, F, G}`](@ref). +""" +const PeriodicMatrix{T} = PeriodicArray{T, 2} + +# Define constructors for PeriodicVector and PeriodicMatrix +PeriodicVector(args...) = PeriodicArray(args...) +PeriodicMatrix(args...) = PeriodicArray(args...) + +Base.IndexStyle(::Type{PeriodicArray{T, N, A, F, G}}) where {T, N, A, F, G} = IndexCartesian() +Base.IndexStyle(::Type{<:PeriodicVector}) = IndexLinear() + +@inline Base.size(arr::PeriodicArray) = size(arr.data) +@inline Base.axes(arr::PeriodicArray) = axes(arr.data) +@inline Base.parent(arr::PeriodicArray) = arr.data + +@inline Base.iterate(arr::PeriodicArray, i...) = iterate(parent(arr), i...) + +@inline Base.in(x, arr::PeriodicArray) = in(x, parent(arr)) +@inline Base.copy(arr::PeriodicArray) = PeriodicArray(copy(parent(arr)), arr.map, arr.imap) + +@inline Base.dataids(arr::PeriodicArray) = Base.dataids(parent(arr)) + +function Base.showarg(io::IO, arr::PeriodicArray, toplevel) + print(io, ndims(arr) == 1 ? "PeriodicVector(" : "PeriodicArray(") + Base.showarg(io, parent(arr), false) + return print(io, ')') +end + +@inline function _similar(arr::PeriodicArray, ::Type{T}, dims) where {T} + return PeriodicArray(similar(parent(arr), T, dims), arr.map, arr.imap) +end +@inline function Base.similar( + arr::PeriodicArray, ::Type{T}, dims::Tuple{Base.DimOrInd, Vararg{Base.DimOrInd}} + ) where {T} + return _similar(arr, T, dims) +end +# Ambiguity resolution with Base +@inline function Base.similar(arr::PeriodicArray, ::Type{T}, dims::Dims) where {T} + return _similar(arr, T, dims) +end +@inline function Base.similar( + arr::PeriodicArray, ::Type{T}, dims::Tuple{Integer, Vararg{Integer}} + ) where {T} + return _similar(arr, T, dims) +end +@inline function Base.similar( + arr::PeriodicArray, ::Type{T}, + dims::Tuple{Union{Integer, Base.OneTo}, Vararg{Union{Integer, Base.OneTo}}} + ) where {T} + return _similar(arr, T, dims) +end diff --git a/src/vector_interface.jl b/src/vector_interface.jl new file mode 100644 index 0000000..e2842b8 --- /dev/null +++ b/src/vector_interface.jl @@ -0,0 +1,22 @@ +Base.empty(a::PeriodicVector{T}, ::Type{U} = T) where {T, U} = PeriodicVector{U}(U[], a.map, a.imap) +Base.empty!(a::PeriodicVector) = (empty!(parent(a)); a) +Base.push!(a::PeriodicVector, x...) = (push!(parent(a), x...); a) +Base.append!(a::PeriodicVector, items) = (append!(parent(a), items); a) +Base.resize!(a::PeriodicVector, nl::Integer) = (resize!(parent(a), nl); a) +Base.pop!(a::PeriodicVector) = pop!(parent(a)) +Base.sizehint!(a::PeriodicVector, sz::Integer) = (sizehint!(parent(a), sz); a) + +function Base.deleteat!(a::PeriodicVector, i::Integer) + deleteat!(parent(a), mod(i, eachindex(IndexLinear(), parent(a)))) + return a +end + +function Base.deleteat!(a::PeriodicVector, inds) + deleteat!(parent(a), sort!(unique(map(i -> mod(i, eachindex(IndexLinear(), parent(a))), inds)))) + return a +end + +function Base.insert!(a::PeriodicVector, i::Integer, item) + insert!(parent(a), mod(i, eachindex(IndexLinear(), parent(a))), item) + return a +end diff --git a/test/Project.toml b/test/Project.toml index 33990e3..80e7756 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,7 +1,6 @@ [deps] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" OffsetArrays = "6fe1bfb0-de20-5000-8ca7-80f57d26f881" -PeriodicArrays = "343d6138-6384-4525-8bee-38906309ab36" SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f" Suppressor = "fd094767-a336-5f1f-9728-57cf17d0bbfb" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" @@ -9,7 +8,6 @@ TestExtras = "5ed8adda-3752-4e41-b88a-e8b09835ee3a" [compat] Aqua = "0.8" -PeriodicArrays = "1.0,1.1" SafeTestsets = "0.1" Suppressor = "0.2" Test = "1.10" diff --git a/test/test_basics.jl b/test/test_basics.jl index 48eab61..dd471a6 100644 --- a/test/test_basics.jl +++ b/test/test_basics.jl @@ -71,7 +71,7 @@ end @test size(v1, 1) == 5 @test parent(v1) == data - @test typeof(v1) == PeriodicVector{Int64, Vector{Int64}, typeof(PeriodicArrays.identity_map)} + @test typeof(v1) == PeriodicVector{Int64, Vector{Int64}, typeof(PeriodicArrays.identity_map), typeof(PeriodicArrays.identity_map)} @test isa(v1, PeriodicVector) @test isa(v1, AbstractVector{Int}) @test !isa(v1, AbstractVector{String}) diff --git a/test/test_nontrivial_boundary.jl b/test/test_nontrivial_boundary.jl index 52c82f8..1a06f90 100644 --- a/test/test_nontrivial_boundary.jl +++ b/test/test_nontrivial_boundary.jl @@ -73,7 +73,7 @@ for f in translation_functions @test size(v1, 1) == 5 @test parent(v1) == data - @test typeof(v1) == PeriodicVector{Int64, Vector{Int64}, typeof(f)} + @test typeof(v1) == PeriodicVector{Int64, Vector{Int64}, typeof(f), PeriodicArrays.NegatedShiftMap{typeof(f)}} @test isa(v1, PeriodicVector) @test isa(v1, AbstractVector{Int}) @test !isa(v1, AbstractVector{String}) From 0035280667982c4844830464a933198da1791990 Mon Sep 17 00:00:00 2001 From: AFeuerpfeil Date: Thu, 2 Apr 2026 11:28:51 -0400 Subject: [PATCH 2/6] format --- src/indexing.jl | 4 ++-- src/types.jl | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/indexing.jl b/src/indexing.jl index 0748344..9425512 100644 --- a/src/indexing.jl +++ b/src/indexing.jl @@ -14,12 +14,12 @@ end # Special case for trivial map (identical to CelledArrays.jl) @inline function Base.getindex( arr::PeriodicArray{T, N, A, _identity_map_type, _identity_map_type}, i::Int - ) where {A <: AbstractArray{T, N}} where {T, N} + ) where {A <: AbstractArray{T, N}} where {T, N} return @inbounds getindex(parent(arr), mod(i, eachindex(IndexLinear(), parent(arr)))) end @inline function Base.setindex!( arr::PeriodicArray{T, N, A, _identity_map_type, _identity_map_type}, v, i::Int - ) where {A <: AbstractArray{T, N}} where {T, N} + ) where {A <: AbstractArray{T, N}} where {T, N} return @inbounds setindex!(parent(arr), v, mod(i, eachindex(IndexLinear(), parent(arr)))) end diff --git a/src/types.jl b/src/types.jl index f916c47..e38579c 100644 --- a/src/types.jl +++ b/src/types.jl @@ -21,13 +21,13 @@ struct PeriodicArray{T, N, A <: AbstractArray{T, N}, F, G} <: AbstractArray{T, N map::F imap::G function PeriodicArray{T}(data::A, map::F, imap::G) where {A <: AbstractArray{T, N}, F, G} where {T, N} - new{T, N, A, F, G}(data, map, imap) + return new{T, N, A, F, G}(data, map, imap) end function PeriodicArray{T, N}(data::A, map::F, imap::G) where {A <: AbstractArray{T, N}, F, G} where {T, N} - new{T, N, A, F, G}(data, map, imap) + return new{T, N, A, F, G}(data, map, imap) end function PeriodicArray{T, N, A}(data::A, map::F, imap::G) where {A <: AbstractArray{T, N}, F, G} where {T, N} - new{T, N, A, F, G}(data, map, imap) + return new{T, N, A, F, G}(data, map, imap) end end From 18b1ea4598898287352514757b544dde33e40302 Mon Sep 17 00:00:00 2001 From: AFeuerpfeil Date: Thu, 2 Apr 2026 11:33:50 -0400 Subject: [PATCH 3/6] update readme and struct fieldbames --- README.md | 23 +---------- docs/files/README.jl | 57 +++++++++++++++++++++++----- src/broadcast.jl | 2 +- src/circshift.jl | 4 +- src/indexing.jl | 4 +- src/mapped_ref.jl | 10 ++--- src/repeat.jl | 12 +++--- src/reverse.jl | 4 +- src/types.jl | 65 +++++++++++++++++--------------- src/vector_interface.jl | 4 +- test/test_basics.jl | 6 +-- test/test_nontrivial_boundary.jl | 14 +++---- 12 files changed, 114 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 2259b30..34d1954 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,6 @@ where `x` is an element of the array and `shift` encodes the unit cell in which `PeriodicArray(data, fmap, imap)`. If neither map is provided, both default to the identity and the array behaves like a `CircularArray`. -**Constraints on `fmap`** (not checked at construction time): -- The output type of `fmap` must be the same as the element type of the data array. - **Constraints on `imap`** (the inverse map used by `setindex!`): - `imap` must satisfy `imap(fmap(x, shift...), shift...) == x` for all valid `x` and `shift`, so that round-tripping a value through `getindex`/`setindex!` is lossless. @@ -54,7 +51,7 @@ If neither map is provided, both default to the identity and the array behaves l `fmap(fmap(x, s...), -s...) == x`. - If `fmap` does **not** satisfy the self-inverse property, supply a custom `imap`. If mutation through out-of-bounds indices should be explicitly forbidden, pass an - `imap` that throws: + `imap` that e.g. throws: ```julia imap_error(x, shift...) = error("mutation through out-of-bounds indices is not supported") a = PeriodicArray(data, fmap, imap_error) @@ -127,7 +124,7 @@ silently does nothing to `x`. The reason is that `x[out_of_bounds_index]` applie For in-bounds indices the element is returned by reference and mutation works as expected. -**Recommended workaround — `mapped_ref`:** +**Workaround — `mapped_ref`:** ```julia ref = mapped_ref(x, out_of_bounds_index) @@ -138,22 +135,6 @@ ref[i, j] = value # applies imap and writes back into parent(x) and the inverse map on writes, so no temporary copy is created and the mutation propagates correctly into the underlying data. -**Alternative workarounds** (lower-level): - -Operate directly on the underlying data (bypasses the map entirely): - -```julia -parent(x)[mod_index][i, j] = value -``` - -Or set the whole element at once (goes through `setindex!` on `x` and applies `imap`): - -```julia -tmp = copy(x[out_of_bounds_index]) -tmp[i, j] = value -x[out_of_bounds_index] = tmp -``` - ## License PeriodicArrays.jl is licensed under the [MIT License](LICENSE). By using or interacting with this software in any way, you agree to the license of this software. diff --git a/docs/files/README.jl b/docs/files/README.jl index c3ed258..d9a26c6 100644 --- a/docs/files/README.jl +++ b/docs/files/README.jl @@ -1,19 +1,32 @@ # # PeriodicArrays.jl # `PeriodicArrays.jl` adds the `PeriodicArray` type which can be backed by any `AbstractArray`. The idea of this package is based on [`CircularArrays.jl`](https://github.com/Vexatos/CircularArrays.jl) and extends its functionality to support user-defined translation rules for periodic indexing. -# A `PeriodicArray{T,N,A,F}` is an `AbstractArray{T,N}` backed by a data array of type `A<:AbstractArray{T,N}` and a map `f` of type `F`. -# The map defines how data in out-of-bounds indices is translated to valid indices in the data array. +# A `PeriodicArray{T,N,A,F,G}` is an `AbstractArray{T,N}` backed by a data array of type `A<:AbstractArray{T,N}`, a forward map `fmap` of type `F`, and an inverse map `imap` of type `G`. +# The maps define how data at out-of-bounds indices is translated to and from valid indices in the data array. -# `f` can be any callable object (e.g. a function or a struct), which defines +# `fmap` and `imap` can be any callable objects (e.g. functions or structs) that define # ```julia -# f(x, shift::Vararg{Int,N}) +# fmap(x, shift::Vararg{Int,N}) # ``` -# where `x` is an element of the array and shift encodes the unit cell, in which we index. -# `f` has to satisfy the following properties, which are not checked at construction time: -# - The output type of `f` has to be the same as the element type of the data array. -# - `f` is invertible with inverse `f(x, -shift...)`, i.e. it satisfies `f(f(x, shift...), -shift...) == x`. +# where `x` is an element of the array and `shift` encodes the unit cell in which we index. -# If `f` is not provided, the identity map is used and the `PeriodicArray` behaves like a `CircularArray`. +# `PeriodicArray` accepts the maps as `PeriodicArray(data, fmap)` or +# `PeriodicArray(data, fmap, imap)`. +# If neither map is provided, both default to the identity and the array behaves like a `CircularArray`. + +# **Constraints on `imap`** (the inverse map used by `setindex!`): +# - `imap` must satisfy `imap(fmap(x, shift...), shift...) == x` for all valid `x` and +# `shift`, so that round-tripping a value through `getindex`/`setindex!` is lossless. +# - When `imap` is omitted, it defaults to `(x, shifts...) -> fmap(x, -shifts...)`. +# This default is correct whenever `fmap` is self-inverse under shift negation, i.e. +# `fmap(fmap(x, s...), -s...) == x`. +# - If `fmap` does **not** satisfy the self-inverse property, supply a custom `imap`. +# If mutation through out-of-bounds indices should be explicitly forbidden, pass an +# `imap` that e.g. throws: +# ```julia +# imap_error(x, shift...) = error("mutation through out-of-bounds indices is not supported") +# a = PeriodicArray(data, fmap, imap_error) +# ``` # This package is compatible with [`OffsetArrays.jl`](https://github.com/JuliaArrays/OffsetArrays.jl). @@ -69,6 +82,30 @@ # 12 15 18 22 25 # ``` +# ## Known Limitations + +# **Iterated indexing for mutation does not work** when the map is non-trivial. +# For a `PeriodicArray` whose elements are themselves mutable (e.g. an array of matrices), writing + +# ```julia +# x[out_of_bounds_index][i, j] = value +# ``` + +# silently does nothing to `x`. The reason is that `x[out_of_bounds_index]` applies the map and returns a *new, transformed copy* of the element; the subsequent assignment mutates only that temporary object, not the underlying data. + +# For in-bounds indices the element is returned by reference and mutation works as expected. + +# **Workaround — `mapped_ref`:** + +# ```julia +# ref = mapped_ref(x, out_of_bounds_index) +# ref[i, j] = value # applies imap and writes back into parent(x) +# ``` + +# `mapped_ref` returns a `MappedRef`: a lazy wrapper that applies the forward map on reads +# and the inverse map on writes, so no temporary copy is created and the mutation propagates +# correctly into the underlying data. + # ## License -# PeriodicArrays.jl is licensed under the MIT License. By using or interacting with this software in any way, you agree to the license of this software. +# PeriodicArrays.jl is licensed under the [MIT License](LICENSE). By using or interacting with this software in any way, you agree to the license of this software. diff --git a/src/broadcast.jl b/src/broadcast.jl index 7df9003..63d53b3 100644 --- a/src/broadcast.jl +++ b/src/broadcast.jl @@ -20,5 +20,5 @@ _find_pa(::Any, rest...) = _find_pa(rest...) bc::Broadcast.Broadcasted{PeriodicArrayStyle{N}}, ::Type{ElType} ) where {N, ElType} pa = _find_pa(bc) - return PeriodicArray(similar(Array{ElType, N}, axes(bc)), pa.map, pa.imap) + return PeriodicArray(similar(Array{ElType, N}, axes(bc)), pa.fmap, pa.imap) end diff --git a/src/circshift.jl b/src/circshift.jl index b08e7b4..66fdff9 100644 --- a/src/circshift.jl +++ b/src/circshift.jl @@ -11,7 +11,7 @@ function _circshift_pa!( i = ntuple(d -> k[d] - s[d], N) i_base, i_shift = cell_position(src_data, i...) v = src_data[i_base...] - dest_data[k] = src.map(v, i_shift...) + dest_data[k] = src.fmap(v, i_shift...) end return dest end @@ -26,7 +26,7 @@ Base.circshift(arr::PeriodicArray{T, N}, shifts::AbstractVector{<:Integer}) wher # circshift! 2-arg (in-place) function Base.circshift!(arr::PeriodicArray{T, N}, shifts) where {T, N} - src = PeriodicArray(copy(parent(arr)), arr.map, arr.imap) + src = PeriodicArray(copy(parent(arr)), arr.fmap, arr.imap) return _circshift_pa!(arr, src, shifts) end # disambiguate with Base.circshift!(::AbstractVector, ::Integer) diff --git a/src/indexing.jl b/src/indexing.jl index 9425512..1ebd8c0 100644 --- a/src/indexing.jl +++ b/src/indexing.jl @@ -11,7 +11,7 @@ function cell_position(arr::AbstractArray{T, N}, I::Vararg{Integer, N}) where {T return i_base, i_shift end -# Special case for trivial map (identical to CelledArrays.jl) +# Special case for trivial fmap (identical to CelledArrays.jl) @inline function Base.getindex( arr::PeriodicArray{T, N, A, _identity_map_type, _identity_map_type}, i::Int ) where {A <: AbstractArray{T, N}} where {T, N} @@ -30,7 +30,7 @@ end @inbounds v = getindex(parent(arr), i_base...) all(iszero, i_shift) && return v - return arr.map(v, i_shift...) + return arr.fmap(v, i_shift...) end @inline function Base.setindex!( arr::PeriodicArray{T, N, A, F, G}, v, I::Vararg{Int, N} diff --git a/src/mapped_ref.jl b/src/mapped_ref.jl index 85b5ab3..a164465 100644 --- a/src/mapped_ref.jl +++ b/src/mapped_ref.jl @@ -12,9 +12,9 @@ Obtain a `MappedRef` via [`mapped_ref`](@ref) rather than constructing it direct """ struct MappedRef{E, N, T <: AbstractArray{E, N}, S <: Tuple, F, G} <: AbstractArray{E, N} ref::T # direct reference into parent(arr) — NOT a copy - shift::S # original periodic shift (same sign convention as PeriodicArray.map) - fmap::F # forward map: (scalar, shift...) -> mapped_scalar - imap::G # inverse map: (val, shift...) -> original_val + shift::S # original periodic shift (same sign convention as PeriodicArray.fmap) + fmap::F # forward fmap: (scalar, shift...) -> mapped_scalar + imap::G # inverse fmap: (val, shift...) -> original_val end Base.size(r::MappedRef) = size(r.ref) @@ -37,7 +37,7 @@ Return a lazy mutable wrapper for the element at periodic index `I...` in `arr`. For in-bounds indices (zero shift) the raw element is returned directly, so normal Julia mutation semantics apply. For out-of-bounds (wrapped) indices a `MappedRef` is -returned: reading through it applies `arr.map` element-wise; writing through it applies +returned: reading through it applies `arr.fmap` element-wise; writing through it applies `arr.imap` element-wise and stores the result back into the underlying data. # Example @@ -55,5 +55,5 @@ function mapped_ref(arr::PeriodicArray{T, N}, I::Vararg{Int, N}) where {T <: Abs i_base, i_shift = cell_position(arr, I...) ref = @inbounds parent(arr)[i_base...] all(iszero, i_shift) && return ref - return MappedRef(ref, i_shift, arr.map, arr.imap) + return MappedRef(ref, i_shift, arr.fmap, arr.imap) end diff --git a/src/repeat.jl b/src/repeat.jl index 8c67718..c585d46 100644 --- a/src/repeat.jl +++ b/src/repeat.jl @@ -1,5 +1,5 @@ function Base.repeat(A::PeriodicArray{T, N}; inner = nothing, outer = nothing) where {T, N} - map = A.map + fmap = A.fmap # If no outer repetition is requested, just repeat the parent array as usual A_new = repeat(parent(A); inner = inner) @@ -17,27 +17,27 @@ function Base.repeat(A::PeriodicArray{T, N}; inner = nothing, outer = nothing) w ps = size(base) newsize = ntuple(i -> ps[i] * outer[i], N) - # create a tiled parent filled with translated values from `map` + # create a tiled parent filled with translated values from `fmap` A_tiled = similar(base, newsize) tile_ranges = ntuple(i -> 0:(outer[i] - 1), N) for tile in CartesianIndices(tile_ranges) shifts = Tuple(Int(tile[i]) for i in 1:N) for pos in CartesianIndices(base) tgt = ntuple(i -> tile[i] * ps[i] + (pos[i] - firstindex(axs[i]) + 1), N) - @inbounds A_tiled[tgt...] = map(base[pos], shifts...) + @inbounds A_tiled[tgt...] = fmap(base[pos], shifts...) end end @inline function map_new(x, shift::Vararg{Integer, N}) - # shifts passed to this map refer to super-cell shifts; amplify + # shifts passed to this fmap refer to super-cell shifts; amplify # by `outer` to convert them to original unit-cell shifts. amplified = ntuple(i -> shift[i] * outer[i], N) - return map(x, amplified...) + return fmap(x, amplified...) end imap_new = NegatedShiftMap(map_new) return PeriodicArray(A_tiled, map_new, imap_new) end - return PeriodicArray(A_new, map, A.imap) + return PeriodicArray(A_new, fmap, A.imap) end diff --git a/src/reverse.jl b/src/reverse.jl index cf02f85..fd9a024 100644 --- a/src/reverse.jl +++ b/src/reverse.jl @@ -8,7 +8,7 @@ function _reverse(arr::PeriodicArray{T, N, A, F}) where {T, N, A, F} @inline function map_rev(x, shifts::Vararg{Integer, N}) neg = ntuple(i -> -shifts[i], N) - return arr.map(x, neg...) + return arr.fmap(x, neg...) end return PeriodicArray(base, map_rev) @@ -20,7 +20,7 @@ function _reverse(arr::PeriodicArray{T, N, A, F}, dims...) where {T, N, A, F} @inline function map_rev(x, shifts::Vararg{Integer, N}) adj = ntuple(i -> (i in dimsset) ? -shifts[i] : shifts[i], N) - return arr.map(x, adj...) + return arr.fmap(x, adj...) end return PeriodicArray(base, map_rev) diff --git a/src/types.jl b/src/types.jl index e38579c..7098791 100644 --- a/src/types.jl +++ b/src/types.jl @@ -2,62 +2,62 @@ identity_map(x, ::Vararg{Any}) = x const _identity_map_type = typeof(identity_map) struct NegatedShiftMap{F} - map::F + fmap::F end -(m::NegatedShiftMap)(x, shifts::Vararg{Integer}) = m.map(x, ntuple(i -> -shifts[i], length(shifts))...) +(m::NegatedShiftMap)(x, shifts::Vararg{Integer}) = m.fmap(x, ntuple(i -> -shifts[i], length(shifts))...) """ PeriodicArray{T, N, A, F, G} <: AbstractArray{T, N} `N`-dimensional array backed by an `AbstractArray{T, N}` of type `A` with fixed size -and periodic indexing as defined by `map` and `imap`. +and periodic indexing as defined by `fmap` and `imap`. - array[index...] == map(array[mod1.(index, size)...], fld.(index .- 1, size)...) + array[index...] == fmap(array[mod1.(index, size)...], fld.(index .- 1, size)...) -`imap` is the inverse map used for `setindex!`, defaulting to `map` with negated shifts. +`imap` is the inverse fmap used for `setindex!`, defaulting to `fmap` with negated shifts. """ struct PeriodicArray{T, N, A <: AbstractArray{T, N}, F, G} <: AbstractArray{T, N} data::A - map::F + fmap::F imap::G - function PeriodicArray{T}(data::A, map::F, imap::G) where {A <: AbstractArray{T, N}, F, G} where {T, N} - return new{T, N, A, F, G}(data, map, imap) + function PeriodicArray{T}(data::A, fmap::F, imap::G) where {A <: AbstractArray{T, N}, F, G} where {T, N} + return new{T, N, A, F, G}(data, fmap, imap) end - function PeriodicArray{T, N}(data::A, map::F, imap::G) where {A <: AbstractArray{T, N}, F, G} where {T, N} - return new{T, N, A, F, G}(data, map, imap) + function PeriodicArray{T, N}(data::A, fmap::F, imap::G) where {A <: AbstractArray{T, N}, F, G} where {T, N} + return new{T, N, A, F, G}(data, fmap, imap) end - function PeriodicArray{T, N, A}(data::A, map::F, imap::G) where {A <: AbstractArray{T, N}, F, G} where {T, N} - return new{T, N, A, F, G}(data, map, imap) + function PeriodicArray{T, N, A}(data::A, fmap::F, imap::G) where {A <: AbstractArray{T, N}, F, G} where {T, N} + return new{T, N, A, F, G}(data, fmap, imap) end end -PeriodicArray{T}(data::A, map::F = identity_map) where {A <: AbstractArray{T, N}, F} where {T, N} = - PeriodicArray{T}(data, map, _default_imap(map)) -PeriodicArray{T, N}(data::A, map::F = identity_map) where {A <: AbstractArray{T, N}, F} where {T, N} = - PeriodicArray{T, N}(data, map, _default_imap(map)) -PeriodicArray{T, N, A}(data::A, map::F = identity_map) where {A <: AbstractArray{T, N}, F} where {T, N} = - PeriodicArray{T, N, A}(data, map, _default_imap(map)) +PeriodicArray{T}(data::A, fmap::F = identity_map) where {A <: AbstractArray{T, N}, F} where {T, N} = + PeriodicArray{T}(data, fmap, _default_imap(fmap)) +PeriodicArray{T, N}(data::A, fmap::F = identity_map) where {A <: AbstractArray{T, N}, F} where {T, N} = + PeriodicArray{T, N}(data, fmap, _default_imap(fmap)) +PeriodicArray{T, N, A}(data::A, fmap::F = identity_map) where {A <: AbstractArray{T, N}, F} where {T, N} = + PeriodicArray{T, N, A}(data, fmap, _default_imap(fmap)) -_default_imap(map::_identity_map_type) = map -_default_imap(map) = NegatedShiftMap(map) +_default_imap(fmap::_identity_map_type) = fmap +_default_imap(fmap) = NegatedShiftMap(fmap) """ - PeriodicArray(data, [map, [imap]]) + PeriodicArray(data, [fmap, [imap]]) Create a `PeriodicArray` backed by `data`. -`map` defaults to the identity map. `imap` defaults to `map` with negated shifts. +`fmap` defaults to the identity fmap. `imap` defaults to `fmap` with negated shifts. """ -PeriodicArray(data::A, map::F = identity_map, imap::G = _default_imap(map)) where {A <: AbstractArray{T, N}, F, G} where {T, N} = PeriodicArray{T, N}(data, map, imap) +PeriodicArray(data::A, fmap::F = identity_map, imap::G = _default_imap(fmap)) where {A <: AbstractArray{T, N}, F, G} where {T, N} = PeriodicArray{T, N}(data, fmap, imap) -PeriodicArray(arr::PeriodicArray, map::F = identity_map) where {F} = arr +PeriodicArray(arr::PeriodicArray, fmap::F = identity_map) where {F} = arr """ - PeriodicArray(def, size, [map]) + PeriodicArray(def, size, [fmap]) Create a `PeriodicArray` of size `size` filled with value `def`. -`map` is optional and defaults to the identity map. +`fmap` is optional and defaults to the identity fmap. """ -PeriodicArray(def::T, size, map::F = identity_map) where {T, F} = PeriodicArray(fill(def, size), map) +PeriodicArray(def::T, size, fmap::F = identity_map) where {T, F} = PeriodicArray(fill(def, size), fmap) """ PeriodicVector{T, A, F, G} <: AbstractVector{T} @@ -65,7 +65,7 @@ PeriodicArray(def::T, size, map::F = identity_map) where {T, F} = PeriodicArray( One-dimensional array backed by an `AbstractArray{T, 1}` of type `A` with fixed size and periodic indexing. Alias for [`PeriodicArray{T, 1, A, F, G}`](@ref). - array[index] == map(array[mod1(index, length)], fld(index - 1, length)) + array[index] == fmap(array[mod1(index, length)], fld(index - 1, length)) """ const PeriodicVector{T} = PeriodicArray{T, 1} @@ -84,6 +84,11 @@ PeriodicMatrix(args...) = PeriodicArray(args...) Base.IndexStyle(::Type{PeriodicArray{T, N, A, F, G}}) where {T, N, A, F, G} = IndexCartesian() Base.IndexStyle(::Type{<:PeriodicVector}) = IndexLinear() +@inline function Base.getproperty(arr::PeriodicArray, name::Symbol) + name === :map && return getfield(arr, :fmap) + return getfield(arr, name) +end + @inline Base.size(arr::PeriodicArray) = size(arr.data) @inline Base.axes(arr::PeriodicArray) = axes(arr.data) @inline Base.parent(arr::PeriodicArray) = arr.data @@ -91,7 +96,7 @@ Base.IndexStyle(::Type{<:PeriodicVector}) = IndexLinear() @inline Base.iterate(arr::PeriodicArray, i...) = iterate(parent(arr), i...) @inline Base.in(x, arr::PeriodicArray) = in(x, parent(arr)) -@inline Base.copy(arr::PeriodicArray) = PeriodicArray(copy(parent(arr)), arr.map, arr.imap) +@inline Base.copy(arr::PeriodicArray) = PeriodicArray(copy(parent(arr)), arr.fmap, arr.imap) @inline Base.dataids(arr::PeriodicArray) = Base.dataids(parent(arr)) @@ -102,7 +107,7 @@ function Base.showarg(io::IO, arr::PeriodicArray, toplevel) end @inline function _similar(arr::PeriodicArray, ::Type{T}, dims) where {T} - return PeriodicArray(similar(parent(arr), T, dims), arr.map, arr.imap) + return PeriodicArray(similar(parent(arr), T, dims), arr.fmap, arr.imap) end @inline function Base.similar( arr::PeriodicArray, ::Type{T}, dims::Tuple{Base.DimOrInd, Vararg{Base.DimOrInd}} diff --git a/src/vector_interface.jl b/src/vector_interface.jl index e2842b8..867c7f8 100644 --- a/src/vector_interface.jl +++ b/src/vector_interface.jl @@ -1,4 +1,4 @@ -Base.empty(a::PeriodicVector{T}, ::Type{U} = T) where {T, U} = PeriodicVector{U}(U[], a.map, a.imap) +Base.empty(a::PeriodicVector{T}, ::Type{U} = T) where {T, U} = PeriodicVector{U}(U[], a.fmap, a.imap) Base.empty!(a::PeriodicVector) = (empty!(parent(a)); a) Base.push!(a::PeriodicVector, x...) = (push!(parent(a), x...); a) Base.append!(a::PeriodicVector, items) = (append!(parent(a), items); a) @@ -12,7 +12,7 @@ function Base.deleteat!(a::PeriodicVector, i::Integer) end function Base.deleteat!(a::PeriodicVector, inds) - deleteat!(parent(a), sort!(unique(map(i -> mod(i, eachindex(IndexLinear(), parent(a))), inds)))) + deleteat!(parent(a), sort!(unique(fmap(i -> mod(i, eachindex(IndexLinear(), parent(a))), inds)))) return a end diff --git a/test/test_basics.jl b/test/test_basics.jl index dd471a6..27e0c8e 100644 --- a/test/test_basics.jl +++ b/test/test_basics.jl @@ -198,7 +198,7 @@ end end @testset "type stability" begin - v3 = @inferred(map(x -> x + 1, PeriodicArray([1, 2, 3, 4]))) + v3 = @inferred(fmap(x -> x + 1, PeriodicArray([1, 2, 3, 4]))) @test v3 isa PeriodicVector{Int64} @test v3 == PeriodicArray([2, 3, 4, 5]) @test similar(v3, Base.OneTo(4)) isa typeof(v3) @@ -211,7 +211,7 @@ end @test v5 isa PeriodicVector{Bool, Vector{Bool}} @test v5 == PeriodicArray([0, 0, 1, 1]) - # scalar-first broadcast (previously: FieldError on `map`) + # scalar-first broadcast (previously: FieldError on `fmap`) v6 = @inferred(1 .+ PeriodicArray([1, 2, 3, 4])) @test v6 isa PeriodicVector{Int64} @test v6 == PeriodicArray([2, 3, 4, 5]) @@ -421,7 +421,7 @@ end @testset "1D" begin a = PeriodicVector([1, 2, 3]) - # outer as scalar — identity map tiles data unchanged + # outer as scalar — identity fmap tiles data unchanged ar = repeat(a; outer = 2) @test parent(ar) == [1, 2, 3, 1, 2, 3] @test length(ar) == 6 diff --git a/test/test_nontrivial_boundary.jl b/test/test_nontrivial_boundary.jl index 1a06f90..4648835 100644 --- a/test/test_nontrivial_boundary.jl +++ b/test/test_nontrivial_boundary.jl @@ -195,7 +195,7 @@ for f in translation_functions end @testset "type stability" begin - v3 = @inferred(map(x -> x + 1, PeriodicArray([1, 2, 3, 4], f))) + v3 = @inferred(fmap(x -> x + 1, PeriodicArray([1, 2, 3, 4], f))) @test v3 isa PeriodicVector{Int64} @test v3 == PeriodicArray([2, 3, 4, 5], f) @test similar(v3, Base.OneTo(4)) isa typeof(v3) @@ -208,7 +208,7 @@ for f in translation_functions @test v5 isa PeriodicVector{Bool, Vector{Bool}} @test v5 == PeriodicArray([0, 0, 1, 1], f) - # scalar-first broadcast (previously: FieldError on `map`) + # scalar-first broadcast (previously: FieldError on `fmap`) v6 = @inferred(1 .+ PeriodicArray([1, 2, 3, 4], f)) @test v6 isa PeriodicVector{Int64} @test v6 == PeriodicArray([2, 3, 4, 5], f) @@ -288,7 +288,7 @@ for f in translation_functions @test c3[3, CartesianIndex(3, 7)] == f(c3[1, 3, 3], 1, 0, 1) @test c3[Int32(3), CartesianIndex(3, 7)] == f(c3[1, 3, 3], 1, 0, 1) - @test vec(c3[:, [CartesianIndex()], 1, 5]) == map(x -> f(x, 0, 0, 1), vec(t3[:, 1, 1])) + @test vec(c3[:, [CartesianIndex()], 1, 5]) == fmap(x -> f(x, 0, 0, 1), vec(t3[:, 1, 1])) @test IndexStyle(c3) == IndexStyle(typeof(c3)) == IndexCartesian() @@ -348,8 +348,8 @@ for f in translation_functions @test size(parent(ar)) == (length(base) * 2,) val = 5 - @test ar.map(val, 1) == a.map(val, 2) - @test ar.map(val, 3) == a.map(val, 6) + @test ar.fmap(val, 1) == a.fmap(val, 2) + @test ar.fmap(val, 3) == a.fmap(val, 6) # inner repetition ai = repeat(a; inner = 2) @@ -383,8 +383,8 @@ for f in translation_functions @test parent(br) == expected2 @test size(parent(br)) == (size(base, 1) * o1, size(base, 2) * o2) - @test br.map(1, 1, 2) == b.map(1, 2, 6) - @test br.map(7, 0, 1) == b.map(7, 0, 3) + @test br.fmap(1, 1, 2) == b.fmap(1, 2, 6) + @test br.fmap(7, 0, 1) == b.fmap(7, 0, 3) # inner repetition in 2D bi = repeat(b; inner = (2, 1)) From 0c998e1b814f5967eff7b29e54ed3459c92ada0e Mon Sep 17 00:00:00 2001 From: AFeuerpfeil Date: Thu, 2 Apr 2026 11:37:02 -0400 Subject: [PATCH 4/6] fixes --- docs/Project.toml | 2 +- src/vector_interface.jl | 2 +- test/test_basics.jl | 2 +- test/test_nontrivial_boundary.jl | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/Project.toml b/docs/Project.toml index add7dde..5b163c6 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -6,4 +6,4 @@ PeriodicArrays = "343d6138-6384-4525-8bee-38906309ab36" [compat] Documenter = "1" Literate = "2" -PeriodicArrays = "1.0" +PeriodicArrays = "1.0,2.0" diff --git a/src/vector_interface.jl b/src/vector_interface.jl index 867c7f8..417baa8 100644 --- a/src/vector_interface.jl +++ b/src/vector_interface.jl @@ -12,7 +12,7 @@ function Base.deleteat!(a::PeriodicVector, i::Integer) end function Base.deleteat!(a::PeriodicVector, inds) - deleteat!(parent(a), sort!(unique(fmap(i -> mod(i, eachindex(IndexLinear(), parent(a))), inds)))) + deleteat!(parent(a), sort!(unique(map(i -> mod(i, eachindex(IndexLinear(), parent(a))), inds)))) return a end diff --git a/test/test_basics.jl b/test/test_basics.jl index 27e0c8e..ab205be 100644 --- a/test/test_basics.jl +++ b/test/test_basics.jl @@ -198,7 +198,7 @@ end end @testset "type stability" begin - v3 = @inferred(fmap(x -> x + 1, PeriodicArray([1, 2, 3, 4]))) + v3 = @inferred(map(x -> x + 1, PeriodicArray([1, 2, 3, 4]))) @test v3 isa PeriodicVector{Int64} @test v3 == PeriodicArray([2, 3, 4, 5]) @test similar(v3, Base.OneTo(4)) isa typeof(v3) diff --git a/test/test_nontrivial_boundary.jl b/test/test_nontrivial_boundary.jl index 4648835..4d3d373 100644 --- a/test/test_nontrivial_boundary.jl +++ b/test/test_nontrivial_boundary.jl @@ -288,7 +288,7 @@ for f in translation_functions @test c3[3, CartesianIndex(3, 7)] == f(c3[1, 3, 3], 1, 0, 1) @test c3[Int32(3), CartesianIndex(3, 7)] == f(c3[1, 3, 3], 1, 0, 1) - @test vec(c3[:, [CartesianIndex()], 1, 5]) == fmap(x -> f(x, 0, 0, 1), vec(t3[:, 1, 1])) + @test vec(c3[:, [CartesianIndex()], 1, 5]) == map(x -> f(x, 0, 0, 1), vec(t3[:, 1, 1])) @test IndexStyle(c3) == IndexStyle(typeof(c3)) == IndexCartesian() From bd04fa5eeac46ae6f60d5c6128a7ac70d7c7d97f Mon Sep 17 00:00:00 2001 From: AFeuerpfeil Date: Thu, 2 Apr 2026 11:40:09 -0400 Subject: [PATCH 5/6] another fix --- test/test_nontrivial_boundary.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_nontrivial_boundary.jl b/test/test_nontrivial_boundary.jl index 4d3d373..98eb9cb 100644 --- a/test/test_nontrivial_boundary.jl +++ b/test/test_nontrivial_boundary.jl @@ -195,7 +195,7 @@ for f in translation_functions end @testset "type stability" begin - v3 = @inferred(fmap(x -> x + 1, PeriodicArray([1, 2, 3, 4], f))) + v3 = @inferred(map(x -> x + 1, PeriodicArray([1, 2, 3, 4], f))) @test v3 isa PeriodicVector{Int64} @test v3 == PeriodicArray([2, 3, 4, 5], f) @test similar(v3, Base.OneTo(4)) isa typeof(v3) From 546b4441d829d5d90de29a950b0f4c3e8feab374 Mon Sep 17 00:00:00 2001 From: AFeuerpfeil Date: Thu, 2 Apr 2026 12:34:56 -0400 Subject: [PATCH 6/6] add tests and fix documentation --- docs/files/README.jl | 2 +- test/test_broadcast_and_mapped_ref.jl | 198 ++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 test/test_broadcast_and_mapped_ref.jl diff --git a/docs/files/README.jl b/docs/files/README.jl index d9a26c6..cbbba9b 100644 --- a/docs/files/README.jl +++ b/docs/files/README.jl @@ -108,4 +108,4 @@ # ## License -# PeriodicArrays.jl is licensed under the [MIT License](LICENSE). By using or interacting with this software in any way, you agree to the license of this software. +# PeriodicArrays.jl is licensed under the MIT License. By using or interacting with this software in any way, you agree to the license of this software. diff --git a/test/test_broadcast_and_mapped_ref.jl b/test/test_broadcast_and_mapped_ref.jl new file mode 100644 index 0000000..6864840 --- /dev/null +++ b/test/test_broadcast_and_mapped_ref.jl @@ -0,0 +1,198 @@ +using PeriodicArrays +using PeriodicArrays: mapped_ref, MappedRef, PeriodicArrayStyle +using Base.Broadcast: BroadcastStyle, DefaultArrayStyle +using Test + +@testset "broadcast" begin + @testset "BroadcastStyle combination rules" begin + s1 = PeriodicArrayStyle{1}() + s2 = PeriodicArrayStyle{2}() + sd1 = DefaultArrayStyle{1}() + sd2 = DefaultArrayStyle{2}() + + # two PeriodicArrayStyles: result has max dimensionality + @test BroadcastStyle(s1, s2) === PeriodicArrayStyle{2}() + @test BroadcastStyle(s2, s1) === PeriodicArrayStyle{2}() + @test BroadcastStyle(s1, s1) === PeriodicArrayStyle{1}() + + # PeriodicArrayStyle with DefaultArrayStyle + @test BroadcastStyle(s1, sd2) === PeriodicArrayStyle{2}() + @test BroadcastStyle(sd2, s1) === PeriodicArrayStyle{2}() + @test BroadcastStyle(s2, sd1) === PeriodicArrayStyle{2}() + @test BroadcastStyle(sd1, s2) === PeriodicArrayStyle{2}() + end + + @testset "BroadcastStyle from array type" begin + v = PeriodicVector([1, 2, 3]) + m = PeriodicMatrix([1 2; 3 4]) + @test BroadcastStyle(typeof(v)) === PeriodicArrayStyle{1}() + @test BroadcastStyle(typeof(m)) === PeriodicArrayStyle{2}() + end + + @testset "broadcast result type — identity fmap" begin + v = PeriodicVector([1, 2, 3]) + m = PeriodicMatrix([1 2 3; 4 5 6]) + + # 1D broadcast preserves PeriodicVector + r1 = v .+ 1 + @test r1 isa PeriodicVector{Int64} + @test r1 == PeriodicVector([2, 3, 4]) + + # 2D broadcast preserves PeriodicMatrix + r2 = m .* 2 + @test r2 isa PeriodicMatrix{Int64} + @test r2 == PeriodicMatrix([2 4 6; 8 10 12]) + + # scalar-first commutative form + r3 = 10 .+ v + @test r3 isa PeriodicVector{Int64} + @test r3 == PeriodicVector([11, 12, 13]) + end + + @testset "broadcast result type — non-trivial fmap" begin + f(x, shift::Vararg{Int}) = x + 10 * sum(shift) + v = PeriodicVector([1, 2, 3], f) + m = PeriodicMatrix([1 2; 3 4], f) + + r1 = v .+ 0 + @test r1 isa PeriodicVector{Int64} + @test r1.fmap === f + + r2 = m .+ 0 + @test r2 isa PeriodicMatrix{Int64} + @test r2.fmap === f + + # scalar-first + r3 = 0 .+ v + @test r3 isa PeriodicVector{Int64} + @test r3.fmap === f + end + + @testset "broadcast 2D × 1D (shape promotion)" begin + m = PeriodicMatrix([1 2 3; 4 5 6]) # 2×3 + v = PeriodicVector([10, 20, 30]) # length-3 row vector view via reshape + r = m .+ reshape(v, 1, 3) + @test r isa PeriodicMatrix{Int64} + @test r == PeriodicMatrix([11 22 33; 14 25 36]) + end + + @testset "in-place broadcast .=" begin + a = PeriodicVector([1, 2, 3]) + b = similar(a) + b .= a .+ 10 + @test b == PeriodicVector([11, 12, 13]) + @test b isa PeriodicVector{Int64} + end + + @testset "_find_pa — nested Broadcasted" begin + # exercises the recursive Broadcasted branch of _find_pa + v = PeriodicVector([1, 2, 3]) + r = (v .+ 1) .* 2 + @test r isa PeriodicVector{Int64} + @test r == PeriodicVector([4, 6, 8]) + end +end + +@testset "mapped_ref" begin + # fmap: x + shift, imap: x - shift (NegatedShiftMap default) + f(x, shift::Vararg{Int}) = x .+ sum(shift) + + @testset "in-bounds returns raw element" begin + data = [ones(2, 2), 2 * ones(2, 2), 3 * ones(2, 2)] + x = PeriodicVector(data, f) + ref = mapped_ref(x, 2) # in-bounds: shift == 0 + @test ref === parent(x)[2] # identity — no MappedRef wrapper + end + + @testset "out-of-bounds returns MappedRef" begin + data = [ones(2, 2), 2 * ones(2, 2)] + x = PeriodicVector(data, f) + ref = mapped_ref(x, 3) # wraps parent(x)[1] with shift=(1,) + @test ref isa PeriodicArrays.MappedRef + end + + @testset "MappedRef size and parent" begin + data = [zeros(3, 4)] + x = PeriodicVector(data, f) + ref = mapped_ref(x, 2) # out-of-bounds: shift=(1,) + @test size(ref) == (3, 4) + @test parent(ref) === parent(x)[1] + end + + @testset "getindex applies fmap" begin + mat = [10.0 20.0; 30.0 40.0] + data = [mat] + x = PeriodicVector(data, f) + ref = mapped_ref(x, 2) # shift=(1,) + @test ref[1, 1] == 10.0 + 1 # fmap adds shift sum + @test ref[2, 2] == 40.0 + 1 + end + + @testset "setindex! applies imap and writes back" begin + mat = zeros(2, 2) + data = [mat] + x = PeriodicVector(data, f) + ref = mapped_ref(x, 2) # shift=(1,), imap subtracts shift + ref[1, 1] = 100.0 # stores imap(100, 1) = 99 into mat[1,1] + @test mat[1, 1] == 99.0 + # reading back through ref applies fmap: 99 + 1 == 100 + @test ref[1, 1] == 100.0 + # reading through x at the out-of-bounds index also gives 100 + @test x[2][1, 1] == 100.0 + end + + @testset "setindex! return value" begin + data = [zeros(2, 2)] + x = PeriodicVector(data, f) + ref = mapped_ref(x, 2) + ret = (ref[1, 2] = 42.0) + @test ret == 42.0 + end + + @testset "mutation round-trip" begin + # A full getindex→setindex! round-trip via mapped_ref must be lossless. + mat = [1.0 2.0; 3.0 4.0] + data = [copy(mat)] + x = PeriodicVector(data, f) + ref = mapped_ref(x, 2) + + for I in CartesianIndices(mat) + original = mat[I] + # fmap(original, 1) is what getindex would return + observed = ref[I] + @test observed == original + 1 + # write it back: imap(observed, 1) == original + ref[I] = observed + @test parent(x)[1][I] == original + end + end + + @testset "2D PeriodicMatrix element access" begin + # PeriodicArray of matrices, indexed with two periodic indices + f2(x, s1::Int, s2::Int) = x .+ s1 .+ s2 + data = [fill(Float64(i + 3 * j), 2, 2) for i in 1:3, j in 1:3] + x = PeriodicMatrix(data, f2) + + # in-bounds + ref_ib = mapped_ref(x, 2, 2) + @test ref_ib === parent(x)[2, 2] + + # out-of-bounds in first dimension + ref_oob = mapped_ref(x, 4, 1) # shift=(1,0) + @test ref_oob isa PeriodicArrays.MappedRef + @test ref_oob[1, 1] == parent(x)[1, 1][1, 1] + 1 + end + + @testset "custom imap" begin + f_fwd(x, shift::Vararg{Int}) = x .* (1 + sum(shift)) + f_inv(x, shift::Vararg{Int}) = x ./ (1 + sum(shift)) + data = [fill(2.0, 2, 2)] + x = PeriodicVector(data, f_fwd, f_inv) + ref = mapped_ref(x, 2) # shift=(1,) + # getindex: fmap(2.0, 1) = 2.0 * 2 = 4.0 + @test ref[1, 1] == 4.0 + # setindex!: imap(8.0, 1) = 8.0 / 2 = 4.0 stored + ref[1, 1] = 8.0 + @test parent(x)[1][1, 1] == 4.0 + end +end