Coverage for klayout_pex/util/unit_formatter.py: 87%

55 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-06 13:14 +0000

1# 

2# -------------------------------------------------------------------------------- 

3# SPDX-FileCopyrightText: 2024-2025 Martin Jan Köhler and Harald Pretl 

4# Johannes Kepler University, Institute for Integrated Circuits. 

5# 

6# This file is part of KPEX 

7# (see https://github.com/iic-jku/klayout-pex). 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <http://www.gnu.org/licenses/>. 

21# SPDX-License-Identifier: GPL-3.0-or-later 

22# -------------------------------------------------------------------------------- 

23# 

24 

25import math 

26import re 

27from typing import Optional 

28 

29 

30# NGSPICE manual, Table 2.1 "Ngspice scale factors" 

31prefix_scales = [ 

32 ('T', 1e12), 

33 ('G', 1e9), 

34 ('Meg', 1e6), 

35 ('K', 1e3), 

36 ('', 1.0), 

37 ('mil', 25.4e-6), 

38 ('m', 1e-3), 

39 ('u', 1e-6), 

40 ('n', 1e-9), 

41 ('p', 1e-12), 

42 ('f', 1e-15), 

43 ('a', 1e-18), 

44] 

45 

46 

47valid_prefixes = {p for p, _ in prefix_scales} 

48 

49# NOTE: the prefixes format_spice_number chooses: not mil (no SI prefix, 3e-3 would be 118.11mil), 

50# and not a, which Xyce reads as a unit (Xyce 7.10 reads 2a as 2, but 0.002f as 2e-18) 

51_CHOSEN_PREFIX_SCALES = [(p, s) for p, s in prefix_scales if p not in ('mil', 'a')] 

52 

53 

54def format_spice_number(value: float, force_prefix: Optional[str] = None) -> str: 

55 """ 

56 Format a spice number into NGSPICE-compatible string (and Xyce-compatible, unless forced to 'a'): 

57 with the largest SI prefix that gives a value ≥ 1, but no smaller one than 'f' (e.g. '0.223458f'). 

58 

59 Args: 

60 value: capacitance in farads (can be negative or zero). 

61 force_prefix: optional forced prefix (must match NGSPICE case, e.g., 'a','f','p','n','u','m','K','Meg','G','T'). 

62 

63 Returns: 

64 NGSPICE-compatible string like '4.7n', '1p', '2.2u', '-33f', '0.5f', or '0'. 

65 """ 

66 if value == 0: 

67 return "0" 

68 

69 if force_prefix is not None: 

70 if force_prefix not in valid_prefixes: 

71 raise ValueError(f"Invalid prefix '{force_prefix}'. Must be one of {sorted(valid_prefixes)}") 

72 scale = dict(prefix_scales)[force_prefix] 

73 scaled = value / scale 

74 # Force decimal notation for NGSPICE (no scientific notation) 

75 if scaled.is_integer(): 

76 return f"{int(scaled)}{force_prefix}" 

77 else: 

78 return f"{scaled:.6g}{force_prefix}" 

79 

80 chosen_prefix = '' 

81 chosen_scaled = value 

82 

83 for i, (prefix, scale) in enumerate(_CHOSEN_PREFIX_SCALES): 

84 scaled = value / scale 

85 if 1 <= abs(scaled) < 1000: 

86 chosen_prefix = prefix 

87 chosen_scaled = scaled 

88 break 

89 elif abs(scaled) >= 1000: 

90 # bump up to next larger prefix if possible 

91 if i == 0: 

92 chosen_prefix = prefix 

93 chosen_scaled = scaled 

94 else: 

95 next_prefix, next_scale = _CHOSEN_PREFIX_SCALES[i - 1] 

96 chosen_prefix = next_prefix 

97 chosen_scaled = value / next_scale 

98 break 

99 else: 

100 # too small, pick smallest prefix 

101 chosen_prefix, chosen_scaled = _CHOSEN_PREFIX_SCALES[-1][0], value / _CHOSEN_PREFIX_SCALES[-1][1] 

102 

103 # Avoid scientific notation for NGSPICE 

104 if chosen_scaled.is_integer(): 

105 return f"{int(chosen_scaled)}{chosen_prefix}" 

106 else: 

107 return f"{chosen_scaled:.6g}{chosen_prefix}" 

108 

109 

110def format_spice_resistance(value: float) -> str: 

111 """ 

112 Format a resistance in Ω the way KPEX writes it into SPICE netlists: 

113 12 significant digits, without scale factor (e.g. '426.666666667', '0.001') 

114 """ 

115 return f"{value:.12g}" 

116 

117 

118_SPICE_NUMBER = re.compile(r'([+-]?(?:\d+\.?\d*|\.\d+))(?:[eE]([+-]?\d+))?([a-zA-Z]*)') 

119 

120# scale factor (lower case) → power of ten 

121_SCALE_EXPONENTS = {prefix.lower(): round(math.log10(scale)) 

122 for prefix, scale in prefix_scales 

123 if prefix not in ('', 'mil')} 

124 

125 

126def parse_spice_number(text: str) -> float: 

127 """ 

128 Parse a SPICE number, e.g. '1.5', '2e-15', '223.458a', '0.1p', '10k', '1Meg', '1pF'. 

129 

130 The scale factor is case-insensitive (NGSPICE manual, Table 2.1 "Ngspice scale factors"), 

131 'meg' and 'mil' are matched before 'm'. As in NGSPICE, letters after the scale factor are ignored, 

132 so are letters that are no scale factor (e.g. the unit in '10V'). 

133 The result is the double nearest to the decimal value ('0.1p' is the same as 1e-13). 

134 

135 :raises ValueError: if the text is no SPICE number (e.g. an expression like '{2*c0}') 

136 """ 

137 match = _SPICE_NUMBER.fullmatch(text.strip()) 

138 if match is None: 

139 raise ValueError(f"Not a SPICE number: '{text}' (examples of valid numbers: " 

140 f"'1.5', '2e-15', '0.1p', '10k', '1Meg')") 

141 significand, exponent, letters = match.groups() 

142 exponent = int(exponent or 0) 

143 letters = letters.lower() 

144 if letters.startswith('mil'): 

145 return float(f"{significand}e{exponent}") * 25.4e-6 

146 if letters.startswith('meg'): 

147 exponent += 6 

148 elif letters[:1] in _SCALE_EXPONENTS: 

149 exponent += _SCALE_EXPONENTS[letters[:1]] 

150 return float(f"{significand}e{exponent}")