The Bamberg v25 delivery carries four metal bands, Z = −2, −1, 0, +1 dex about the standard sdB pattern. Every fit in data/spek uses Z0 and never looks at the others. Two experiments, in order.
zfit.py the same spectrum on each band -- what do the metals move? zswap.py metallicity as the third fitted parameter instead of helium
Sample: the highest signal-to-noise sdOB and He-sdOB spectra in the BOSS archive, because He II is the line most exposed to metal blanketing and in those classes it carries the temperature. Fifteen stars screened, twelve usable — the rest never settled inside a window on any band, or pinned helium at the top of the grid, and the brightest He-sdOB of all (J06232+4019, S/N 86) is one of those: a fit against an edge measures the edge.
Method as in ../comparison: both windows slide, and sigma comes from the continuum windows rather than a running rms — with a running rms a worse model inflates its own error estimate and so lowers its reduced chi-square, which reverses the comparison.
zfit.dat)Z = +1 minus Z = −1, so two dex of metals:
| class | n | dTeff | dlog g | dnHe |
|---|---|---|---|---|
| sdOB | 6 | +1276 K | +0.062 | +0.014 (rms 0.065) |
| He-sdOB | 6 | +866 K | +0.360 | +0.275 (rms 0.261) |
Three things follow.
Temperature is what really cares. Both classes move about 1000 K over the range, so roughly ±600 K per dex of Z sits underneath every fit in the archive — larger than the formal Teff error on these spectra (300–500 K). Metal-rich models make the star hotter.
Helium is immune in the sdOB stars and not in the He-sdOB ones. 0.008 dex against 0.29. In a helium-dominated atmosphere helium and metals trade against each other, and log g goes with them (0.36 dex). So the axis swap below is well posed for sdOB and, strictly, ill posed for He-sdOB.
No star prefers a metal-poor model. Best reduced chi-square is Z=+1 for six and Z=0 for six; Z=−1 and Z=−2 never win, and chi-square rises monotonically toward metal-poor in almost every row.
zswap.dat)Helium is held at exactly the value the ordinary fit found — each band splined along its own helium axis to that value, not snapped to the nearest delivered file, because the helium points are half a dex apart at the metal-poor end — and Z is free over −2…+1. Three parameters float either way.
10 of 12 improve. Median reduced chi-square -0.06, mean improvement 5.5%. Best fitted Z is positive in 11 of 12: median +0.52 (sdOB), +0.41 (He-sdOB). Sum of the total chi-square change over the twelve stars: -895.
The two that do not improve (J17083+3001, J07284+4149) are the two that come back at Z ~ 0, which is the consistent answer rather than a failure.
The honest caveat. This is not quite like for like. Helium was fixed at a value measured at Z = 0, so the Z run has had four quantities determined from the data where the helium run had three, and part of the 5.5% is that extra freedom rather than a better model. What can be said without settling it: the data want more metals than the standard pattern, by about half a dex, in both classes and in eleven stars out of twelve.
Nothing in the interpolation ever knew that its third axis was helium. It splines over whatever numbers the axis carries, one axis at a time, and hands back that axis's own first derivative along with the value — which is what gives Levenberg–Marquardt an analytic Jacobian for the price of the interpolation it had to do anyway. So a set of models at one helium abundance and several metallicities makes a grid whose third parameter is Z, fitted exactly the way helium is.
for each band Z = -1, 0, +1:
load the six helium files bracketing the fitted abundance
spline along helium to that exact value -> a (log g, Teff) cube
stack the three cubes along Z -> the grid to fit
fit Teff, log g, Z; the spline returns d/dZ as it returned d/dHe
The one library change was to let GridSet.load take the axis values explicitly (axis=, axis_label=) instead of reading each file's own helium header. Z = -2 is loaded as well if the answer leans on the -1 edge.
Three parameters float either way — Teff, log g, and one of helium or Z — so the two reduced chi-squares compare directly. This is not a four-parameter fit: in the Z run helium is held.
Done — see section 3 above. What follows is how it works.
Floating Teff, log g, helium and Z together is the clean test. The arithmetic was written and checked in nd_prototype.py first, then moved into spgrid.
The derivative chain generalises to a loop. At each axis the value array is splined for its value and the new derivative, while each derivative carried from an earlier axis is splined for its value only: differentiating it again would be a second derivative, which the Jacobian does not want. The stack grows by one per axis, so k axes cost 1+2+...+k spline passes — six now, ten with four.
| check | result |
|---|---|
| all four analytic derivatives vs central finite differences | agree to 1.4e-9 … 1.9e-7 relative |
| the same routine on three axes vs the shipped interpolation | identical bit for bit, value and all three derivatives |
| memory for a four-axis block over a twelve-line window | 57 MB |
| time to assemble it | 2.3 s cold, 0.54 s warm |
The plumbing followed — the result object, the output writers, the bounds and the error scaling all counted to three. Two decisions were taken:
deliberately allowed to walk out of the grid, because that is a diagnostic and the driver slides the window; there is nowhere to slide Z, and a cubic through four points a dex apart is wild outside them.
nothing already published moves. At n ≈ 650–830 the difference from n − len(free) is 0.3%, far below anything measured here.
The three-axis path is unchanged: a regression baseline of six fits — parameters, errors, chi-square, and probe values from the interpolation itself — is reproduced bit for bit.
zfit4.dat)Teff, log g, helium and Z, on eighteen high signal-to-noise stars chosen to span He II 4686 strength — from sdB with the line absent (W = −0.18) through sdOB and sdO to He-sdOB (W = 2.4 Å). zfit4.py.
It converges. Eighteen of eighteen, and seventeen determine Z to 0.08–0.31 dex (median 0.18). Helium's error widens by only ×1.2 median. Reduced chi-square 1.46 → 1.35, better in 14 of 18, mean 6%.
The degeneracy is not the one we expected. Metallicity trades against temperature, not against helium:
| pair | median abs. correlation | worst |
|---|---|---|
| helium ↔ Z | 0.14 | 0.46 |
| temperature ↔ Z | 0.43 | 0.91 |
| gravity ↔ Z | 0.30 | 0.64 |
and the correlation predicts the damage. Freeing Z moves the temperature by a median of 800 K in the seven stars with |corr(Teff,Z)| > 0.5, and by 44 K in the ten below it — an eighteen-fold difference, split cleanly by a number computed from the covariance rather than from the shifts themselves. The worst case, J22226+0051, has corr = +0.91 and moves 5507 K.
That is the ±600 K per dex of Z from experiment 1, seen from the inside: the two are partially interchangeable, so fixing Z is what makes the temperature well determined in every three-parameter fit on this site.
He II 4686 does not gate it. The eight sdB stars with no He II at all determine Z as well as anything else (0.08–0.31 dex), and across the sample |corr(He,Z)| rises with He II strength rather than falling (rank correlation +0.36, not significant at n = 18) — the opposite of what one would expect if that line carried the metal signal alone. What constrains Z is blanketing across the whole spectrum: the Balmer lines already carry 2.9–3.3% of the band-to-band disagreement in sdOB, and at S/N 75–92 that is plenty.
One star is genuinely degenerate, and it is the coolest: J10233+0759 at 18.9 kK, where Z pins on the −2 bound with an error of 2.42 dex and everything is correlated — corr(Teff,Z) −0.985, corr(g,Z) −0.813, corr(He,Z) +0.728. Not "no He II" but "no metals to see": at 19 kK the blanketing is weakest, and the other seven He II-free stars sit at 22–32 kK where it still bites.
Caveats. The errors are formal, from the covariance with the continuum-window noise model, and the reduced chi-squares run 1.2–3.6. Eighteen stars is enough to see an 18-fold split but not to fit a trend. Z is bounded to the delivered range, so a star wanting more than +1 or less than −2 reports the bound and not a measurement.
zfit.fit rebuilds each fit directory from scratch, so anything another experiment left there goes with it — zswap.py writes Zfree.plot into the Z0 directory, and zfit4.py refits Z0 as its reference. Re-run in this order after touching any of them:
./zfit.py # the bands ./zswap.py <stars> # Z in helium's place, writes Zfree.* ./zfit4.py <stars> # all four free ./zpage.py # the pages ../mkindex.py # the front page, and the link check
zfit.html the bands: tiles, six figures, three tables zfit4.html all four parameters free fits/<Jname>_<band>/zcomp.html that star on every band, line by line fits/<Jname>_Z0/zcomp4.html three parameters against four, line by line
Named so none of them can stand where an index.html already does. The per-band page is centred on the band whose directory it sits in -- bold in the table and the legend -- and links back to the summary.
Every line panel carries a residual strip under the profile — observed minus model — because a deep Balmer core hides the difference between two models that a residual shows plainly. The buttons above the panels choose which fit the residuals belong to; they all switch together and share a vertical scale, so a residual cannot look small merely because its own panel was rescaled.
zcomp4.html is the one to read when a four-parameter fit looks odd: it puts both models over the observed profile line by line, with how far apart they get in per cent of the continuum. J22226+0051 is the instructive case -- the temperature differs by 5507 K between the two fits and the profiles differ by 0.4-1.6%, which is what a degeneracy looks like from the inside.
Median over the stars of how far apart the four bands' best fits get on each line, in per cent of the continuum. Each band is at its own fitted parameters, so this is what survives after every band has done what it can, which is the part that drives the parameter differences.
| line | sdOB | He-sdOB |
|---|---|---|
| He II 4686 | 9.6 | 1.9 |
| H-gamma | 3.3 | 2.6 |
| H-beta | 2.9 | 3.4 |
| H-delta | 2.9 | 2.5 |
| He I 4922 | 2.3 | 1.3 |
| H-epsilon | 1.8 | 3.3 |
| He II 5412 | — | 2.8 |
In the sdOB stars He II 4686 carries three times the disagreement of any other line, which is why the metals move the temperature there. In the helium-rich stars it does not: the disagreement is spread evenly over everything at 1-3%, and no single line is doing the work -- which is the same degeneracy that makes helium move 0.29 dex there.
The helium axis has thirteen points over five dex; a fit uses six of them. A cubic through the six either side of the answer is the same cubic, and the other seven are models the star is nowhere near. So the axis is cut into overlapping stacks, one per helium regime, all overlapping by a full dex so a fit that stops on an edge finds itself well inside the neighbour:
| stack | HELOG | log n(He)/n(H) | holds |
|---|---|---|---|
| faint | −5.05 … −2.55 | −5.05 … −2.55 | the upper-limit tail |
| poor | −3.55 … −1.05 | −3.55 … −1.01 | 88% of the 962 helium-poor fits |
| iHe | −2.05 … −0.30 | −2.05 … 0.00 | the intermediate classes |
| He | −1.05 … −0.041 | −1.01 … +1.00 | helium-dominated, to the grid top |
poor tops out exactly on the poor/iHe class boundary, and the 6% that fall out of each of its ends land inside the neighbouring stack. Helium now slides between stacks the way temperature and gravity slide between windows — and by edge, not by membership: a fit sitting on the rim of its stack has measured the rim, and only when there is no neighbour left is it the grid's own end.
Z = −2 is not loaded by default. Nothing in the standard samples goes near it, and it is added back only when a fit leans on the −1 edge (spgrid.z_bands, threshold −0.7).
Verified against the full axis on thirteen stars: max |ΔTeff| 39 K, max |Δlog g| 0.003, max |Δlog n(He)/n(H)| 0.018, max |Δχ²| 0.012 — against formal Teff errors of 130–400 K. The saving is free.
Reading six FITS files takes 0.14 s. Building the Gaussian convolution operator takes 1.8 s, and it was being rebuilt once per band — four bands paid for the same matrix four times. It is a pure function of the wavelength grid and the resolution, so it is now cached (spgrid._OPCACHE).
| setup | before | after (cold) | after (warm) |
|---|---|---|---|
| one band, 13 helium | 2.5 s | 2.5 s | 0.40 s |
| one band, 6 helium | — | 1.9 s | 0.18 s |
| 4 bands × 13 (52 files) | 8.7 s | 3.4 s | 1.6 s |
| 3 bands × 6 (18 files) | — | 2.3 s | 0.54 s |
Warm is the normal case: the sliding loop refits the same star several times, and consecutive stars share a wavelength grid whenever the line set is the same. The whole zswap run over twelve stars now takes 40 s.
Three changes, all of them in spek/lib:
| what | why |
|---|---|
GridSet.load(axis=, axis_label=) | the third axis takes explicit values instead of each file's own helium — this is what lets Z be fitted (see How it is done) |
spgrid.HE_STACKS, he_stack, he_neighbour, z_bands, he_files | choosing which files to load, and sliding between helium stacks |
spgrid._OPCACHE | the convolution operator cached on (wavelength grid, resolution) — it was being rebuilt once per band |
nd_prototype.py, here rather than in the library, holds the N-axis interpolation: written, checked, and not yet wired in.