Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions examples/multi_dataset_example.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using SpectralFitting
using Random

println("=== Issue #132 + #196: Single model, multiple datasets ===")

TRUE_a = 1.7
TRUE_K = [1.0, 0.5, 2.0]
energy_bins = collect(range(0.3, 10.0, length=51))

println("\nStep 1: Generating synthetic datasets...")
function make_fake_dataset(K, a, energy_bins; seed=42)
Random.seed!(seed)
m = PowerLaw(K=FitParam(K), a=FitParam(a))
flux = invokemodel(energy_bins, m)
noisy = max.(flux .+ randn(length(flux)) .* sqrt.(abs.(flux)) .* 0.05, 1e-10)
variance = abs.(flux) .* 0.05^2
InjectiveData(energy_bins[1:end-1], noisy; codomain_variance=variance)
end

# FIXED: alag seed har dataset ke liye
d1 = make_fake_dataset(TRUE_K[1], TRUE_a, energy_bins; seed=1)
d2 = make_fake_dataset(TRUE_K[2], TRUE_a, energy_bins; seed=2)
d3 = make_fake_dataset(TRUE_K[3], TRUE_a, energy_bins; seed=3)
println(" 3 datasets created ✓")

println("\nStep 2: domain_union test...")
union_dom = domain_union(d1, d2, d3)
println(" Union domain edges: $(length(union_dom)) ✓")
println(" Compatible: $(domains_are_compatible(d1, d2, d3)) ✓")

println("\nStep 3: normalisation extraction test...")
m_test = PowerLaw()
idx = extract_normalisation_index(m_test)
pnames = SpectralFitting.parameter_names(m_test)
println(" Norm index in PowerLaw: $idx ✓")
println(" Param name: $(pnames[idx]) ✓")
println(" Has normalisation: $(has_normalisation(m_test)) ✓")

println("\nStep 4: Building FittingProblem...")
base_model = PowerLaw(
K = FitParam(1.0, lower_limit=0.0, upper_limit=100.0),
a = FitParam(1.5, lower_limit=0.0, upper_limit=5.0)
)
prob = multi_dataset_problem(base_model, d1, d2, d3)

# FIXED: details() se clean output
println(" Models: $(SpectralFitting.model_count(prob))")
println(" Datasets: $(SpectralFitting.data_count(prob))")

println("\nStep 5: Fitting...")
result = fit(prob, LevenbergMarquadt())

println("\n=== RESULTS ===")
println("True: K=$(TRUE_K), α=$(TRUE_a)")
for i in 1:3
r = result[i]
K_rec = round(r.u[1], digits=4)
a_rec = round(r.u[2], digits=4)
K_err = round(abs(r.u[1] - TRUE_K[i])/TRUE_K[i]*100, digits=2)
println(" Dataset $i: K=$(K_rec) (err=$(K_err)%), α=$(a_rec)")
end

println("\n=== ISSUE #132 + #196 VERIFICATION ===")
println(" ✓ Single model fitted to 3 datasets")
println(" ✓ Per-dataset normalisations recovered")
println(" ✓ Shared photon index across datasets")
println(" ✓ domain_union utility works")
println(" ✓ extract_normalisation_index works")
println("\nDone! ✓")
6 changes: 6 additions & 0 deletions src/SpectralFitting.jl
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,10 @@ function __init__()
_check_model_directory_present()
end


include("multi-data/domain_utils.jl")
include("multi-data/norm_extraction.jl")
include("multi-data/multi_problem.jl")

end # module

67 changes: 67 additions & 0 deletions src/multi-data/domain_utils.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""
domain_union(datasets::AbstractDataset...; tolerance=1e-4)

Return the union of model domains from multiple datasets, merging
bin edges that are within `tolerance` of each other.

Useful when datasets from different instruments have slightly offset
energy grids that should be treated as identical bins.

