Skip to content
Closed
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
107 changes: 81 additions & 26 deletions astropy/io/fits/card.py

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] _format_float contains an unreachable leftover block at the end referencing value_str and str_len (from the previous implementation). Although it won’t execute due to earlier returns, it’s confusing and risks future maintenance issues. Please remove the dead code entirely.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Exponent normalization in _normalize_float_str currently pads digits to "at least two" using rjust(2, '0'), which preserves strings like E+003. The previous implementation explicitly normalized zero-padded small exponents to two digits (e.g., E+03). To maintain backward compatibility and FITS style, please collapse unnecessary leading zeros by parsing the exponent and reformatting: digits_fmt = f"{int(digits):02d}" and then rebuild mant + "E" + sign + digits_fmt. This retains 3+ digits where necessary (e.g., E+100) while ensuring small exponents use exactly two digits.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] The truncation logic in _format_float slices the mantissa and final output (mant[:avail] / out[:20]). This can produce non-rounded mantissas and break round-trip behavior when the minimal-string or .16G formats exceed 20 chars. Consider computing an appropriate precision and using a rounding format (e.g., :.{n}g or :.{n}f depending on presence of a decimal) to fit within the 20-char field while preserving numeric intent, rather than raw substring truncation.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] In _format_float, the precision loop uses :.{N}E which always emits scientific notation, even for values where a fixed or minimal form might fit within 20 characters (e.g., 0.1). You already attempt minimal representation first; however, when falling back, consider trying :.{N}G before :.{N}E so values near 1.0 are shown without an exponent when beneficial and still normalized with _normalize_float_str. This may further reduce comment truncation in borderline cases.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Thanks for the updates—this looks much cleaner. One small suggestion for _normalize_float_str: building the exponent currently reparses exp with a try/except and a fallback parse. Since Python’s float.__format__('E') already produces canonical exponents (no spaces, with sign), you can simplify by parsing via int(exp) directly after ensuring a leading sign and catching only ValueError once, or rely on the int(exp) result from the earlier sci branch. Not a blocker, but consider simplifying the defensive branches for maintainability.

Original file line number Diff line number Diff line change
Expand Up @@ -1297,35 +1297,90 @@ def _format_value(value):
return ""


def _format_float(value):
"""Format a floating number to make sure it gets the decimal point."""
value_str = f"{value:.16G}"
if "." not in value_str and "E" not in value_str:
value_str += ".0"
elif "E" in value_str:
# On some Windows builds of Python (and possibly other platforms?) the
# exponent is zero-padded out to, it seems, three digits. Normalize
# the format to pad only to two digits.
significand, exponent = value_str.split("E")
if exponent[0] in ("+", "-"):
sign = exponent[0]
exponent = exponent[1:]
else:
sign = ""
value_str = f"{significand}E{sign}{int(exponent):02d}"

# Limit the value string to at most 20 characters.
str_len = len(value_str)

if str_len > 20:
idx = value_str.find("E")
def _normalize_float_str(s):
"""
Normalize a float string for FITS:
- Uppercase exponent letter to 'E'
- Exponent normalization: include a sign and at least two digits only
if abs(exponent) < 100; otherwise preserve full width without extra
padding zeros.
- Ensure integer-like floats include a decimal point (e.g., '2.0').
"""
# Do not alter special floats
sl = s.lower()
if sl in ("nan", "inf", "-inf"):
return s

if idx < 0:
value_str = value_str[:20]
# Normalize exponent
if "e" in s or "E" in s:
s = s.replace("e", "E")
mant, exp = s.split("E", 1)
# Remove spaces in exponent part and ensure a sign
exp = exp.replace(" ", "")
if not exp or exp[0] not in "+-":
exp = "+" + exp
# Parse exponent as integer
try:
exp_int = int(exp)
except ValueError:
# Fallback: strip sign and parse digits only
sign_char = exp[0] if exp and exp[0] in "+-" else "+"
digits = exp[1:]
try:
exp_int = int(("-" if sign_char == "-" else "") + (digits or "0"))
except ValueError:
exp_int = 0
sign_char = "+" if exp_int >= 0 else "-"
abs_exp = abs(exp_int)
if abs_exp < 100:
digits_out = f"{abs_exp:02d}"
else:
value_str = value_str[: 20 - (str_len - idx)] + value_str[idx:]
digits_out = str(abs_exp)
s = mant + "E" + sign_char + digits_out
# Ensure integer-like floats include a decimal point
if ("E" not in s) and ("." not in s):
s = s + ".0"
return s

