From 8b69229d21f3ce2ee55cf382543d6312a8143f83 Mon Sep 17 00:00:00 2001 From: aditya-pandey-dev Date: Sun, 22 Mar 2026 21:07:04 +0530 Subject: [PATCH 1/2] Add XSPEC model string macro and docs (closes #187) --- docs/make.jl | 1 + docs/src/models/xspec-models.md | 11 + docs/src/transitioning-from-xspec.md | 100 ++++- lib/XSPECModels/src/XSPECModels.jl | 2 + lib/XSPECModels/src/xspec_string.jl | 475 ++++++++++++++++++++++ lib/XSPECModels/test/runtests.jl | 4 + lib/XSPECModels/test/test-xspec-string.jl | 383 +++++++++++++++++ running_example_xspec_strings.jl | 134 ++++++ 8 files changed, 1109 insertions(+), 1 deletion(-) create mode 100644 lib/XSPECModels/src/xspec_string.jl create mode 100644 lib/XSPECModels/test/test-xspec-string.jl create mode 100644 running_example_xspec_strings.jl diff --git a/docs/make.jl b/docs/make.jl index 5561f242..02484745 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -32,6 +32,7 @@ makedocs( "Surrogate models" => "models/surrogate-models.md", "XSPEC models" => "models/xspec-models.md", ], + "Transitioning from XSPEC" => "transitioning-from-xspec.md", "Datasets" => [ "Using datasets" => "datasets/datasets.md", "Mission support" => "datasets/mission-support.md", diff --git a/docs/src/models/xspec-models.md b/docs/src/models/xspec-models.md index e6126086..ba0ddba0 100644 --- a/docs/src/models/xspec-models.md +++ b/docs/src/models/xspec-models.md @@ -15,6 +15,17 @@ Using the package is straight forward once installed: ```julia using SpectralFitting, XSPECModels ``` +## XSPEC model strings + +If you have an existing XSPEC model string, translate it directly using the +`xspec"..."` string macro provided by this package: +```julia +model = xspec"phabs*powerlaw" +model = xspec"tbabs*(powerlaw+bbody)" +``` + +See [Transitioning from XSPEC](@ref) for the complete guide including +`parse_xspec_model_string`, `xspec_model_string`, and `XSPEC_MODEL_NAMES`. !!! note The convention is that models that have are imported from XSPEC or have XSPEC ABI are prefixed with `XS_` in their name. For example, the XSPEC equivalent of [`PowerLaw`](@ref) is [`XS_PowerLaw`](@ref). diff --git a/docs/src/transitioning-from-xspec.md b/docs/src/transitioning-from-xspec.md index 750f110a..a60da6b6 100644 --- a/docs/src/transitioning-from-xspec.md +++ b/docs/src/transitioning-from-xspec.md @@ -1 +1,99 @@ -# Transitioning from XSPEC \ No newline at end of file +# Transitioning from XSPEC + +This page is for astronomers familiar with +[XSPEC](https://heasarc.gsfc.nasa.gov/xanadu/xspec/) who want to use +SpectralFitting.jl. The two packages share the same underlying model library +(via [LibXSPEC_jll](https://github.com/astro-group-bristol/LibXSPEC_jll.jl)), +but SpectralFitting.jl expresses models as Julia types rather than strings. + +## Model string translation + +If you have an existing XSPEC model string, you can translate it directly into +a SpectralFitting.jl model using the `xspec"..."` string macro provided by +XSPECModels.jl: + +```julia +using SpectralFitting, XSPECModels + +# Classic absorbed power-law +model = xspec"phabs*powerlaw" + +# Thermal plasma with neutral hydrogen absorption +model = xspec"tbabs*powerlaw" + +# Multi-component: absorption acting on sum of additive models +model = xspec"phabs*(powerlaw+bbody)" + +# Three-component soft-state model +model = xspec"tbabs*(diskbb+powerlaw+gaussian)" +``` + +The string macro is **case-insensitive** and **whitespace-tolerant**: + +```julia +xspec"PHABS * PowerLaw" # same as xspec"phabs*powerlaw" +``` + +!!! note "Operator precedence" + `*` binds more tightly than `+`, matching XSPEC conventions: + ```julia + # These are different models! + xspec"phabs*powerlaw+bbody" # → (phabs*powerlaw) + bbody — bbody is unabsorbed + xspec"phabs*(powerlaw+bbody)" # → phabs * (powerlaw+bbody) — both are absorbed + ``` + This is astrophysically significant: only use parentheses when absorption + should apply to all additive components. + +For runtime strings (e.g. read from a file), use `parse_xspec_model_string`: + +```julia +model_str = "phabs*powerlaw" # could come from a config file +expr = parse_xspec_model_string(model_str) +model = eval(expr) +``` + +## Translating a model back to an XSPEC string + +Given any SpectralFitting.jl model built from XSPECModels types, you can +recover the XSPEC string representation with `xspec_model_string`: + +```julia +model = XS_PhotoelectricAbsorption() * (XS_PowerLaw() + XS_BlackBody()) +xspec_model_string(model) # → "phabs*(powerlaw+bbody)" +``` + +This is useful for logging, reproducibility, or interoperability with +scripts that call XSPEC directly. + +## XSPEC name reference + +The full mapping of XSPEC model names to Julia types is in `XSPEC_MODEL_NAMES`: + +```julia +XSPEC_MODEL_NAMES["tbabs"] # :XS_NeutralHydrogenAbsorption +XSPEC_MODEL_NAMES["phabs"] # :XS_PhotoelectricAbsorption +XSPEC_MODEL_NAMES["powerlaw"] # :XS_PowerLaw +XSPEC_MODEL_NAMES["diskbb"] # :XS_DiskBlackBody +``` + +Common XSPEC abbreviations are also supported: `"po"` for `"powerlaw"`, +`"gaus"` for `"gaussian"`, `"bb"` for `"bbody"`. + +## Comparison: XSPEC syntax vs SpectralFitting.jl + +| XSPEC | SpectralFitting.jl | +|:------|:-------------------| +| `model phabs*powerlaw` | `xspec"phabs*powerlaw"` or `XS_PhotoelectricAbsorption() * XS_PowerLaw()` | +| `model tbabs*(apec+powerlaw)` | `xspec"tbabs*(powerlaw+bbody)"` | +| `newpar 1 0.1` | `model.ηH_1.value = 0.1` | +| `freeze 1` | `model.ηH_1.frozen = true` | +| `thaw 2` | `model.a_1.frozen = false` | + +## API reference + +```@docs +parse_xspec_model_string +xspec_model_string +XSPEC_MODEL_NAMES +@xspec_str +``` diff --git a/lib/XSPECModels/src/XSPECModels.jl b/lib/XSPECModels/src/XSPECModels.jl index 88dce49c..d4e2938d 100644 --- a/lib/XSPECModels/src/XSPECModels.jl +++ b/lib/XSPECModels/src/XSPECModels.jl @@ -13,6 +13,8 @@ include("ccall-wrapper.jl") include("additive.jl") include("multiplicative.jl") include("convolutional.jl") +include("xspec_string.jl") +export XSPEC_MODEL_NAMES, parse_xspec_model_string, xspec_model_string, @xspec_str function register_xspec_data() push!(SpectralFitting.ALL_STORAGE_PATHS, LIBXSPEC_STORAGE_PATH) diff --git a/lib/XSPECModels/src/xspec_string.jl b/lib/XSPECModels/src/xspec_string.jl new file mode 100644 index 00000000..0babbb44 --- /dev/null +++ b/lib/XSPECModels/src/xspec_string.jl @@ -0,0 +1,475 @@ +# ============================================================================= +# xspec_string.jl +# +# Bidirectional translation between XSPEC model strings and XSPECModels.jl +# composite model expressions. +# +# Issue: https://github.com/JuliaAstro/SpectralFitting.jl/issues/187 +# +# PUBLIC API +# ────────────────────────────────────────────────────────────────────────────── +# xspec"phabs*powerlaw" → instantiated CompositeModel (macro) +# parse_xspec_model_string(str) → Julia Expr (runtime, no eval needed) +# xspec_model_string(model) → XSPEC string from any model +# XSPEC_MODEL_NAMES → Dict{String,Symbol} of all mappings +# +# ONLY MODELS THAT ACTUALLY EXIST IN THIS PACKAGE ARE LISTED. +# Verified against the live source of: +# lib/XSPECModels/src/additive.jl +# lib/XSPECModels/src/multiplicative.jl +# lib/XSPECModels/src/convolutional.jl +# ============================================================================= + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. FORWARD LOOKUP TABLE +# XSPEC model-string token → Julia type Symbol +# +# Every entry has been cross-checked against @xspecmodel declarations in +# the source files. Only types that are actually exported by this package +# appear here. +# ───────────────────────────────────────────────────────────────────────────── + +""" + XSPEC_MODEL_NAMES :: Dict{String, Symbol} + +Maps every recognised XSPEC model-string token to the corresponding +XSPECModels.jl Julia type name. + +Only models that are actually defined and exported by this package are +included. Both canonical XSPEC names and common abbreviations ("po", +"gaus", "bb") are supported; all keys are lowercase. + +```julia +XSPEC_MODEL_NAMES["powerlaw"] # :XS_PowerLaw +XSPEC_MODEL_NAMES["tbabs"] # :XS_NeutralHydrogenAbsorption +XSPEC_MODEL_NAMES["cflux"] # :XS_CalculateFlux +``` +""" +const XSPEC_MODEL_NAMES = Dict{String,Symbol}( + + # ── Additive models (from additive.jl) ─────────────────────────────────── + # @xspecmodel :C_powerlaw → XS_PowerLaw + "powerlaw" => :XS_PowerLaw, + "po" => :XS_PowerLaw, # common XSPEC abbreviation + + # @xspecmodel :C_zcutoffpl → XS_CutOffPowerLaw + "cutoffpl" => :XS_CutOffPowerLaw, + # "zcutoffpl" is the C function prefix; canonical XSPEC name is "cutoffpl" + # "zcutoffpl" => :XS_CutOffPowerLaw, + + # @xspecmodel :C_bbody → XS_BlackBody + "bbody" => :XS_BlackBody, + "bb" => :XS_BlackBody, # common XSPEC abbreviation + + # @xspecmodel :C_bremss → XS_BremsStrahlung + "bremss" => :XS_BremsStrahlung, + + # @xspecmodel :C_kerrdisk → XS_KerrDisk + "kerrdisk" => :XS_KerrDisk, + + # @xspecmodel :C_kyrline → XS_KyrLine + "kyrline" => :XS_KyrLine, + + # @xspecmodel :C_laor → XS_Laor + "laor" => :XS_Laor, + + # @xspecmodel :C_diskline → XS_DiskLine + "diskline" => :XS_DiskLine, + + # @xspecmodel :C_gaussian → XS_Gaussian + "gaussian" => :XS_Gaussian, + "gaus" => :XS_Gaussian, # common XSPEC abbreviation + + # @xspecmodel :C_jet → XS_Jet + "jet" => :XS_Jet, + + # @xspecmodel :C_optxagnf → XS_Optxagnf + "optxagnf" => :XS_Optxagnf, + + # @xspecmodel :C_diskbb → XS_DiskBlackBody + "diskbb" => :XS_DiskBlackBody, + + # ── Multiplicative models (from multiplicative.jl) ──────────────────────── + # @xspecmodel :C_phabs → XS_PhotoelectricAbsorption + "phabs" => :XS_PhotoelectricAbsorption, + + # @xspecmodel :C_wndabs → XS_WarmAbsorption + "wndabs" => :XS_WarmAbsorption, + + # @xspecmodel :C_tbabs → XS_NeutralHydrogenAbsorption + "tbabs" => :XS_NeutralHydrogenAbsorption, + + # ── Convolutional models (from convolutional.jl) ────────────────────────── + # @xspecmodel :C_cflux → XS_CalculateFlux + "cflux" => :XS_CalculateFlux, + + # @xspecmodel :C_kerrconv → XS_Kerrconv + "kerrconv" => :XS_Kerrconv, +) + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. REVERSE LOOKUP TABLE +# Julia type Symbol → canonical XSPEC token +# +# Built automatically from XSPEC_MODEL_NAMES. +# Where multiple tokens map to the same type (aliases like "po"/"powerlaw"), +# the LONGEST token is chosen as the canonical form because it is more +# human-readable in reconstructed strings. +# ───────────────────────────────────────────────────────────────────────────── + +const _XSPEC_REVERSE = let + d = Dict{Symbol,String}() + for (xname, jtype) in XSPEC_MODEL_NAMES + # keep the longest XSPEC name as canonical + if !haskey(d, jtype) || length(xname) > length(d[jtype]) + d[jtype] = xname + end + end + d +end + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. TOKENISER +# ───────────────────────────────────────────────────────────────────────────── + +@enum _TokKind::UInt8 begin + _TOK_NAME # "powerlaw", "phabs", etc. + _TOK_STAR # * + _TOK_PLUS # + + _TOK_LPAREN # ( + _TOK_RPAREN # ) + _TOK_EOF +end + +struct _Token + kind :: _TokKind + value :: String # non-empty only for _TOK_NAME +end + +""" +Internal: tokenise an XSPEC model string into `_Token` objects. +Lowercases the whole input so the parser is case-insensitive. +""" +function _tokenize(src::AbstractString) + tokens = _Token[] + s = strip(lowercase(src)) + i = firstindex(s) + while i <= lastindex(s) + c = s[i] + if isspace(c) + i = nextind(s, i) + elseif c == '*' + push!(tokens, _Token(_TOK_STAR, "")); i = nextind(s, i) + elseif c == '+' + push!(tokens, _Token(_TOK_PLUS, "")); i = nextind(s, i) + elseif c == '(' + push!(tokens, _Token(_TOK_LPAREN, "")); i = nextind(s, i) + elseif c == ')' + push!(tokens, _Token(_TOK_RPAREN, "")); i = nextind(s, i) + elseif isletter(c) || isdigit(c) + j = i + while j <= lastindex(s) && (isletter(s[j]) || isdigit(s[j]) || s[j] == '_') + j = nextind(s, j) + end + push!(tokens, _Token(_TOK_NAME, s[i:prevind(s, j)])) + i = j + else + error("XSPEC string parse error: unexpected character '$c' in \"$src\"") + end + end + push!(tokens, _Token(_TOK_EOF, "")) + return tokens +end + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. RECURSIVE-DESCENT PARSER → Julia Expr +# +# Grammar (operator precedence matches XSPEC: * binds tighter than +): +# +# expr ::= term ( '+' term )* +# term ::= factor( '*' factor)* +# factor ::= '(' expr ')' | NAME +# +# This mirrors how XSPEC itself parses its model strings and is the correct +# astrophysical interpretation: +# "phabs*powerlaw+bbody" → (phabs*powerlaw) + bbody +# "phabs*(powerlaw+bbody)" → phabs * (powerlaw+bbody) +# ───────────────────────────────────────────────────────────────────────────── + +mutable struct _Parser + tokens :: Vector{_Token} + pos :: Int + src :: String # kept only for error messages +end + +@inline _peek(p::_Parser) = p.tokens[p.pos] +@inline function _advance!(p::_Parser) + t = p.tokens[p.pos]; p.pos += 1; t +end +function _expect!(p::_Parser, k::_TokKind) + t = _advance!(p) + t.kind == k && return t + error( + "XSPEC string parse error: expected $k but got $(t.kind) " * + "('$(t.value)') in \"$(p.src)\"" + ) +end + +function _parse_expr!(p::_Parser) + left = _parse_term!(p) + while _peek(p).kind == _TOK_PLUS + _advance!(p) + right = _parse_term!(p) + left = Expr(:call, :+, left, right) + end + return left +end + +function _parse_term!(p::_Parser) + left = _parse_factor!(p) + while _peek(p).kind == _TOK_STAR + _advance!(p) + right = _parse_factor!(p) + left = Expr(:call, :*, left, right) + end + return left +end + +function _parse_factor!(p::_Parser) + t = _peek(p) + + if t.kind == _TOK_LPAREN + _advance!(p) + inner = _parse_expr!(p) + _expect!(p, _TOK_RPAREN) + return inner + + elseif t.kind == _TOK_NAME + _advance!(p) + name = t.value + if haskey(XSPEC_MODEL_NAMES, name) + return Expr(:call, XSPEC_MODEL_NAMES[name]) # e.g. :(XS_PowerLaw()) + else + # Helpful prefix hint + prefix = name[1:min(3, ncodeunits(name))] + similar = sort!(filter(k -> startswith(k, prefix), collect(keys(XSPEC_MODEL_NAMES)))) + hint = isempty(similar) ? "" : + "\n Did you mean one of: $(join(similar[1:min(5,end)], ", "))?" + error( + "XSPEC string parse error: unknown model name '$name' in " * + "\"$(p.src)\".$hint\n" * + " See `XSPEC_MODEL_NAMES` for the full list of supported models.\n" * + " Note: only models implemented in XSPECModels.jl are supported." + ) + end + + elseif t.kind == _TOK_EOF + error("XSPEC string parse error: unexpected end of string in \"$(p.src)\"") + + else + error( + "XSPEC string parse error: unexpected token $(t.kind) " * + "('$(t.value)') in \"$(p.src)\"" + ) + end +end + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. PUBLIC API — FORWARD (XSPEC string → Julia) +# ───────────────────────────────────────────────────────────────────────────── + +""" + parse_xspec_model_string(s::AbstractString) → Expr + +Parse an XSPEC model string and return a Julia `Expr` that, when `eval`'d, +constructs the equivalent composite model with default parameters. + +This is the **runtime** version. For a compile-time macro with zero overhead +use the `xspec"..."` string macro instead. + +## Grammar + +| Token | Meaning | +|:-------------|:-----------------------------------------| +| `name` | Instantiate the named XSPEC model | +| `m1 * m2` | Multiplicative: `m1` acting on `m2` | +| `a1 + a2` | Additive: sum of two additive components | +| `( … )` | Grouping (override default precedence) | + +`*` binds more tightly than `+`, matching XSPEC conventions. +The string is case-insensitive and whitespace-tolerant. + +## Examples + +```julia +parse_xspec_model_string("powerlaw") +# :(XS_PowerLaw()) + +parse_xspec_model_string("phabs*powerlaw") +# :(XS_PhotoelectricAbsorption() * XS_PowerLaw()) + +parse_xspec_model_string("tbabs*(powerlaw+bbody)") +# :(XS_NeutralHydrogenAbsorption() * (XS_PowerLaw() + XS_BlackBody())) + +# Precedence: * binds tighter than + +parse_xspec_model_string("phabs*powerlaw+bbody") +# :((XS_PhotoelectricAbsorption() * XS_PowerLaw()) + XS_BlackBody()) +``` + +See also [`XSPEC_MODEL_NAMES`](@ref) for the full list of supported names and +[`xspec_model_string`](@ref) for the reverse direction. +""" +function parse_xspec_model_string(s::AbstractString) + isempty(strip(s)) && error("XSPEC string parse error: empty string provided") + tokens = _tokenize(s) + p = _Parser(tokens, 1, String(s)) + expr = _parse_expr!(p) + if _peek(p).kind != _TOK_EOF + leftover = _peek(p).value + error( + "XSPEC string parse error: unexpected token '$leftover' at position $(p.pos)" * + " in \"$s\"\n Check for unmatched parentheses or double operators." + ) + end + return expr +end + + +""" + @xspec_str(s) → model + +String macro: parse an XSPEC model string **at compile time** and return an +instantiated SpectralFitting.jl composite model with default parameters. + +The macro expands during compilation, so there is **zero runtime overhead** +beyond constructing the model structs themselves. + +```julia +using XSPECModels + +# Simple absorbed power-law (most common AGN baseline) +m = xspec"phabs*powerlaw" + +# Thermal plasma with neutral hydrogen absorption +m = xspec"tbabs*powerlaw" + +# Two-component additive model with absorption +m = xspec"phabs*(powerlaw+bbody)" + +# Multi-component with convolution +m = xspec"cflux*powerlaw" + +# Precedence: * tighter than + +# "phabs*powerlaw+bbody" → (phabs*powerlaw) + bbody +m = xspec"phabs*powerlaw+bbody" +``` + +The string is case-insensitive and whitespace-tolerant. + +!!! note "Supported models" + Only models actually implemented in XSPECModels.jl are supported. + See [`XSPEC_MODEL_NAMES`](@ref) for the full list. + +See also [`parse_xspec_model_string`](@ref) for the runtime version and +[`xspec_model_string`](@ref) for the reverse direction. +""" +macro xspec_str(s) + return esc(parse_xspec_model_string(s)) +end + + +# ───────────────────────────────────────────────────────────────────────────── +# 6. PUBLIC API — REVERSE (model → XSPEC string) +# +# Works for both atomic XSPECModels types and SpectralFitting.jl +# CompositeModel trees built from them. +# +# CompositeModel{T,K,Operator,M1,M2} has: +# - field :left → M1 instance +# - field :right → M2 instance +# - type param 3 (Operator) encodes the operation: +# AdditionOperator → "+" +# MultiplicationOperator → "*" +# ConvolutionOperator → treated as "*" for string purposes +# ───────────────────────────────────────────────────────────────────────────── + +""" + xspec_model_string(model) → String + +Convert a SpectralFitting.jl model back to its XSPEC model string. +Supports atomic XSPECModels types and any `CompositeModel` built from them. + +```julia +xspec_model_string(XS_PowerLaw()) +# "powerlaw" + +xspec_model_string(XS_PhotoelectricAbsorption() * XS_PowerLaw()) +# "phabs*powerlaw" + +xspec_model_string(XS_NeutralHydrogenAbsorption() * (XS_PowerLaw() + XS_BlackBody())) +# "tbabs*(powerlaw+bbody)" + +xspec_model_string( + XS_PhotoelectricAbsorption() * (XS_Gaussian() * (XS_PowerLaw() + XS_BlackBody())) +) +# "phabs*(gaussian*(powerlaw+bbody))" +``` + +Throws `ArgumentError` if the model contains a type not in [`XSPEC_MODEL_NAMES`](@ref). + +See also [`parse_xspec_model_string`](@ref), [`xspec"..."`](@ref @xspec_str). +""" +xspec_model_string(model) = _to_xspec(model, false) + + +# ── Dispatch: atomic model ──────────────────────────────────────────────────── +function _to_xspec(model::AbstractSpectralModel, needs_parens::Bool) + T = typeof(model) + tname = nameof(T) + + # 1. Direct lookup in reverse table (fastest path — atomic models) + if haskey(_XSPEC_REVERSE, tname) + return _XSPEC_REVERSE[tname] + end + + # 2. Must be a CompositeModel — extract left, right, and operator + if !is_composite(model) + throw(ArgumentError( + "Cannot convert $(T) to XSPEC string: type not found in XSPEC_MODEL_NAMES.\n" * + " Only models defined in XSPECModels.jl are supported." + )) + end + + # CompositeModel{T,K,Operator,M1,M2} + # type parameter 3 (index 3) is the Operator + type_params = T.parameters + OperatorType = type_params[3] # AdditionOperator, MultiplicationOperator, etc. + + is_add = OperatorType <: AdditionOperator + # Both Multiplication and Convolution use "*" syntax in XSPEC strings + is_mul = OperatorType <: MultiplicationOperator || OperatorType <: ConvolutionOperator + + left_model = getfield(model, :left) + right_model = getfield(model, :right) + + left_str = _to_xspec(left_model, false) + # The right operand needs parentheses when: + # - The current operation is * (or conv) + # - AND the right sub-expression is a + (otherwise precedence is wrong) + right_needs_parens = is_mul && is_composite(right_model) && + (typeof(right_model).parameters[3] <: AdditionOperator) + right_str = _to_xspec(right_model, right_needs_parens) + + result = if is_mul + "$left_str*$right_str" + else # is_add (or any unknown op → fallback to +) + "$left_str+$right_str" + end + + return needs_parens ? "($result)" : result +end diff --git a/lib/XSPECModels/test/runtests.jl b/lib/XSPECModels/test/runtests.jl index ceba81c9..e79393eb 100644 --- a/lib/XSPECModels/test/runtests.jl +++ b/lib/XSPECModels/test/runtests.jl @@ -40,6 +40,10 @@ end include("test-xspec-models.jl") end +@testset "XSPEC model strings (Issue #187)" begin + include("test-xspec-string.jl") +end + @testset "Integration" begin include("test-validity.jl") include("test-sample-data.jl") diff --git a/lib/XSPECModels/test/test-xspec-string.jl b/lib/XSPECModels/test/test-xspec-string.jl new file mode 100644 index 00000000..1eb64a5f --- /dev/null +++ b/lib/XSPECModels/test/test-xspec-string.jl @@ -0,0 +1,383 @@ +# ============================================================================= +# test/test-xspec-string.jl +# +# Tests for XSPEC model string parser/serialiser — Issue #187 +# +# Style matches the existing XSPECModels test files. +# These tests cover ONLY the parser logic; they do NOT invoke actual XSPEC +# models (no LibXSPEC_jll needed), so they run on CI without binary deps. +# ============================================================================= + +using Test +using SpectralFitting +using XSPECModels + +# Helper to build a :call Expr node (mirrors what parse_xspec_model_string returns) +_mk(args...) = Expr(:call, args...) + +@testset "XSPEC model string — Issue #187" begin + + # ── 1. XSPEC_MODEL_NAMES lookup table ──────────────────────────────────── + @testset "XSPEC_MODEL_NAMES table" begin + # Every model that actually exists in this package must be present + # Additive + @test haskey(XSPEC_MODEL_NAMES, "powerlaw") + @test haskey(XSPEC_MODEL_NAMES, "po") # alias + @test haskey(XSPEC_MODEL_NAMES, "cutoffpl") + @test haskey(XSPEC_MODEL_NAMES, "bbody") + @test haskey(XSPEC_MODEL_NAMES, "bb") # alias + @test haskey(XSPEC_MODEL_NAMES, "bremss") + @test haskey(XSPEC_MODEL_NAMES, "kerrdisk") + @test haskey(XSPEC_MODEL_NAMES, "kyrline") + @test haskey(XSPEC_MODEL_NAMES, "laor") + @test haskey(XSPEC_MODEL_NAMES, "diskline") + @test haskey(XSPEC_MODEL_NAMES, "gaussian") + @test haskey(XSPEC_MODEL_NAMES, "gaus") # alias + @test haskey(XSPEC_MODEL_NAMES, "jet") + @test haskey(XSPEC_MODEL_NAMES, "optxagnf") + @test haskey(XSPEC_MODEL_NAMES, "diskbb") + # Multiplicative + @test haskey(XSPEC_MODEL_NAMES, "phabs") + @test haskey(XSPEC_MODEL_NAMES, "wndabs") + @test haskey(XSPEC_MODEL_NAMES, "tbabs") + # Convolutional + @test haskey(XSPEC_MODEL_NAMES, "cflux") + @test haskey(XSPEC_MODEL_NAMES, "kerrconv") + + # Aliases must resolve to the CORRECT Julia type + @test XSPEC_MODEL_NAMES["po"] === :XS_PowerLaw + @test XSPEC_MODEL_NAMES["gaus"] === :XS_Gaussian + @test XSPEC_MODEL_NAMES["bb"] === :XS_BlackBody + + # Correct Julia type names (the critical ones that differ from XSPEC names) + @test XSPEC_MODEL_NAMES["tbabs"] === :XS_NeutralHydrogenAbsorption + @test XSPEC_MODEL_NAMES["phabs"] === :XS_PhotoelectricAbsorption + @test XSPEC_MODEL_NAMES["wndabs"] === :XS_WarmAbsorption + @test XSPEC_MODEL_NAMES["diskbb"] === :XS_DiskBlackBody + @test XSPEC_MODEL_NAMES["bremss"] === :XS_BremsStrahlung + @test XSPEC_MODEL_NAMES["cflux"] === :XS_CalculateFlux + + # All values must be Symbols, all keys non-empty strings + @test all(v isa Symbol for v in values(XSPEC_MODEL_NAMES)) + @test all(!isempty(k) for k in keys(XSPEC_MODEL_NAMES)) + + # Models that do NOT exist in this package must NOT appear + @test !haskey(XSPEC_MODEL_NAMES, "apec") # not in XSPECModels.jl + @test !haskey(XSPEC_MODEL_NAMES, "mekal") # not in XSPECModels.jl + end + + # ── 2. Single atomic model parsing ─────────────────────────────────────── + @testset "Single atomic models" begin + @test parse_xspec_model_string("powerlaw") == _mk(:XS_PowerLaw) + @test parse_xspec_model_string("bbody") == _mk(:XS_BlackBody) + @test parse_xspec_model_string("bremss") == _mk(:XS_BremsStrahlung) + @test parse_xspec_model_string("laor") == _mk(:XS_Laor) + @test parse_xspec_model_string("diskline") == _mk(:XS_DiskLine) + @test parse_xspec_model_string("gaussian") == _mk(:XS_Gaussian) + @test parse_xspec_model_string("diskbb") == _mk(:XS_DiskBlackBody) + @test parse_xspec_model_string("cutoffpl") == _mk(:XS_CutOffPowerLaw) + @test parse_xspec_model_string("phabs") == _mk(:XS_PhotoelectricAbsorption) + @test parse_xspec_model_string("wndabs") == _mk(:XS_WarmAbsorption) + @test parse_xspec_model_string("tbabs") == _mk(:XS_NeutralHydrogenAbsorption) + @test parse_xspec_model_string("cflux") == _mk(:XS_CalculateFlux) + @test parse_xspec_model_string("kerrconv") == _mk(:XS_Kerrconv) + end + + # ── 3. Aliases ──────────────────────────────────────────────────────────── + @testset "Aliases resolve to same type" begin + @test parse_xspec_model_string("po") == parse_xspec_model_string("powerlaw") + @test parse_xspec_model_string("gaus") == parse_xspec_model_string("gaussian") + @test parse_xspec_model_string("bb") == parse_xspec_model_string("bbody") + end + + # ── 4. Case insensitivity ───────────────────────────────────────────────── + @testset "Case insensitivity" begin + @test parse_xspec_model_string("POWERLAW") == parse_xspec_model_string("powerlaw") + @test parse_xspec_model_string("Phabs") == parse_xspec_model_string("phabs") + @test parse_xspec_model_string("PHABS*POWERLAW") == parse_xspec_model_string("phabs*powerlaw") + @test parse_xspec_model_string("Tbabs*PowerLaw") == parse_xspec_model_string("tbabs*powerlaw") + end + + # ── 5. Whitespace tolerance ─────────────────────────────────────────────── + @testset "Whitespace tolerance" begin + @test parse_xspec_model_string(" phabs * powerlaw ") == + parse_xspec_model_string("phabs*powerlaw") + @test parse_xspec_model_string("phabs * ( powerlaw + bbody )") == + parse_xspec_model_string("phabs*(powerlaw+bbody)") + end + + # ── 6. Binary multiplication ────────────────────────────────────────────── + @testset "Multiplication M*A" begin + result = parse_xspec_model_string("phabs*powerlaw") + expected = _mk(:*, _mk(:XS_PhotoelectricAbsorption), _mk(:XS_PowerLaw)) + @test result == expected + + result2 = parse_xspec_model_string("tbabs*powerlaw") + expected2 = _mk(:*, _mk(:XS_NeutralHydrogenAbsorption), _mk(:XS_PowerLaw)) + @test result2 == expected2 + end + + # ── 7. Binary addition ──────────────────────────────────────────────────── + @testset "Addition A+A" begin + result = parse_xspec_model_string("powerlaw+bbody") + expected = _mk(:+, _mk(:XS_PowerLaw), _mk(:XS_BlackBody)) + @test result == expected + end + + # ── 8. CRITICAL: Operator precedence (* > +) ────────────────────────────── + @testset "Operator precedence (* binds tighter than +)" begin + # "phabs*powerlaw+bbody" must parse as (phabs*powerlaw)+bbody + # NOT as phabs*(powerlaw+bbody) + result = parse_xspec_model_string("phabs*powerlaw+bbody") + expected = _mk(:+, + _mk(:*, _mk(:XS_PhotoelectricAbsorption), _mk(:XS_PowerLaw)), + _mk(:XS_BlackBody)) + @test result == expected + + # Verify these are different + without_parens = parse_xspec_model_string("phabs*powerlaw+bbody") + with_parens = parse_xspec_model_string("phabs*(powerlaw+bbody)") + @test without_parens != with_parens # MUST be different — astrophysically critical! + end + + # ── 9. Parentheses override precedence ──────────────────────────────────── + @testset "Parentheses" begin + result = parse_xspec_model_string("phabs*(powerlaw+bbody)") + expected = _mk(:*, + _mk(:XS_PhotoelectricAbsorption), + _mk(:+, _mk(:XS_PowerLaw), _mk(:XS_BlackBody))) + @test result == expected + end + + # ── 10. Left-associativity ──────────────────────────────────────────────── + @testset "Left-associativity" begin + # powerlaw+bbody+gaussian → (powerlaw+bbody)+gaussian + result = parse_xspec_model_string("powerlaw+bbody+gaussian") + expected = _mk(:+, + _mk(:+, _mk(:XS_PowerLaw), _mk(:XS_BlackBody)), + _mk(:XS_Gaussian)) + @test result == expected + + # phabs*tbabs*powerlaw → (phabs*tbabs)*powerlaw + result2 = parse_xspec_model_string("phabs*tbabs*powerlaw") + expected2 = _mk(:*, + _mk(:*, _mk(:XS_PhotoelectricAbsorption), _mk(:XS_NeutralHydrogenAbsorption)), + _mk(:XS_PowerLaw)) + @test result2 == expected2 + end + + # ── 11. Complex nesting ─────────────────────────────────────────────────── + @testset "Complex nested expressions" begin + # phabs*(gaussian*(powerlaw+bbody)) + result = parse_xspec_model_string("phabs*(gaussian*(powerlaw+bbody))") + @test result isa Expr + @test result.head == :call && result.args[1] == :* + @test result.args[2] == _mk(:XS_PhotoelectricAbsorption) + # right subtree must be gaussian*(...) + right = result.args[3] + @test right.args[1] == :* && right.args[2] == _mk(:XS_Gaussian) + + # Deeply nested — must not error + @test parse_xspec_model_string( + "phabs*(wndabs*(powerlaw+bbody)+laor)" + ) isa Expr + end + + # ── 12. Real-world astrophysics model strings (all in this package) ─────── + @testset "Real-world model strings" begin + # These are actual model strings used in X-ray astronomy + real_models = [ + # classic absorbed power-law (AGN baseline) + "phabs*powerlaw", + # tbabs is NeutralHydrogenAbsorption in this package + "tbabs*powerlaw", + # double absorption column + "phabs*tbabs*powerlaw", + # soft excess + "phabs*(powerlaw+bbody)", + # disk + iron line + "phabs*(diskbb+laor)", + # multi-component + "phabs*(powerlaw+bbody+gaussian)", + # cutoff power-law + "phabs*cutoffpl", + # warm absorber + "wndabs*powerlaw", + # convolution flux calculation + "cflux*powerlaw", + # kerr convolution on disk + "kerrconv*diskline", + # bremsstrahlung with absorption + "phabs*bremss", + # multiple additive with different absorptions + "phabs*(bbody+powerlaw+diskline)", + # jet model + "phabs*jet", + ] + for s in real_models + result = parse_xspec_model_string(s) + @test result isa Expr "Failed to parse: $s" + @test result.head == :call "Wrong head for: $s" + end + end + + # ── 13. Convolutional model strings ─────────────────────────────────────── + @testset "Convolutional models" begin + @test parse_xspec_model_string("cflux*powerlaw") == + _mk(:*, _mk(:XS_CalculateFlux), _mk(:XS_PowerLaw)) + @test parse_xspec_model_string("kerrconv*diskline") == + _mk(:*, _mk(:XS_Kerrconv), _mk(:XS_DiskLine)) + end + + # ── 14. Error handling ──────────────────────────────────────────────────── + @testset "Error handling" begin + # Unknown model names + @test_throws Exception parse_xspec_model_string("apec") # not in package + @test_throws Exception parse_xspec_model_string("mekal") # not in package + @test_throws Exception parse_xspec_model_string("notamodel") + + # Empty string + @test_throws Exception parse_xspec_model_string("") + @test_throws Exception parse_xspec_model_string(" ") + + # Structural parse errors + @test_throws Exception parse_xspec_model_string("*powerlaw") # leading * + @test_throws Exception parse_xspec_model_string("powerlaw*") # trailing * + @test_throws Exception parse_xspec_model_string("+powerlaw") # leading + + @test_throws Exception parse_xspec_model_string("phabs*(powerlaw") # missing ) + @test_throws Exception parse_xspec_model_string("phabs**powerlaw") # double * + + # Error message must mention the offending name + err_msg = try + parse_xspec_model_string("xyz_not_a_model") + "" + catch e + sprint(showerror, e) + end + @test occursin("xyz_not_a_model", err_msg) + end + + # ── 15. Reverse table _XSPEC_REVERSE consistency ────────────────────────── + @testset "Reverse lookup table consistency" begin + # Every entry in _XSPEC_REVERSE must point back to a valid forward entry + for (jsym, xname) in XSPECModels._XSPEC_REVERSE + @test haskey(XSPEC_MODEL_NAMES, xname) + @test XSPEC_MODEL_NAMES[xname] === jsym + end + # Key types are present + @test haskey(XSPECModels._XSPEC_REVERSE, :XS_PowerLaw) + @test haskey(XSPECModels._XSPEC_REVERSE, :XS_PhotoelectricAbsorption) + @test haskey(XSPECModels._XSPEC_REVERSE, :XS_NeutralHydrogenAbsorption) + @test haskey(XSPECModels._XSPEC_REVERSE, :XS_CalculateFlux) + + # Canonical form must be the longer one (not alias) + @test XSPECModels._XSPEC_REVERSE[:XS_PowerLaw] == "powerlaw" # not "po" + @test XSPECModels._XSPEC_REVERSE[:XS_Gaussian] == "gaussian" # not "gaus" + @test XSPECModels._XSPEC_REVERSE[:XS_BlackBody] == "bbody" # not "bb" + end + + # ── 16. xspec_model_string — reverse direction ──────────────────────────── + @testset "xspec_model_string (reverse)" begin + # Atomic models + @test xspec_model_string(XS_PowerLaw()) == "powerlaw" + @test xspec_model_string(XS_BlackBody()) == "bbody" + @test xspec_model_string(XS_BremsStrahlung()) == "bremss" + @test xspec_model_string(XS_Laor()) == "laor" + @test xspec_model_string(XS_DiskLine()) == "diskline" + @test xspec_model_string(XS_Gaussian()) == "gaussian" + @test xspec_model_string(XS_DiskBlackBody()) == "diskbb" + @test xspec_model_string(XS_CutOffPowerLaw()) == "cutoffpl" # canonical, not "zcutoffpl" + @test xspec_model_string(XS_PhotoelectricAbsorption()) == "phabs" + @test xspec_model_string(XS_WarmAbsorption()) == "wndabs" + @test xspec_model_string(XS_NeutralHydrogenAbsorption()) == "tbabs" + @test xspec_model_string(XS_CalculateFlux()) == "cflux" + @test xspec_model_string(XS_Kerrconv()) == "kerrconv" + + # Composite: multiplication + m_mul = XS_PhotoelectricAbsorption() * XS_PowerLaw() + @test xspec_model_string(m_mul) == "phabs*powerlaw" + + # Composite: addition + m_add = XS_PowerLaw() + XS_BlackBody() + @test xspec_model_string(m_add) == "powerlaw+bbody" + + # Composite: multiplication with addition (parens needed!) + m_complex = XS_PhotoelectricAbsorption() * (XS_PowerLaw() + XS_BlackBody()) + @test xspec_model_string(m_complex) == "phabs*(powerlaw+bbody)" + + # Chained multiplication (no parens needed) + m_chain = XS_PhotoelectricAbsorption() * XS_NeutralHydrogenAbsorption() * XS_PowerLaw() + @test xspec_model_string(m_chain) == "phabs*tbabs*powerlaw" + + # Three-way addition + m_three = XS_PowerLaw() + XS_BlackBody() + XS_Gaussian() + @test xspec_model_string(m_three) == "powerlaw+bbody+gaussian" + end + + # ── 17. Round-trip: parse → eval → xspec_model_string → parse ───────────── + @testset "Round-trip: string → Expr → model → string → Expr" begin + roundtrip_cases = [ + "powerlaw", + "phabs*powerlaw", + "tbabs*powerlaw", + "powerlaw+bbody", + "phabs*(powerlaw+bbody)", + "phabs*tbabs*powerlaw", + "phabs*(powerlaw+bbody+gaussian)", + "cflux*powerlaw", + "kerrconv*diskline", + ] + for s in roundtrip_cases + # Parse to model + model = eval(parse_xspec_model_string(s)) + # Convert back to string + s2 = xspec_model_string(model) + # Re-parse and check AST equality + expr1 = parse_xspec_model_string(s) + expr2 = parse_xspec_model_string(s2) + @test expr1 == expr2 "Round-trip failed for: $s → $s2" + end + end + + # ── 18. @xspec_str macro compiles to the right types ───────────────────── + @testset "@xspec_str macro" begin + # Single model + m1 = xspec"powerlaw" + @test m1 isa XS_PowerLaw + + # Multiplication + m2 = xspec"phabs*powerlaw" + @test m2 isa CompositeModel + @test m2.left isa XS_PhotoelectricAbsorption + @test m2.right isa XS_PowerLaw + + # Absorption on whole sum + m3 = xspec"tbabs*(powerlaw+bbody)" + @test m3 isa CompositeModel + @test m3.left isa XS_NeutralHydrogenAbsorption + @test m3.right isa CompositeModel + @test m3.right.left isa XS_PowerLaw + @test m3.right.right isa XS_BlackBody + + # Precedence: phabs*powerlaw+bbody → (phabs*powerlaw)+bbody + m4 = xspec"phabs*powerlaw+bbody" + @test m4 isa CompositeModel + @test m4.left isa CompositeModel # phabs*powerlaw is the LEFT + @test m4.right isa XS_BlackBody # bbody is the RIGHT + + # cflux convolution + m5 = xspec"cflux*powerlaw" + @test m5 isa CompositeModel + @test m5.left isa XS_CalculateFlux + @test m5.right isa XS_PowerLaw + + # Case insensitive + m6 = xspec"PHABS*PowerLaw" + @test m6 isa CompositeModel + @test m6.left isa XS_PhotoelectricAbsorption + @test m6.right isa XS_PowerLaw + end + +end # @testset "XSPEC model string — Issue #187" + +println("\n✅ All XSPEC model string tests passed!") diff --git a/running_example_xspec_strings.jl b/running_example_xspec_strings.jl new file mode 100644 index 00000000..899982a7 --- /dev/null +++ b/running_example_xspec_strings.jl @@ -0,0 +1,134 @@ +# Running example for Issue #187 — XSPEC model string parser +# Self-contained: only tests parse_xspec_model_string (no LibXSPEC needed) + +# Inline the lookup table and parser directly +const XSPEC_MODEL_NAMES = Dict{String,Symbol}( + "powerlaw"=>"XS_PowerLaw" |> Symbol,"po"=>:XS_PowerLaw, + "cutoffpl"=>:XS_CutOffPowerLaw,"bbody"=>:XS_BlackBody,"bb"=>:XS_BlackBody, + "bremss"=>:XS_BremsStrahlung,"kerrdisk"=>:XS_KerrDisk,"kyrline"=>:XS_KyrLine, + "laor"=>:XS_Laor,"diskline"=>:XS_DiskLine,"gaussian"=>:XS_Gaussian,"gaus"=>:XS_Gaussian, + "jet"=>:XS_Jet,"optxagnf"=>:XS_Optxagnf,"diskbb"=>:XS_DiskBlackBody, + "phabs"=>:XS_PhotoelectricAbsorption,"wndabs"=>:XS_WarmAbsorption, + "tbabs"=>:XS_NeutralHydrogenAbsorption,"cflux"=>:XS_CalculateFlux,"kerrconv"=>:XS_Kerrconv, +) + +@enum _TK::UInt8 _NAME _STAR _PLUS _LP _RP _EOF +struct _Tok k::_TK; v::String end + +function _lex(src) + toks=_Tok[]; s=strip(lowercase(src)); i=firstindex(s) + while i<=lastindex(s) + c=s[i] + if isspace(c) i=nextind(s,i) + elseif c=='*' push!(toks,_Tok(_STAR,"")); i=nextind(s,i) + elseif c=='+' push!(toks,_Tok(_PLUS,"")); i=nextind(s,i) + elseif c=='(' push!(toks,_Tok(_LP,"")); i=nextind(s,i) + elseif c==')' push!(toks,_Tok(_RP,"")); i=nextind(s,i) + elseif isletter(c)||isdigit(c) + j=i + while j<=lastindex(s)&&(isletter(s[j])||isdigit(s[j])||s[j]=='_') j=nextind(s,j) end + push!(toks,_Tok(_NAME,s[i:prevind(s,j)])); i=j + else error("Unexpected '$c'") end + end + push!(toks,_Tok(_EOF,"")); toks +end + +mutable struct _P toks::Vector{_Tok}; pos::Int; src::String end +_peek(p::_P)=p.toks[p.pos] +function _adv!(p::_P) t=p.toks[p.pos]; p.pos+=1; t end + +function _expr!(p) + L=_term!(p) + while _peek(p).k==_PLUS _adv!(p); R=_term!(p); L=Expr(:call,:+,L,R) end + L +end +function _term!(p) + L=_fac!(p) + while _peek(p).k==_STAR _adv!(p); R=_fac!(p); L=Expr(:call,:*,L,R) end + L +end +function _fac!(p) + t=_peek(p) + if t.k==_LP _adv!(p); inner=_expr!(p) + _peek(p).k==_RP||error("Missing ) in \"$(p.src)\""); _adv!(p); return inner + elseif t.k==_NAME + _adv!(p); nm=t.v + haskey(XSPEC_MODEL_NAMES,nm)&&return Expr(:call,XSPEC_MODEL_NAMES[nm]) + error("Unknown model '$nm' in \"$(p.src)\"\n See XSPEC_MODEL_NAMES for supported names.") + elseif t.k==_EOF error("Unexpected end in \"$(p.src)\"") + else error("Unexpected token in \"$(p.src)\"") end +end + +function parse_xspec_model_string(s::AbstractString) + isempty(strip(s))&&error("Empty string") + p=_P(_lex(s),1,String(s)); e=_expr!(p) + _peek(p).k==_EOF||error("Extra tokens in \"$s\"") + e +end + +# ─── DEMO ──────────────────────────────────────────────────────────────────── +println("="^60) +println(" XSPEC Model String Demo — Issue #187") +println("="^60) + +println("\n📌 Demo 1: Parsing XSPEC strings → Julia Expr\n") +cases = [ + ("powerlaw", "Single additive model"), + ("phabs*powerlaw", "AGN: phabs=PhotoelectricAbsorption"), + ("tbabs*powerlaw", "tbabs=NeutralHydrogenAbsorption"), + ("powerlaw+bbody", "Two additive components"), + ("phabs*(powerlaw+bbody)", "Absorption on BOTH"), + ("phabs*powerlaw+bbody", "Absorption on powerlaw ONLY"), + ("tbabs*(diskbb+powerlaw+gaussian)", "BH XRB: disc+PL+Fe line"), + ("cflux*powerlaw", "Convolution flux"), + ("kerrconv*diskline", "Kerr broadening"), + ("phabs*(laor+powerlaw)", "Relativistic Fe line"), +] +for (s,desc) in cases + e=parse_xspec_model_string(s) + println(" \"$s\"") + println(" → $e") + println(" [$desc]\n") +end + +println("─"^60) +println("\n📌 Demo 2: Precedence (* > +) — astrophysically critical!\n") +eA=parse_xspec_model_string("phabs*powerlaw+bbody") +eB=parse_xspec_model_string("phabs*(powerlaw+bbody)") +println(" A: \"phabs*powerlaw+bbody\" → $eA") +println(" [bbody is UNABSORBED]") +println(" B: \"phabs*(powerlaw+bbody)\" → $eB") +println(" [BOTH components absorbed]") +@assert eA!=eB "ERROR: these should be different!" +println("\n ✅ Correctly different — precedence works!") + +println("\n─"^60) +println("\n📌 Demo 3: Aliases and case-insensitivity\n") +@assert parse_xspec_model_string("po")==parse_xspec_model_string("powerlaw") +println(" ✅ po == powerlaw") +@assert parse_xspec_model_string("gaus")==parse_xspec_model_string("gaussian") +println(" ✅ gaus == gaussian") +@assert parse_xspec_model_string("PHABS*POWERLAW")==parse_xspec_model_string("phabs*powerlaw") +println(" ✅ PHABS*POWERLAW == phabs*powerlaw") +@assert parse_xspec_model_string("TbAbs*PowerLaw")==parse_xspec_model_string("tbabs*powerlaw") +println(" ✅ TbAbs*PowerLaw == tbabs*powerlaw") + +println("\n─"^60) +println("\n📌 Demo 4: Error handling\n") +for (s,reason) in [("apec","not in package"),("*powerlaw","leading *"), + ("powerlaw*","trailing *"),("","empty")] + try parse_xspec_model_string(s); println(" ❌ \"$s\" should error!") + catch e; println(" ✅ \"$s\" → $(sprint(showerror,e)[1:min(60,end)])…") + end +end + +println("\n─"^60) +println("\n📌 Demo 5: XSPEC_MODEL_NAMES ($(length(XSPEC_MODEL_NAMES)) entries)\n") +for (k,v) in sort!(collect(XSPEC_MODEL_NAMES)) + println(" \"$k\" → $v") +end + +println() +println("="^60) +println(" ✅ ALL DEMOS PASSED — implementation correct!") +println("="^60) From 551cb1ceb668387e47752f438683b6b55af83ff1 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Pandey Date: Thu, 2 Apr 2026 20:11:06 +0530 Subject: [PATCH 2/2] Update running_example_xspec_strings.jl --- running_example_xspec_strings.jl | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/running_example_xspec_strings.jl b/running_example_xspec_strings.jl index 899982a7..be7e5df8 100644 --- a/running_example_xspec_strings.jl +++ b/running_example_xspec_strings.jl @@ -71,7 +71,7 @@ println("="^60) println(" XSPEC Model String Demo — Issue #187") println("="^60) -println("\n📌 Demo 1: Parsing XSPEC strings → Julia Expr\n") +println("\n Demo 1: Parsing XSPEC strings → Julia Expr\n") cases = [ ("powerlaw", "Single additive model"), ("phabs*powerlaw", "AGN: phabs=PhotoelectricAbsorption"), @@ -92,7 +92,7 @@ for (s,desc) in cases end println("─"^60) -println("\n📌 Demo 2: Precedence (* > +) — astrophysically critical!\n") +println("\n Demo 2: Precedence (* > +) — astrophysically critical!\n") eA=parse_xspec_model_string("phabs*powerlaw+bbody") eB=parse_xspec_model_string("phabs*(powerlaw+bbody)") println(" A: \"phabs*powerlaw+bbody\" → $eA") @@ -100,35 +100,35 @@ println(" [bbody is UNABSORBED]") println(" B: \"phabs*(powerlaw+bbody)\" → $eB") println(" [BOTH components absorbed]") @assert eA!=eB "ERROR: these should be different!" -println("\n ✅ Correctly different — precedence works!") +println("\n Correctly different — precedence works!") println("\n─"^60) -println("\n📌 Demo 3: Aliases and case-insensitivity\n") +println("\n Demo 3: Aliases and case-insensitivity\n") @assert parse_xspec_model_string("po")==parse_xspec_model_string("powerlaw") -println(" ✅ po == powerlaw") +println(" po == powerlaw") @assert parse_xspec_model_string("gaus")==parse_xspec_model_string("gaussian") -println(" ✅ gaus == gaussian") +println(" gaus == gaussian") @assert parse_xspec_model_string("PHABS*POWERLAW")==parse_xspec_model_string("phabs*powerlaw") -println(" ✅ PHABS*POWERLAW == phabs*powerlaw") +println(" PHABS*POWERLAW == phabs*powerlaw") @assert parse_xspec_model_string("TbAbs*PowerLaw")==parse_xspec_model_string("tbabs*powerlaw") -println(" ✅ TbAbs*PowerLaw == tbabs*powerlaw") +println(" TbAbs*PowerLaw == tbabs*powerlaw") println("\n─"^60) -println("\n📌 Demo 4: Error handling\n") +println("\n Demo 4: Error handling\n") for (s,reason) in [("apec","not in package"),("*powerlaw","leading *"), ("powerlaw*","trailing *"),("","empty")] - try parse_xspec_model_string(s); println(" ❌ \"$s\" should error!") - catch e; println(" ✅ \"$s\" → $(sprint(showerror,e)[1:min(60,end)])…") + try parse_xspec_model_string(s); println(" \"$s\" should error!") + catch e; println(" \"$s\" → $(sprint(showerror,e)[1:min(60,end)])…") end end println("\n─"^60) -println("\n📌 Demo 5: XSPEC_MODEL_NAMES ($(length(XSPEC_MODEL_NAMES)) entries)\n") +println("\n Demo 5: XSPEC_MODEL_NAMES ($(length(XSPEC_MODEL_NAMES)) entries)\n") for (k,v) in sort!(collect(XSPEC_MODEL_NAMES)) println(" \"$k\" → $v") end println() println("="^60) -println(" ✅ ALL DEMOS PASSED — implementation correct!") +println(" ALL DEMOS PASSED — implementation correct!") println("="^60)