## Example
```julia
union_dom = domain_union(data1, data2, data3; tolerance=1e-3)
```
"""
function domain_union(
datasets::AbstractDataset...;
tolerance::Float64 = 1e-4,
)
length(datasets) >= 2 ||
throw(ArgumentError("domain_union requires at least 2 datasets"))

layout = ContiguouslyBinned()
all_edges = Float64[]

for d in datasets
# Validate dataset supports ContiguouslyBinned
if !(ContiguouslyBinned() in SpectralFitting.supports(typeof(d)))
throw(ArgumentError(
"$(typeof(d)) does not support ContiguouslyBinned layout"
))
end
append!(all_edges, make_model_domain(layout, d))
end

sort!(all_edges)

# Merge edges within tolerance
merged = Float64[]
for e in all_edges
if isempty(merged) || abs(e - last(merged)) > tolerance
push!(merged, e)
end
end

return merged
end

"""
domains_are_compatible(datasets::AbstractDataset...; tolerance=1e-4)

Return `true` if all datasets share the same energy domain up to `tolerance`.

Used to verify that the single-evaluation optimisation in
[`multi_dataset_problem`](@ref) is valid.
"""
function domains_are_compatible(
datasets::AbstractDataset...;
tolerance::Float64 = 1e-4,
)
layout = ContiguouslyBinned()
domains = [make_model_domain(layout, d) for d in datasets]
ref = first(domains)
all(d -> length(d) == length(ref) &&
all(abs.(d .- ref) .< tolerance), domains)
end

export domain_union, domains_are_compatible
55 changes: 55 additions & 0 deletions src/multi-data/multi_problem.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
multi_dataset_problem(model, datasets...; bind_norms=false)

Construct a [`FittingProblem`](@ref) for fitting a single model to multiple
datasets simultaneously, where only the normalisation (`:K`) differs between
datasets and all other parameters are shared.

Creates N copies of `model` (one per dataset), binds all shared parameters
using [`bindall!`](@ref), and returns a ready-to-fit [`FittingProblem`](@ref).

## Parameters
- `model`: An [`AbstractSpectralModel`](@ref) — copied N times
- `datasets`: Two or more [`AbstractDataset`](@ref) instances
- `bind_norms`: If `true`, also bind normalisations across datasets (default: `false`)

## Example
```julia
model = PowerLaw()
prob = multi_dataset_problem(model, data1, data2, data3)
result = fit(prob, LevenbergMarquadt())
```
"""
function multi_dataset_problem(
model::M,
datasets::Vararg{<:AbstractDataset, N};
bind_norms::Bool = false,
) where {M <: AbstractSpectralModel, N}
N >= 2 || throw(ArgumentError("Need at least 2 datasets, got $N"))

# Type-stable: ntuple with Val(N) gives Tuple, not Vector
models = ntuple(_ -> deepcopy(model), Val(N))

# Type-stable pair construction
prob = FittingProblem(map(=>, models, datasets)...)

# Get shared parameter names (all except normalisation)
all_names = SpectralFitting.parameter_names(model)
norm_sym = has_normalisation(model) ?
SpectralFitting.normalisationfield(typeof(model)) : nothing

shared_names = filter(s -> s !== norm_sym, all_names)

# Bind shared params across all N models
for sym in shared_names
bindall!(prob, sym)
end

if bind_norms && !isnothing(norm_sym)
bindall!(prob, norm_sym)
end

return prob
end

export multi_dataset_problem
51 changes: 51 additions & 0 deletions src/multi-data/norm_extraction.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""
extract_normalisation_index(model::AbstractSpectralModel)

Return the index of the normalisation parameter (`:K`) in the
model's [`parameter_vector`](@ref). Returns `nothing` for non-additive models.

Uses the existing [`SpectralFitting.normalisationfield`](@ref) and
[`SpectralFitting.parameter_names`](@ref) from the codebase.

## Example
```julia
m = PowerLaw()
idx = extract_normalisation_index(m) # → 1
SpectralFitting.parameter_names(m)[idx] # → :K
```
"""
function extract_normalisation_index(
model::AbstractSpectralModel{T, Additive},
) where {T}
norm_sym = SpectralFitting.normalisationfield(typeof(model))
names = SpectralFitting.parameter_names(model)
idx = findfirst(==(norm_sym), names)
isnothing(idx) && error(
"Model $(typeof(model)) is Additive but has no field $norm_sym. " *
"This is a bug — please report it."
)
return idx
end

# Non-additive models (Multiplicative, Convolutional) have no normalisation
extract_normalisation_index(::AbstractSpectralModel) = nothing

"""
has_normalisation(model::AbstractSpectralModel)

