From 03f5bb09155f1dc9d5e88ca40cc89feeb2cca174 Mon Sep 17 00:00:00 2001 From: Marcus Mellor Date: Mon, 20 Jul 2026 14:14:51 -0500 Subject: [PATCH 01/12] cli: Add option to disable SPICE simulations --- charlib/characterizer/characterizer.py | 3 +- .../procedures/combinational/delay.py | 35 ++++++++------ .../procedures/combinational/leakage_power.py | 44 +++++++++--------- .../procedures/pin_capacitance/ac_sweep.py | 10 ++-- .../pin_capacitance/charge_integration.py | 46 +++++++++---------- charlib/cli/main.py | 7 +-- charlib/cli/run.py | 5 +- 7 files changed, 79 insertions(+), 71 deletions(-) diff --git a/charlib/characterizer/characterizer.py b/charlib/characterizer/characterizer.py index f8e38be7..8278c3b1 100644 --- a/charlib/characterizer/characterizer.py +++ b/charlib/characterizer/characterizer.py @@ -134,8 +134,9 @@ def __init__(self, **kwargs): self.debug = kwargs.pop('debug', False) self.debug_dir = Path(kwargs.pop('debug_dir', 'debug')) self.quiet = kwargs.pop('quiet', False) - self.cell_defaults = kwargs.get('cell_defaults', {}) + self.dry_run = kwargs.pop('dry_run', False) self.omit_on_failure = kwargs.get('omit_on_failure', False) + self.cell_defaults = kwargs.get('cell_defaults', {}) # Simulation procedures self.simulation = SimulationSettings(**kwargs.get('simulation', {})) diff --git a/charlib/characterizer/procedures/combinational/delay.py b/charlib/characterizer/procedures/combinational/delay.py index 2cd60a0f..164ca0ad 100644 --- a/charlib/characterizer/procedures/combinational/delay.py +++ b/charlib/characterizer/procedures/combinational/delay.py @@ -131,38 +131,44 @@ def measure_delays_for_path_with_criterion(cell, config, settings, variation, pa raise ValueError(f'Unable to connect unrecognized pin {pin.name} in cell {cell.name}') circuit.X('dut', cell.name, *connections) - # Run the simulation, taking all measurements + # Build the simulation simulator = PySpice.Simulator.factory(simulator=settings.simulation.backend) simulation = simulator.simulation( circuit, temperature=settings.temperature, nominal_temperature=settings.temperature ) - simulation.options('autostop', 'nopage', 'nomod', post=1, ingold=2, trtol=1) + simulation.options('autostop', trtol=1) for measure in measurements: simulation.measure(*measure, run=False) simulation.transient(step_time=data_slew/8, end_time=t_sim_end, run=False) stable_pins_map_str = ', '.join(['='.join([pin, state]) for pin, state in pin_map.stable_inputs.items()]) + + if settings.debug: + debug_path = settings.debug_dir / cell.name / __name__.split('.')[-1] + debug_path.mkdir(parents=True, exist_ok=True) + with open(debug_path / f'slew = {data_slew} load = {load}.sp', 'w', encoding='utf-8') as file: + file.write(str(simulation)) + + # Skip simulation if this is a dry-run + if settings.dry_run: + # TODO: Display a message if not settings.quiet + continue + + # Run the simulation, taking all measurements try: analyses[stable_pins_map_str] = simulator.run(simulation) except Exception as e: msg = f'Procedure measure_worst_case_delay_for_path failed for cell {cell.name} ' \ f'with variation {variation}, pin states {state_map}' - if settings.debug: - debug_path = settings.debug_dir / cell.name / __name__.split('.')[-1] - debug_path.mkdir(parents=True, exist_ok=True) - with open(debug_path / f'slew = {data_slew} load = {load}.sp', 'w', encoding='utf-8') as file: - file.write(str(simulation)) raise ProcedureFailedException(msg) from e # Select the worst-case delays and add to LUTs result = cell.liberty - result.group('pin', output_pin).add_group('timing', f'/* {input_pin} */') # FIXME: This is a \ - # hack to allow multiple timing groups while the liberty API doesn't yet support multiple - # groups with the same name and no id. In practice timing groups are distinguished by - # their related_pin attribute. - result.group('pin', output_pin).group('timing', f'/* {input_pin} */').add_attribute('related_pin', input_pin) # FIXME + timing_group = liberty.Group('timing') + timing_group.add_attribute('related_pin', input_pin) + # TODO: Add timing_sense attribute to indicate unateness for name in measurement_names: # Get the worst delay & plot io if 'io' in config.plots: @@ -179,13 +185,14 @@ def measure_delays_for_path_with_criterion(cell, config, settings, variation, pa # Build LUT delay_measurements =[analysis.measurements[name] for analysis in analyses.values() if name in analysis.measurements] - delay = criterion(delay_measurements) @ PySpice.Unit.u_s + delay = (criterion(delay_measurements) if delay_measurements else -1) @ PySpice.Unit.u_s lut_name, meas_path = name.split('__') lut_template_size = f'{len(config.parameters["loads"])}x{len(config.parameters["data_slews"])}' lut = LookupTable(lut_name, f'delay_template_{lut_template_size}', total_output_net_capacitance=[load.convert(settings.units.capacitance.prefixed_unit).value], input_net_transition=[data_slew.convert(settings.units.time.prefixed_unit).value]) lut.values[0,0] = delay.convert(settings.units.time.prefixed_unit).value - result.group('pin', output_pin).group('timing', f'/* {input_pin} */').add_group(lut) # FIXME + timing_group.add_group(lut) + result.group('pin', output_pin).add_group(timing_group) return result diff --git a/charlib/characterizer/procedures/combinational/leakage_power.py b/charlib/characterizer/procedures/combinational/leakage_power.py index 639e5670..c96b1c24 100644 --- a/charlib/characterizer/procedures/combinational/leakage_power.py +++ b/charlib/characterizer/procedures/combinational/leakage_power.py @@ -67,31 +67,33 @@ def measure_leakage_for_state(cell, config, settings, state_map): simulation.options('nopage', 'nomod') simulation.operating_point() - try: - analysis = simulator.run(simulation) - except Exception as e: - msg = (f'Procedure measure_leakage_for_state failed for cell {cell.name} ' - f'with state {state_map}') - if settings.debug: - debug_path = settings.debug_dir / cell.name / __name__.split('.')[-1] - debug_path.mkdir(parents=True, exist_ok=True) - with open(debug_path / f'state = {state_map}.sp', 'w', encoding='utf-8') as f: - f.write(str(simulation)) - raise ProcedureFailedException(msg) from e + if settings.debug: + debug_path = settings.debug_dir / cell.name / __name__.split('.')[-1] + debug_path.mkdir(parents=True, exist_ok=True) + with open(debug_path / f'state = {state_map}.sp', 'w', encoding='utf-8') as f: + f.write(str(simulation)) - # Branch current: ngspice names it #branch, simplified to (lower) - i_vdd = float(analysis.branches[settings.primary_power.name.lower()][0]) - power_W = settings.primary_power.voltage * abs(i_vdd) - power_value = (power_W @ PySpice.Unit.u_W).convert( - settings.units.power.prefixed_unit - ).value + if settings.dry_run: + # TODO: Display a message if not settings.quiet + power_value = -1 + else: + try: + analysis = simulator.run(simulation) + except Exception as e: + msg = (f'Procedure measure_leakage_for_state failed for cell {cell.name} ' + f'with state {state_map}') + raise ProcedureFailedException(msg) from e - when_str = build_when_str(state_map) + # Branch current: ngspice names it #branch, simplified to (lower) + i_vdd = float(analysis.branches[settings.primary_power.name.lower()][0]) + power_W = settings.primary_power.voltage * abs(i_vdd) + power_value = (power_W @ PySpice.Unit.u_W).convert( + settings.units.power.prefixed_unit + ).value - # Use when_str as identifier so multiple leakage_power groups in the same cell don't collide result = cell.liberty - lp_group = liberty.Group('leakage_power', f'/* {when_str} */') - lp_group.add_attribute('when', when_str) + lp_group = liberty.Group('leakage_power') + lp_group.add_attribute('when', build_when_str(state_map)) lp_group.add_attribute('value', power_value) result.add_group(lp_group) return result diff --git a/charlib/characterizer/procedures/pin_capacitance/ac_sweep.py b/charlib/characterizer/procedures/pin_capacitance/ac_sweep.py index efa412dc..14f6e8a9 100644 --- a/charlib/characterizer/procedures/pin_capacitance/ac_sweep.py +++ b/charlib/characterizer/procedures/pin_capacitance/ac_sweep.py @@ -78,9 +78,13 @@ def measure_pin_cap_by_ac_sweep(cell, settings, config, target_pin): spice_file.write(str(simulation)) # Measure capacitance as the slope of the conductance with respect to frequency - analysis = simulator.run(simulation) - conductance = np.reciprocal(np.abs(analysis.vin)/i_in) - [*_, capacitance] = np.polynomial.polynomial.polyfit(analysis.frequency, conductance, 1) + if settings.dry_run: + # TODO: Display a message if not settings.quiet + capacitance = -1 + else: + analysis = simulator.run(simulation) + conductance = np.reciprocal(np.abs(analysis.vin)/i_in) + [*_, capacitance] = np.polynomial.polynomial.polyfit(analysis.frequency, conductance, 1) # Add to the liberty group converted_cap = (capacitance @ u_F).convert(settings.units.capacitance.prefixed_unit).value diff --git a/charlib/characterizer/procedures/pin_capacitance/charge_integration.py b/charlib/characterizer/procedures/pin_capacitance/charge_integration.py index 9e15439b..21c48557 100755 --- a/charlib/characterizer/procedures/pin_capacitance/charge_integration.py +++ b/charlib/characterizer/procedures/pin_capacitance/charge_integration.py @@ -26,7 +26,6 @@ def measure_pin_cap_by_charge_integration(cell, settings, config, target_pin): Returns a liberty cell group with the capacitance set on the appropriate pin. """ - result = cell.liberty vdd = settings.primary_power.voltage * settings.units.voltage vss = settings.primary_ground.voltage * settings.units.voltage @@ -95,19 +94,13 @@ def measure_pin_cap_by_charge_integration(cell, settings, config, target_pin): temperature=settings.temperature, nominal_temperature=settings.temperature ) - simulation.options('nopage', 'nomod', post=1, ingold=2) + simulation.options('autostop') # Integrate i(vstim) over each edge; i(vstim) is negative when sourcing current - simulation.measure('tran', 'q_rise', - 'integ i(vstim)', - f'from={t_rise_start:.6g}', - f'to={t_rise_end:.6g}', - run=False) - simulation.measure('tran', 'q_fall', - 'integ i(vstim)', - f'from={t_fall_start:.6g}', - f'to={t_fall_end:.6g}', - run=False) + simulation.measure('tran', 'q_rise', 'integ i(vstim)', + f'from={t_rise_start:.6g}', f'to={t_rise_end:.6g}', run=False) + simulation.measure('tran', 'q_fall', 'integ i(vstim)', + f'from={t_fall_start:.6g}', f'to={t_fall_end:.6g}', run=False) simulation.transient(step_time=t_slew / 10, end_time=t_sim_end, run=False) if settings.debug: @@ -116,23 +109,30 @@ def measure_pin_cap_by_charge_integration(cell, settings, config, target_pin): with open(debug_path / f'{target_pin}.spice', 'w', encoding='utf-8') as spice_file: spice_file.write(str(simulation)) - try: - analysis = simulator.run(simulation) - except Exception as e: - msg = (f'Procedure measure_pin_cap_by_charge_integration failed for cell {cell.name}, ' - f'pin {target_pin}') - raise ProcedureFailedException(msg) from e + if settings.dry_run: + # TODO: Display a message if not settings.quiet + q_rise = -1 + q_fall = -1 + else: + try: + analysis = simulator.run(simulation) + except Exception as e: + msg = (f'Procedure measure_pin_cap_by_charge_integration failed for cell {cell.name}, ' + f'pin {target_pin}') + raise ProcedureFailedException(msg) from e + + q_rise = abs(analysis.measurements.get('q_rise', float('nan'))) + q_fall = abs(analysis.measurements.get('q_fall', float('nan'))) - q_rise = analysis.measurements.get('q_rise', float('nan')) - q_fall = analysis.measurements.get('q_fall', float('nan')) + result = cell.liberty if math.isnan(q_rise) or math.isnan(q_fall): return result # C = |Q| / VDD per edge; capacitance is the worst-case vdd_v = settings.primary_power.voltage - rise_cap_F = abs(q_rise) / vdd_v - fall_cap_F = abs(q_fall) / vdd_v - worst_cap_F = max(rise_cap_F, fall_cap_F) + rise_cap_F = q_rise / vdd_v + fall_cap_F = q_fall / vdd_v + worst_cap_F = max(rise_cap_F, fall_cap_F) def to_lib(cap_F): return (cap_F @ u_F).convert(settings.units.capacitance.prefixed_unit).value diff --git a/charlib/cli/main.py b/charlib/cli/main.py index d3582884..2ac69f03 100644 --- a/charlib/cli/main.py +++ b/charlib/cli/main.py @@ -25,9 +25,6 @@ def main(): parser_compare = subparser.add_parser( 'compare', help='(experimental) Compare two liberty files') - parser_genfunctions = subparser.add_parser( - 'generate_functions', - help='(experimental) Generate YAML maps for registered functions') # Set up charlib run arguments parser_characterize.add_argument( @@ -40,8 +37,8 @@ def main(): '-j', '--jobs', type=int, default=0, help='Specify the number of concurrent jobs') parser_characterize.add_argument( - '--comparewith', type=str, default='', - help='(experimental) A liberty file to compare results with') + '-n', '--no-sim', action='store_true', + help='Perform all tasks except for running simulations') parser_characterize.add_argument( '-f', '--filters', nargs='*', help='A list of one or more regex strings. charlib will only characterize cells matching one or more of the filters.') diff --git a/charlib/cli/run.py b/charlib/cli/run.py index 47120145..192860f2 100755 --- a/charlib/cli/run.py +++ b/charlib/cli/run.py @@ -27,6 +27,7 @@ def run(args): characterizer.settings.debug = characterizer.settings.debug or args.debug characterizer.settings.quiet = characterizer.settings.quiet or args.quiet characterizer.settings.jobs = args.jobs if args.jobs else characterizer.settings.jobs + characterizer.settings.dry_run = characterizer.settings.dry_run or args.no_sim # Filter and add cells if args.filters: @@ -50,7 +51,3 @@ def run(args): f.write(str(liberty)) if not characterizer.settings.quiet: print(f'Results written to {str(libfile.resolve())}') - - # Run any post-characterization analysis - if args.comparewith: - compare(args.comparewith, library) From e6549d7012975426595e39197ec487a7bc01efa4 Mon Sep 17 00:00:00 2001 From: Marcus Mellor Date: Mon, 20 Jul 2026 15:00:03 -0500 Subject: [PATCH 02/12] cli: Add dry-run compatibility to c2q_contour --- .../constraint/metastability/c2q_contour.py | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/charlib/characterizer/procedures/sequential/constraint/metastability/c2q_contour.py b/charlib/characterizer/procedures/sequential/constraint/metastability/c2q_contour.py index d15b4674..6fc57620 100644 --- a/charlib/characterizer/procedures/sequential/constraint/metastability/c2q_contour.py +++ b/charlib/characterizer/procedures/sequential/constraint/metastability/c2q_contour.py @@ -4,6 +4,7 @@ from charlib.characterizer.procedures import register, ProcedureFailedException from charlib.characterizer import utils, plots +from charlib.liberty import liberty from charlib.liberty.library import LookupTable @register( @@ -224,6 +225,13 @@ def find_setup_hold_for_path(cell, config, settings, variation, path, state_maps filename='contour_sweep_b_setup_as_outer_loop.png', title=_base_title + '\nSweep B: setup outer, hold inner') + # If this is a dry-run, we can't proceed past this point because the next step relies on + # previously measured data + if settings.dry_run: + # TODO: Display a message if not settings.quiet + result_per_state[state_str] = (1 @ PySpice.Unit.u_s, 1 @ PySpice.Unit.u_s, None) + continue + # Step 6: pick the balanced knee point from the merged contour. boundary_pts = extract_2d_contour(latched_a, latched_b, setup_vals_s, hold_vals_s) (knee_setup_s, knee_hold_s), knee_is_fallback = utils.find_knee_point( @@ -255,7 +263,6 @@ def find_setup_hold_for_path(cell, config, settings, variation, path, state_maps worst_hold = None worst_setup_state = None worst_hold_state = None - for state_str, (setup, hold, _) in result_per_state.items(): if worst_setup is None or setup > worst_setup: worst_setup = setup @@ -291,38 +298,30 @@ def find_setup_hold_for_path(cell, config, settings, variation, path, state_maps # Build liberty output result = cell.liberty - clock_pin = cell.clock - n_ds = len(config.parameters['data_slews']) - n_cs = len(config.parameters['clock_slews']) - lut_template_size = f'{len(config.parameters["clock_slews"])}x{len(config.parameters["data_slews"])}' + lut_size = f'{len(config.parameters["clock_slews"])}x{len(config.parameters["data_slews"])}' + constraint_name = 'rise_constraint' if data_transition == '01' else 'fall_constraint' # Setup timing group - result.group('pin', data_pin).add_group('timing', "/* setup */") - stg = result.group('pin', data_pin).group('timing', "/* setup */") - stg.add_attribute('related_pin', clock_pin.name) - stg.add_attribute('timing_type', 'setup_falling' if clock_pin.is_inverted() else 'setup_rising') - - # Hold timing group - result.group('pin', data_pin).add_group('timing', "/* hold */") - htg = result.group('pin', data_pin).group('timing', "/* hold */") - htg.add_attribute('related_pin', clock_pin.name) - htg.add_attribute('timing_type', 'hold_falling' if clock_pin.is_inverted() else 'hold_rising') - - # add lut to timing group - # rise_constraint when D rises (01), fall_constraint when D falls (10) - cname = 'rise_constraint' if data_transition == '01' else 'fall_constraint' - - setup_lut = LookupTable(cname, f'setup_template_{n_cs}x{n_ds}', + stg = liberty.Group('timing') + stg.add_attribute('related_pin', cell.clock.name) + stg.add_attribute('timing_type', 'setup_falling' if cell.clock.is_inverted() else 'setup_rising') + setup_lut = LookupTable(constraint_name, f'setup_template_{lut_size}', related_pin_transition=[cs.convert(settings.units.time.prefixed_unit).value], - constraint_pin_transition=[ds.convert(settings.units.time.prefixed_unit).value]) + constrained_pin_transition=[ds.convert(settings.units.time.prefixed_unit).value]) setup_lut.values[0, 0] = worst_setup.convert(settings.units.time.prefixed_unit).value stg.add_group(setup_lut) + result.group('pin', data_pin).add_group(stg) - hold_lut = LookupTable(cname, f'hold_template_{n_cs}x{n_ds}', + # Hold timing group + htg = liberty.Group('timing') + htg.add_attribute('related_pin', cell.clock.name) + htg.add_attribute('timing_type', 'hold_falling' if cell.clock.is_inverted() else 'hold_rising') + hold_lut = LookupTable(constraint_name, f'hold_template_{lut_size}', related_pin_transition=[cs.convert(settings.units.time.prefixed_unit).value], - constraint_pin_transition=[ds.convert(settings.units.time.prefixed_unit).value]) + constrained_pin_transition=[ds.convert(settings.units.time.prefixed_unit).value]) hold_lut.values[0, 0] = worst_hold.convert(settings.units.time.prefixed_unit).value htg.add_group(hold_lut) + result.group('pin', data_pin).add_group(htg) return result @@ -550,6 +549,10 @@ def get_t_stabilizing(cell, config, settings, path, state_map, k=2, th_low=0.03, simulation runtime. This procedure measures the transient time of the output signal, then multiplies that by a 'safety factor' k to determine a reasonable stabilizing time.""" + if settings.dry_run: + # TODO: Display a message if not settings.quiet + return -1. @ PySpice.Unit.u_s + simulator, simulation = sim_latch(cell, config, settings, path, state_map, **sim_kwargs) try: analysis = simulator.run(simulation) @@ -587,6 +590,10 @@ def get_c2q(cell, config, settings, path, state_map, debug_dir=None, **sim_kwarg # If t_setup + t_hold < 0 fail immediately return float('nan') + if settings.dry_run: + # TODO: Display a message if not settings.quiet + return -1. @ PySpice.Unit.u_s + # Run simulation try: analysis = simulator.run(simulation) From cd9657440c255f4922f13717614cfb9fe131100a Mon Sep 17 00:00:00 2001 From: Marcus Mellor Date: Mon, 20 Jul 2026 15:08:24 -0500 Subject: [PATCH 03/12] combinational delay: Add timing_type output --- charlib/characterizer/procedures/combinational/delay.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/charlib/characterizer/procedures/combinational/delay.py b/charlib/characterizer/procedures/combinational/delay.py index 164ca0ad..ac6af149 100644 --- a/charlib/characterizer/procedures/combinational/delay.py +++ b/charlib/characterizer/procedures/combinational/delay.py @@ -47,7 +47,7 @@ def measure_delays_for_path_with_criterion(cell, config, settings, variation, pa Default max. """ # Set up key parameters - [input_pin, _, output_pin, _] = path + [input_pin, _, output_pin, output_transition] = path data_slew = variation['data_slews'] * settings.units.time load = variation['loads'] * settings.units.capacitance t_sim_end = max(variation['transient_sim_end_time'] * settings.units.time, 1000*data_slew) @@ -168,7 +168,8 @@ def measure_delays_for_path_with_criterion(cell, config, settings, variation, pa result = cell.liberty timing_group = liberty.Group('timing') timing_group.add_attribute('related_pin', input_pin) - # TODO: Add timing_sense attribute to indicate unateness + timing_type = 'combinational_rise' if output_transition == '01' else 'combinational_fall' + timing_group.add_attribute('timing_type', timing_type) for name in measurement_names: # Get the worst delay & plot io if 'io' in config.plots: From 9947af4e57e33fff815f329766b011412a665f34 Mon Sep 17 00:00:00 2001 From: Marcus Mellor Date: Tue, 21 Jul 2026 08:15:35 -0500 Subject: [PATCH 04/12] combinational delay: Only set delay = -1 during dry runs --- .../characterizer/procedures/combinational/delay.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/charlib/characterizer/procedures/combinational/delay.py b/charlib/characterizer/procedures/combinational/delay.py index ac6af149..fe8da212 100644 --- a/charlib/characterizer/procedures/combinational/delay.py +++ b/charlib/characterizer/procedures/combinational/delay.py @@ -185,8 +185,14 @@ def measure_delays_for_path_with_criterion(cell, config, settings, variation, pa plt.close(fig) # Build LUT - delay_measurements =[analysis.measurements[name] for analysis in analyses.values() if name in analysis.measurements] - delay = (criterion(delay_measurements) if delay_measurements else -1) @ PySpice.Unit.u_s + delay_measurements = [analysis.measurements[name] for analysis in analyses.values() if name in analysis.measurements] + try: + delay = criterion(delay_measurements) @ PySpice.Unit.u_s + except ValueError as e: + if settings.dry_run: + delay = -1 @ PySpice.Unit.u_s + else: + raise lut_name, meas_path = name.split('__') lut_template_size = f'{len(config.parameters["loads"])}x{len(config.parameters["data_slews"])}' lut = LookupTable(lut_name, f'delay_template_{lut_template_size}', From f8be6f92bbfd9a71e415451e900bd7697cb14767 Mon Sep 17 00:00:00 2001 From: Marcus Mellor Date: Tue, 21 Jul 2026 08:17:44 -0500 Subject: [PATCH 05/12] c2q_contour: Don't mask SPICE output from get_c2q during dry runs --- .../sequential/constraint/metastability/c2q_contour.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/charlib/characterizer/procedures/sequential/constraint/metastability/c2q_contour.py b/charlib/characterizer/procedures/sequential/constraint/metastability/c2q_contour.py index 6fc57620..8e1a47be 100644 --- a/charlib/characterizer/procedures/sequential/constraint/metastability/c2q_contour.py +++ b/charlib/characterizer/procedures/sequential/constraint/metastability/c2q_contour.py @@ -229,7 +229,7 @@ def find_setup_hold_for_path(cell, config, settings, variation, path, state_maps # previously measured data if settings.dry_run: # TODO: Display a message if not settings.quiet - result_per_state[state_str] = (1 @ PySpice.Unit.u_s, 1 @ PySpice.Unit.u_s, None) + result_per_state[state_str] = (-1 @ PySpice.Unit.u_s, -1 @ PySpice.Unit.u_s, None) continue # Step 6: pick the balanced knee point from the merged contour. @@ -549,11 +549,12 @@ def get_t_stabilizing(cell, config, settings, path, state_map, k=2, th_low=0.03, simulation runtime. This procedure measures the transient time of the output signal, then multiplies that by a 'safety factor' k to determine a reasonable stabilizing time.""" + simulator, simulation = sim_latch(cell, config, settings, path, state_map, **sim_kwargs) + if settings.dry_run: # TODO: Display a message if not settings.quiet return -1. @ PySpice.Unit.u_s - simulator, simulation = sim_latch(cell, config, settings, path, state_map, **sim_kwargs) try: analysis = simulator.run(simulation) except Exception as e: From 593eed50db86a26992d0402da484a3cfc48b1663 Mon Sep 17 00:00:00 2001 From: Marcus Mellor Date: Tue, 21 Jul 2026 09:09:53 -0500 Subject: [PATCH 06/12] combinational delay: Use ProcedureFailedException and drop unused meas_path variable --- charlib/characterizer/procedures/combinational/delay.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/charlib/characterizer/procedures/combinational/delay.py b/charlib/characterizer/procedures/combinational/delay.py index fe8da212..ac1df397 100644 --- a/charlib/characterizer/procedures/combinational/delay.py +++ b/charlib/characterizer/procedures/combinational/delay.py @@ -192,8 +192,10 @@ def measure_delays_for_path_with_criterion(cell, config, settings, variation, pa if settings.dry_run: delay = -1 @ PySpice.Unit.u_s else: - raise - lut_name, meas_path = name.split('__') + msg = f'Procedure measure_worst_case_delay_for_path failed for cell {cell.name} ' \ + f'with variation {variation}, pin states {state_map}' + raise ProcedureFailedException(msg) from e + lut_name, *_ = name.split('__') lut_template_size = f'{len(config.parameters["loads"])}x{len(config.parameters["data_slews"])}' lut = LookupTable(lut_name, f'delay_template_{lut_template_size}', total_output_net_capacitance=[load.convert(settings.units.capacitance.prefixed_unit).value], From 7fd6222bd4e41bf173181d10571b4bc542c23d83 Mon Sep 17 00:00:00 2001 From: Marcus Mellor Date: Tue, 21 Jul 2026 10:04:49 -0500 Subject: [PATCH 07/12] config: Document dry_run option --- charlib/config/syntax.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/charlib/config/syntax.py b/charlib/config/syntax.py index 2216d5e6..89675003 100644 --- a/charlib/config/syntax.py +++ b/charlib/config/syntax.py @@ -476,6 +476,13 @@ class ConfigFile: 'keyword is set to ``True``' ), default='debug' ) : str, + Optional( + Literal( + 'dry_run', + description='If true, CharLib will perform all steps except for running SPICE ' \ + ' simulations. Equivalent to the ``--no-sim`` command line option.' + ), default=False + ) : bool, Optional( Literal( 'omit_on_failure', From cf4b276e6eafe08b93cb6ff23b315d9491925e96 Mon Sep 17 00:00:00 2001 From: Marcus Mellor Date: Tue, 21 Jul 2026 10:56:56 -0500 Subject: [PATCH 08/12] liberty: Make Statement a proper abstract base class --- charlib/liberty/liberty.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/charlib/liberty/liberty.py b/charlib/liberty/liberty.py index daf429c4..b727cc0b 100644 --- a/charlib/liberty/liberty.py +++ b/charlib/liberty/liberty.py @@ -1,12 +1,13 @@ """Tools for creating Liberty groups. See Liberty User Guide Vol 1, Chapter 1.""" import re +from abc import ABC, abstractmethod from collections import UserDict INDENT_STR = ' ' -class Statement: +class Statement(ABC): """Abstract base class for Liberty statements, such as Groups and Attributes""" @property def name(self): @@ -22,8 +23,9 @@ def name(self, name): def unique_key(self): return (self.name, self.identifier) + @abstractmethod def to_liberty(self, indent=0, precision=6): - return NotImplemented + pass class Group(Statement): From 8bccff5c4ff041207e1a80dd5d66408f406fc297 Mon Sep 17 00:00:00 2001 From: Marcus Mellor Date: Tue, 21 Jul 2026 10:58:29 -0500 Subject: [PATCH 09/12] leakage_power: remove unneeded sim options --- charlib/characterizer/procedures/combinational/leakage_power.py | 1 - 1 file changed, 1 deletion(-) diff --git a/charlib/characterizer/procedures/combinational/leakage_power.py b/charlib/characterizer/procedures/combinational/leakage_power.py index c96b1c24..08a46d65 100644 --- a/charlib/characterizer/procedures/combinational/leakage_power.py +++ b/charlib/characterizer/procedures/combinational/leakage_power.py @@ -64,7 +64,6 @@ def measure_leakage_for_state(cell, config, settings, state_map): temperature=settings.temperature, nominal_temperature=settings.temperature ) - simulation.options('nopage', 'nomod') simulation.operating_point() if settings.debug: From 7f04314281dae4f0dc541146a421ee183dce7c4f Mon Sep 17 00:00:00 2001 From: Marcus Mellor Date: Tue, 21 Jul 2026 14:35:58 -0500 Subject: [PATCH 10/12] characterizer: Teardown runners at task completion to reduce memory usage --- charlib/characterizer/characterizer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charlib/characterizer/characterizer.py b/charlib/characterizer/characterizer.py index 8278c3b1..8e493aaf 100644 --- a/charlib/characterizer/characterizer.py +++ b/charlib/characterizer/characterizer.py @@ -86,7 +86,7 @@ def characterize(self): # Run all simulation jobs and merge each resulting liberty cell group into the library with tqdm(bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]', total=len(simulation_tasks), desc="Characterizing") as progress_bar: - with ProcessPoolExecutor(max_workers=self.settings.jobs) as executor: + with ProcessPoolExecutor(max_workers=self.settings.jobs, max_tasks_per_child=1) as executor: futures = [executor.submit(task, *args) for (task, *args) in simulation_tasks] for future in as_completed(futures): try: From 3ed0a034e1784379002dd5463c13a6e54a96d9f9 Mon Sep 17 00:00:00 2001 From: Marcus Mellor Date: Tue, 21 Jul 2026 14:43:41 -0500 Subject: [PATCH 11/12] workflows: Reduce numpy log spam (?) --- .github/workflows/run-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 62b43988..5ed945a2 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -6,6 +6,7 @@ on: - pull_request env: + PYTHONWARNINGS: "ignore" MOSIS_OSU035_COMMIT_HASH: b36db529c2dff117e1fbead561bf792ec866e1cb GF180MCU_OSU_SC_COMMIT_HASH: 8a2f58f283a2eaa725314c9e1b8b7d1d343f23a3 GF180MCU_FD_PR_COMMIT_HASH: 4adc3a4704fbe722bdf2145341a409b6419788fd From 6e9e45a61e4ba1fe5fe27cb3a3fbee1889940361 Mon Sep 17 00:00:00 2001 From: Marcus Mellor Date: Tue, 21 Jul 2026 16:02:34 -0500 Subject: [PATCH 12/12] Revert "workflows: Reduce numpy log spam (?)" This reverts commit 3ed0a034e1784379002dd5463c13a6e54a96d9f9, which didn't seem to have any effect. --- .github/workflows/run-tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5ed945a2..62b43988 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -6,7 +6,6 @@ on: - pull_request env: - PYTHONWARNINGS: "ignore" MOSIS_OSU035_COMMIT_HASH: b36db529c2dff117e1fbead561bf792ec866e1cb GF180MCU_OSU_SC_COMMIT_HASH: 8a2f58f283a2eaa725314c9e1b8b7d1d343f23a3 GF180MCU_FD_PR_COMMIT_HASH: 4adc3a4704fbe722bdf2145341a409b6419788fd