return value_str
def _format_float(value):
"""
Format a float according to FITS card value rules:
- Prefer Python's minimal round-trip representation (str(value)) when it fits
within the 20-character numeric field; otherwise use scientific notation
with precision-based rounding.
- Exponent is normalized (uppercase 'E', sign, and at least two digits
only for abs(exp) < 100; otherwise preserve full width).
- Ensure integer-like floats include a decimal point.
- Special floats (NaN/Inf) behavior unchanged.
"""
# Primary candidate: minimal round-trip representation
try:
s1 = str(value)
except Exception:
s1 = f"{value:.16G}"
sl = s1.lower()
# Preserve special float behavior unchanged
if sl in ("nan", "inf", "-inf"):
return s1
s1n = _normalize_float_str(s1)
if len(s1n) <= 20:
return s1n

# Use scientific notation with precision-based rounding
# Start with up to 16 digits after decimal and reduce until it fits
N = 16
while N >= 0:
sci = f"{value:.{N}E}"
sci_n = _normalize_float_str(sci)
if len(sci_n) <= 20:
return sci_n
N -= 1

# Fallback: if somehow still too long, take normalized minimal and truncate
out = s1n[:20]
if out.endswith("."):
out = out[:-1] or "0"
return out


def _pad(input):
Expand Down
102 changes: 102 additions & 0 deletions astropy/io/fits/tests/test_float_format.py

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The _value_field helper assumes a single space after = and grabs up to 20 chars; this is fine for standard cards but HIERARCH can alter padding semantics. Consider a brief docstring note or leveraging Card parsing helpers (if available) to avoid brittle assumptions in future refactors.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Please add a test case for negative zero to ensure formatting preserves the sign and decimal point (e.g., Card('NEGZERO', -0.0) yields a value field containing -0.0). This guards against regressions in _normalize_float_str.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Consider adding a test for large positive exponents (e.g., 1e+100) to confirm normalization keeps 3-digit exponents unchanged (i.e., E+100), alongside the small-exponent case. This will document the intended behavior across exponent sizes.

Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import pytest
from astropy.io import fits
from astropy.io.fits.verify import VerifyWarning


def _value_field(card_str):
"""
Extract the fixed-width value field (up to 20 characters) after '='.
For standard FITS cards, the numeric value field is exactly 20 characters
and right-justified after '= '. HIERARCH cards may omit the space or use
a shortened value indicator; this helper is intended for standard cards
in these tests.
"""
if '=' not in card_str:
return ''
eq = card_str.index('=')
# After '=' there is typically a space before the field
start = eq + 1
if start < len(card_str) and card_str[start] == ' ':
start += 1
end = min(start + 20, len(card_str))
return card_str[start:end]


def test_hierarch_float_minimal_repr_no_truncate():
c = fits.Card(
'HIERARCH ESO IFM CL RADIUS',
0.009125,
'[m] radius arround actuator to avoid'
)
with pytest.warns(None) as rec:
s = str(c)
# No VerifyWarning
assert not any(isinstance(w.message, VerifyWarning) for w in rec)
# Card is exactly 80 chars
assert len(s) == 80
# Value uses minimal representation and comment preserved
assert '= 0.009125 /' in s
assert s.endswith('[m] radius arround actuator to avoid')


def test_float_minimal_repr_exponent_uppercase():
c = fits.Card('FOO', 1e-5)
s = str(c)
# Uppercase exponent with two digits
assert 'E-05' in s
# Value field is right-justified within 20 chars for standard card
vf = _value_field(s)
assert len(vf) == 20
assert vf.endswith('E-05')
# Leading spaces indicate right-justification
assert vf[0] == ' '


def test_float_integer_like_has_decimal_point():
c = fits.Card('BAR', 2.0)
s = str(c)
vf = _value_field(s)
assert '2.0' in vf


def test_complex_minimal_repr():
c = fits.Card('BAZ', complex(0.009125, -5e-6))
s = str(c)
# Real part minimal, imag part uses normalized small exponent
assert '0.009125' in s
assert 'E-06' in s
vf = _value_field(s)
# Entire complex literal should fit in the 20-char field in standard cards
assert len(vf) <= 20


def test_hierarch_equal_sign_shortening():
# Construct a HIERARCH card near the limit to force '='-shortening logic
# The specific keyword and value combo aims to exceed by 1 char before shortening
key = 'HIERARCH ESO VERY LONG KEYWORD TEST'
comment = 'C' * 30
c = fits.Card(key, 0.009125, comment)
with pytest.warns(None) as rec:
s = str(c)
# Shortening occurs without raising errors; card length should be 80
assert len(s) == 80
# No VerifyWarning
assert not any(isinstance(w.message, VerifyWarning) for w in rec)


def test_negative_zero_preserved():
c = fits.Card('NEGZERO', -0.0)
s = str(c)
vf = _value_field(s)
# Ensure the sign and decimal point are preserved for -0.0
assert '-0.0' in vf


def test_large_exponent_not_zero_padded():
c = fits.Card('BIG', 1e100)
s = str(c)
vf = _value_field(s)
# Exponent should be normalized with uppercase E, explicit sign, and
# no forced zero-padding beyond the natural width (E+100 here)
assert 'E+100' in vf
assert len(vf) <= 20