Return `true` if `model` is an [`Additive`](@ref) model with a
normalisation parameter.
"""
has_normalisation(::AbstractSpectralModel{T, Additive}) where {T} = true
has_normalisation(::AbstractSpectralModel) = false

"""
get_normalisation_param(model::AbstractSpectralModel{<:FitParam, Additive})

Return the [`FitParam`](@ref) for the normalisation (`:K`) of an
[`Additive`](@ref) model.
"""
get_normalisation_param(model::AbstractSpectralModel{<:FitParam, Additive}) =
SpectralFitting.normalisation(model)

export extract_normalisation_index, has_normalisation, get_normalisation_param
1 change: 1 addition & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,4 @@ using Aqua
# little bit of aqua
Aqua.test_undefined_exports(SpectralFitting)
Aqua.test_unbound_args(SpectralFitting)
include("test_multi_dataset.jl")
78 changes: 78 additions & 0 deletions test/test_multi_dataset.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using SpectralFitting
using Test
using Random

function _make_dataset(K, a, energy_bins; seed=1)
Random.seed!(seed)
m = PowerLaw(K=FitParam(K), a=FitParam(a))
flux = collect(invokemodel(energy_bins, m))
var = abs.(flux) .* 0.001
InjectiveData(energy_bins[1:end-1], flux; codomain_variance=var)
end

const EBINS = collect(range(0.3, 10.0, length=51))
const TRUE_K = [1.0, 0.5, 2.0]
const TRUE_a = 1.7

@testset "domain_union" begin
d1 = _make_dataset(1.0, TRUE_a, EBINS; seed=1)
d2 = _make_dataset(0.5, TRUE_a, EBINS; seed=2)
d3 = _make_dataset(2.0, TRUE_a, EBINS; seed=3)

u = domain_union(d1, d2, d3)
@test length(u) == 51
@test first(u) ≈ 0.3 atol=1e-6
@test last(u) ≈ 10.0 atol=1e-6
@test issorted(u)
@test domains_are_compatible(d1, d2, d3)
@test_throws ArgumentError domain_union(d1)
end

@testset "extract_normalisation_index" begin
m = PowerLaw()
@test extract_normalisation_index(m) == 1
@test has_normalisation(m) == true
@test SpectralFitting.parameter_names(m)[1] == :K
@test extract_normalisation_index(m) !== nothing
end

@testset "multi_dataset_problem structure" begin
datasets = [_make_dataset(TRUE_K[i], TRUE_a, EBINS; seed=i)
for i in 1:3]
base = PowerLaw(
K = FitParam(1.0, lower_limit=0.0, upper_limit=100.0),
a = FitParam(1.5, lower_limit=0.0, upper_limit=5.0),
)
prob = multi_dataset_problem(base, datasets...)

@test SpectralFitting.model_count(prob) == 3
@test SpectralFitting.data_count(prob) == 3

# `a` is shared — bindings dict should have entries
@test !isempty(prob.bindings)

# Error on single dataset
@test_throws ArgumentError multi_dataset_problem(base, datasets[1])
end

@testset "multi_dataset_problem fitting" begin
datasets = [_make_dataset(TRUE_K[i], TRUE_a, EBINS; seed=i)
for i in 1:3]
base = PowerLaw(
K = FitParam(1.0, lower_limit=0.0, upper_limit=100.0),
a = FitParam(1.5, lower_limit=0.0, upper_limit=5.0),
)
prob = multi_dataset_problem(base, datasets...)
result = fit(prob, LevenbergMarquadt())

for i in 1:3
@test isapprox(result[i].u[1], TRUE_K[i], rtol=0.05)
end
@test isapprox(result[1].u[2], TRUE_a, rtol=0.05)

# bind_norms=true: all K equal
prob2 = multi_dataset_problem(base, datasets...; bind_norms=true)
result2 = fit(prob2, LevenbergMarquadt())
@test result2[1].u[1] ≈ result2[2].u[1] rtol=1e-4
@test result2[1].u[1] ≈ result2[3].u[1] rtol=1e-4
end
Loading