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
« 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#
25import math
26import re
27from typing import Optional
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]
47valid_prefixes = {p for p, _ in prefix_scales}
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')]
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').
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').
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"
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}"
80 chosen_prefix = ''
81 chosen_scaled = value
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]
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}"
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}"
118_SPICE_NUMBER = re.compile(r'([+-]?(?:\d+\.?\d*|\.\d+))(?:[eE]([+-]?\d+))?([a-zA-Z]*)')
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')}
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'.
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).
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}")