Conversation
8d5b067 to
1b80838
Compare
4bb147e to
25d4202
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #150 +/- ##
==========================================
- Coverage 72.37% 71.71% -0.67%
==========================================
Files 35 37 +2
Lines 4727 5783 +1056
Branches 998 1251 +253
==========================================
+ Hits 3421 4147 +726
- Misses 968 1221 +253
- Partials 338 415 +77
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
d7c81f3 to
db5bb3f
Compare
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a Cantera-based ignition delay time (IDT) simulation + sensitivity-analysis (SA) pathway, including role-driven fuel/oxidizer/diluent mixtures and schema/docs updates, plus new example inputs and extensive tests.
Changes:
- Introduces
CanteraIDT(IDT simulation, brute-force + adjoint SA, experimental comparison) andCanteraRCM(constant-pressure reactor variant). - Adds role-based mixture specification (
role,equivalence_ratios, oxidizer/diluent parameters) and new sensitivity schema fields for IDT SA. - Improves Cantera model “fix-up” utilities and updates main execution/restart/SA plumbing; adds new tests/data and documentation/examples.
Reviewed changes
Copilot reviewed 22 out of 28 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_simulate/test_cantera_IDT.py | New end-to-end and unit tests for IDT simulation, SA utilities, modes, criteria, experiment comparison, and failure handling. |
| tests/test_schema.py | Adds enum + validation coverage for new schema fields (IDT criterion/method, role fields, max workers). |
| tests/test_main.py | Updates expected defaults/paths and restart tuple semantics; includes new sensitivity/species/reactor defaults. |
| tests/test_common.py | Adds tests for new helper utilities (species label cleanup, stoich, φ sweeps). |
| tests/data/sa_idt_2.yaml | New SA fixture for get_top_sa_coefficients() tests. |
| tests/data/models/eA_units.yaml | New Cantera YAML fixture to validate activation-energy unit parsing. |
| t3/utils/fix_cantera.py | Adds logging, dedup-mark tracking, and new handling for invalid rate coefficient errors. |
| t3/simulate/rmg_constant_tp.py | Updates adapter SA API signature to match the new base adapter interface. |
| t3/simulate/cantera_rcm.py | New adapter subclass using constant-pressure reactors for RCM-like IDT. |
| t3/simulate/cantera_pfr_t_profile.py | Updates SA API signature forwarding to base. |
| t3/simulate/cantera_base.py | Updates SA API signature and documents unused params for signature parity. |
| t3/simulate/cantera_IDT.py | New main Cantera IDT adapter + SA + rate utilities + plotting + experiment comparison. |
| t3/simulate/adapter.py | Expands abstract SA method signature to include top-N pruning, max workers, save-to-disk flag. |
| t3/simulate/init.py | Exposes the new CanteraRCM adapter. |
| t3/schema.py | Adds enums + schema fields for IDT SA and role-based φ sweeps; relaxes T/P list length rules for row mode. |
| t3/runners/rmg_runner.py | Adds optional post-run Cantera model fixing and improves folder creation + error message typo. |
| t3/main.py | Integrates IDT SA execution, adds IDT SA-based refinement path, updates restart logic, adds figures+SA paths, computes φ sweep concentrations at init. |
| t3/common.py | Adds helpers for removing numeric suffixes, numpy conversion, atom counts, oxidizer stoich, and φ-driven concentration generation. |
| examples/idt_with_experiment/input.yml | New example showing IDT SA with experiment comparison wiring. |
| examples/idt_with_experiment/experimental_idt.yaml | Example experimental IDT YAML format + placeholder data. |
| docs/input_reference.md | Documents new adapters, new IDT SA fields, role-based mixtures, and idt_mode. |
| docs/examples.md | Adds a new documented example entry for IDT + experiment comparison. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if idt is None: | ||
| continue |
There was a problem hiding this comment.
This drops failed working points entirely (idt is None), but the docstring and downstream logic imply None should be stored for failures. Skipping keys makes it impossible to distinguish “failed” from “never simulated”, and can break consumers that expect a fixed T-grid (including plotting/SA alignment). Store None values at idt_dict[phi][P][T] instead of continuing.
| if idt is None: | |
| continue |
| concentration = np.asarray([row[0] for row in time_history(radical_label).X], dtype=np.float32) | ||
| if all(c == 0 for c in concentration): | ||
| return None | ||
| dc_dt = np.diff(concentration) / np.diff(times) |
There was a problem hiding this comment.
Unlike the max_dTdt branch, the radical-based branch divides by np.diff(times) without guarding against zero time steps. If Cantera returns repeated times (or very small dt), this can produce inf/NaN and a bogus IDT index. Apply the same dt = np.where(dt == 0, tiny, dt) pattern here before division.
| dc_dt = np.diff(concentration) / np.diff(times) | |
| dt = np.diff(times) | |
| dt = np.where(dt == 0, np.finfo(float).tiny, dt) | |
| dc_dt = np.diff(concentration) / dt |
| i, p_low, p_high = 0, 0.0, 0.0 | ||
| for i in range(len(reaction_data['rate-constants']) - 1): | ||
| p_low = get_pressure_from_cantera(reaction_data['rate-constants'][i]['P']) | ||
| p_high = get_pressure_from_cantera(reaction_data['rate-constants'][i + 1]['P']) | ||
| if p_low <= P <= p_high: | ||
| break | ||
| k_low = calculate_arrhenius_rate_coefficient(A=reaction_data['rate-constants'][i]['A'], | ||
| n=reaction_data['rate-constants'][i]['b'], | ||
| Ea=reaction_data['rate-constants'][i]['Ea'], | ||
| T=T, Ea_units=Ea_units) | ||
| k_high = calculate_arrhenius_rate_coefficient(A=reaction_data['rate-constants'][i + 1]['A'], | ||
| n=reaction_data['rate-constants'][i + 1]['b'], | ||
| Ea=reaction_data['rate-constants'][i + 1]['Ea'], |
There was a problem hiding this comment.
For PLOG, if P is outside the tabulated pressure range, the loop never breaks and i ends up at the last segment, causing out-of-range pressures below the minimum to incorrectly interpolate using the highest-pressure entries. Implement the standard behavior: if P <= P_min, use the first rate; if P >= P_max, use the last rate; otherwise interpolate between the bracketing pair.
| i, p_low, p_high = 0, 0.0, 0.0 | |
| for i in range(len(reaction_data['rate-constants']) - 1): | |
| p_low = get_pressure_from_cantera(reaction_data['rate-constants'][i]['P']) | |
| p_high = get_pressure_from_cantera(reaction_data['rate-constants'][i + 1]['P']) | |
| if p_low <= P <= p_high: | |
| break | |
| k_low = calculate_arrhenius_rate_coefficient(A=reaction_data['rate-constants'][i]['A'], | |
| n=reaction_data['rate-constants'][i]['b'], | |
| Ea=reaction_data['rate-constants'][i]['Ea'], | |
| T=T, Ea_units=Ea_units) | |
| k_high = calculate_arrhenius_rate_coefficient(A=reaction_data['rate-constants'][i + 1]['A'], | |
| n=reaction_data['rate-constants'][i + 1]['b'], | |
| Ea=reaction_data['rate-constants'][i + 1]['Ea'], | |
| rate_constants = reaction_data['rate-constants'] | |
| pressures = [get_pressure_from_cantera(rate_constant['P']) for rate_constant in rate_constants] | |
| if P <= pressures[0]: | |
| return calculate_arrhenius_rate_coefficient(A=rate_constants[0]['A'], | |
| n=rate_constants[0]['b'], | |
| Ea=rate_constants[0]['Ea'], | |
| T=T, Ea_units=Ea_units) | |
| if P >= pressures[-1]: | |
| return calculate_arrhenius_rate_coefficient(A=rate_constants[-1]['A'], | |
| n=rate_constants[-1]['b'], | |
| Ea=rate_constants[-1]['Ea'], | |
| T=T, Ea_units=Ea_units) | |
| i, p_low, p_high = 0, pressures[0], pressures[1] | |
| for i in range(len(rate_constants) - 1): | |
| p_low = pressures[i] | |
| p_high = pressures[i + 1] | |
| if p_low <= P <= p_high: | |
| break | |
| k_low = calculate_arrhenius_rate_coefficient(A=rate_constants[i]['A'], | |
| n=rate_constants[i]['b'], | |
| Ea=rate_constants[i]['Ea'], | |
| T=T, Ea_units=Ea_units) | |
| k_high = calculate_arrhenius_rate_coefficient(A=rate_constants[i + 1]['A'], | |
| n=rate_constants[i + 1]['b'], | |
| Ea=rate_constants[i + 1]['Ea'], |
| logger.info(f'Removing rxn {rxns[0]} from reaction list:\n({content["reactions"][rxns[0]]})') | ||
| content['reactions'] = remove_rxn(reactions=content['reactions'], index=rxns[0]) |
There was a problem hiding this comment.
The rxns indices returned by get_dup_rxn_indices() are 1-based (the earlier code marks i - 1). In the removal path you index/remove using rxns[0] without converting to 0-based, so it removes/logs the wrong reaction (off-by-one). Convert to rxns[0] - 1 for both the log access and remove_rxn(..., index=...) call.
| logger.info(f'Removing rxn {rxns[0]} from reaction list:\n({content["reactions"][rxns[0]]})') | |
| content['reactions'] = remove_rxn(reactions=content['reactions'], index=rxns[0]) | |
| rxn_index = rxns[0] - 1 | |
| logger.info(f'Removing rxn {rxns[0]} from reaction list:\n({content["reactions"][rxn_index]})') | |
| content['reactions'] = remove_rxn(reactions=content['reactions'], index=rxn_index) |
| tb (str): The traceback. | ||
| """ | ||
| content = read_yaml_file(model_path) | ||
| rxn = get_rxn_to_remove(model_path=model_path, tb=tb) |
There was a problem hiding this comment.
get_rxn_to_remove() can return None (and also may fail to find an exact equation match). In that case content['reactions'][rxn] raises immediately and prevents the fixer from proceeding. Add a guard: if rxn is None, log the inability to identify the reaction and return without modifying the file.
| rxn = get_rxn_to_remove(model_path=model_path, tb=tb) | |
| rxn = get_rxn_to_remove(model_path=model_path, tb=tb) | |
| if rxn is None: | |
| logger.warning('Could not identify the reaction with an invalid rate coefficient; ' | |
| 'leaving the Cantera file unchanged.') | |
| return |
| future_to_task[future] = (task, task_delta_h, task_delta_k) | ||
|
|
||
| for future in cf.as_completed(future_to_task): | ||
| task, task_delta_h, task_delta_k = future_to_task[future] | ||
| try: | ||
| idt_dict = future.result() | ||
| sa_dict[task[0]]['IDT'][task[1]] = idt_dict | ||
| succeeded += 1 | ||
| except Exception as e: | ||
| # Retry once with halved perturbation | ||
| kind, index = task | ||
| retry_delta_h = task_delta_h / 2 if kind == 'thermo' else task_delta_h | ||
| retry_delta_k = task_delta_k / 2 if kind == 'kinetics' else task_delta_k | ||
| py_logger.warning(f"Task {task} failed: {e}. Retrying with halved perturbation.") | ||
| retried += 1 | ||
| try: | ||
| retry_future = executor.submit(worker, | ||
| task, | ||
| self.paths['cantera annotated'], | ||
| self.paths['SA'], | ||
| self.t3, | ||
| self.paths, | ||
| self.rmg, | ||
| self.logger, | ||
| 1.0, | ||
| retry_delta_h, | ||
| retry_delta_k, | ||
| ) | ||
| idt_dict = retry_future.result() | ||
| sa_dict[task[0]]['IDT'][task[1]] = idt_dict | ||
| succeeded += 1 | ||
| except Exception as e2: | ||
| failed += 1 | ||
| if self.logger is not None: | ||
| self.logger.error(f"Task {task} permanently failed after retry:\n{e2}\n" | ||
| f"{traceback.format_exc()}") |
There was a problem hiding this comment.
The retry path submits a new task but immediately blocks on retry_future.result() inside the as_completed loop, which serializes retries and reduces parallel throughput (especially when multiple tasks fail). Consider collecting retry futures and processing them via as_completed as well, or implement the retry logic inside worker() so each task uses a single process slot until it either succeeds or definitively fails.
| future_to_task[future] = (task, task_delta_h, task_delta_k) | |
| for future in cf.as_completed(future_to_task): | |
| task, task_delta_h, task_delta_k = future_to_task[future] | |
| try: | |
| idt_dict = future.result() | |
| sa_dict[task[0]]['IDT'][task[1]] = idt_dict | |
| succeeded += 1 | |
| except Exception as e: | |
| # Retry once with halved perturbation | |
| kind, index = task | |
| retry_delta_h = task_delta_h / 2 if kind == 'thermo' else task_delta_h | |
| retry_delta_k = task_delta_k / 2 if kind == 'kinetics' else task_delta_k | |
| py_logger.warning(f"Task {task} failed: {e}. Retrying with halved perturbation.") | |
| retried += 1 | |
| try: | |
| retry_future = executor.submit(worker, | |
| task, | |
| self.paths['cantera annotated'], | |
| self.paths['SA'], | |
| self.t3, | |
| self.paths, | |
| self.rmg, | |
| self.logger, | |
| 1.0, | |
| retry_delta_h, | |
| retry_delta_k, | |
| ) | |
| idt_dict = retry_future.result() | |
| sa_dict[task[0]]['IDT'][task[1]] = idt_dict | |
| succeeded += 1 | |
| except Exception as e2: | |
| failed += 1 | |
| if self.logger is not None: | |
| self.logger.error(f"Task {task} permanently failed after retry:\n{e2}\n" | |
| f"{traceback.format_exc()}") | |
| future_to_task[future] = (task, task_delta_h, task_delta_k, False) | |
| while future_to_task: | |
| done, _ = cf.wait(future_to_task, return_when=cf.FIRST_COMPLETED) | |
| for future in done: | |
| task, task_delta_h, task_delta_k, is_retry = future_to_task.pop(future) | |
| try: | |
| idt_dict = future.result() | |
| sa_dict[task[0]]['IDT'][task[1]] = idt_dict | |
| succeeded += 1 | |
| except Exception as e: | |
| if not is_retry: | |
| # Retry once with halved perturbation | |
| kind, index = task | |
| retry_delta_h = task_delta_h / 2 if kind == 'thermo' else task_delta_h | |
| retry_delta_k = task_delta_k / 2 if kind == 'kinetics' else task_delta_k | |
| py_logger.warning( | |
| f"Task {task} failed: {e}. Retrying with halved perturbation." | |
| ) | |
| retried += 1 | |
| retry_future = executor.submit(worker, | |
| task, | |
| self.paths['cantera annotated'], | |
| self.paths['SA'], | |
| self.t3, | |
| self.paths, | |
| self.rmg, | |
| self.logger, | |
| 1.0, | |
| retry_delta_h, | |
| retry_delta_k, | |
| ) | |
| future_to_task[retry_future] = (task, retry_delta_h, retry_delta_k, True) | |
| else: | |
| failed += 1 | |
| if self.logger is not None: | |
| self.logger.error(f"Task {task} permanently failed after retry:\n{e}\n" | |
| f"{traceback.format_exc()}") |
| ax.scatter([1000 / t for t in exp_data[phi][p].keys()], | ||
| [e * 1e-6 for e in exp_data[phi][p].values()], | ||
| label='experiment', color='orange', marker='D') |
There was a problem hiding this comment.
The docstrings and example experimental YAML describe IDT units as seconds, but the plot scales experimental values by 1e-6 (microseconds conversion). This will misplot by 1e6 unless the experimental data is actually in µs. Either remove the scaling, or clearly enforce/document that experimental IDTs are in µs and keep the conversion consistent across compare_with_experiment() and docs/examples.
| visited_species, species_keys = list(), list() | ||
| visited_rxns, rxn_keys = list(), list() |
There was a problem hiding this comment.
visited_species mixes two different identifier domains: Cantera species indices (index) and T3 species keys (spc_key). This can cause incorrect dedup behavior (skipping needed work or duplicating work), and can even accidentally collide if the integers overlap. Use separate sets (e.g., visited_ct_species_indices and visited_t3_species_keys) or normalize on one concept (e.g., species labels) throughout this function.
| if token == 'thermo': | ||
| if index in visited_species: | ||
| continue | ||
| visited_species.append(index) |
There was a problem hiding this comment.
visited_species mixes two different identifier domains: Cantera species indices (index) and T3 species keys (spc_key). This can cause incorrect dedup behavior (skipping needed work or duplicating work), and can even accidentally collide if the integers overlap. Use separate sets (e.g., visited_ct_species_indices and visited_t3_species_keys) or normalize on one concept (e.g., species labels) throughout this function.
| for spc in list(reaction.r_species) + list(reaction.p_species): | ||
| spc_key = self.get_species_key(species=spc) | ||
| if spc_key in visited_species: | ||
| continue | ||
| visited_species.append(spc_key) |
There was a problem hiding this comment.
visited_species mixes two different identifier domains: Cantera species indices (index) and T3 species keys (spc_key). This can cause incorrect dedup behavior (skipping needed work or duplicating work), and can even accidentally collide if the integers overlap. Use separate sets (e.g., visited_ct_species_indices and visited_t3_species_keys) or normalize on one concept (e.g., species labels) throughout this function.
bd564b9 to
0b1c288
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 27 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for i in rxns: | ||
| logger.info(f'Removing duplicate mark from reaction {i}:\n({content["reactions"][i]})') | ||
| if 'duplicate' in content['reactions'][i].keys(): | ||
| print(f'Marking reaction {i} as non-duplicate.') | ||
| logger.info(f'Marking reaction {i} as non-duplicate.') | ||
| del content['reactions'][i]['duplicate'] | ||
| save_yaml_file(model_path, content) | ||
| changed = True | ||
| elif 'duplicate' in content['reactions'][i + 1].keys(): | ||
| logger.info(f'Marking reaction {i + 1} as non-duplicate.') | ||
| del content['reactions'][i + 1]['duplicate'] | ||
| changed = True |
There was a problem hiding this comment.
fix_no_duplicate_found() treats reaction numbers from get_mistakenly_marked_dup_rxns() as 0-based indices (content['reactions'][i]), but the traceback message “declared duplicate reaction number N” is 1-based (consistent with get_dup_rxn_indices() and the i - 1 usage in fix_undeclared_duplicate_reactions()). This likely clears the wrong reaction’s duplicate flag and can also go out of bounds with i + 1. Convert to 0-based once (idx = i - 1), bounds-check before idx+1, and operate consistently on 0-based list indices.
| def fix_cantera(model_path: str): | ||
| """ | ||
| Fix a Cantera model that has incorrectly marked duplicate reactions. | ||
| Creates a backup copy of the Cantera model and fixes the content of the original file in place. | ||
|
|
||
| Args: | ||
| model_path (str): The path to the cantera YAML model file. | ||
|
|
||
| Returns: | ||
| bool: Whether the model was fixed. | ||
| """ | ||
| if not os.path.isfile(model_path): | ||
| return False | ||
| shutil.copyfile(model_path, model_path + '.bak') | ||
| done, fixed = False, False | ||
| marked_dups: List[List[int]] = list() | ||
| counter = 0 | ||
| while not done and counter < 1000: | ||
| counter += 1 | ||
| tb = get_traceback(model_path) | ||
| if tb is None: | ||
| done = True | ||
| break | ||
| else: | ||
| if 'Undeclared duplicate reactions detected' in tb: | ||
| fix_undeclared_duplicate_reactions(model_path, tb) | ||
| fixed = True | ||
| if fix_undeclared_duplicate_reactions(model_path, tb, marked_dups): | ||
| fixed = True | ||
| else: | ||
| break | ||
| elif 'No duplicate found for declared duplicate reaction' in tb: | ||
| fix_no_duplicate_found(model_path, tb) | ||
| fixed = True | ||
| if fix_no_duplicate_found(model_path, tb): | ||
| fixed = True | ||
| else: | ||
| break | ||
| elif 'Invalid rate coefficient for reaction' in tb: | ||
| if remove_reaction_with_invalid_k(model_path, tb): | ||
| fixed = True | ||
| else: | ||
| break | ||
| else: | ||
| print(f'Could not fix {model_path}:\n\n{tb}') | ||
| logger.warning(f'Could not fix {model_path}:\n\n{tb}') | ||
| break | ||
| time.sleep(1) | ||
| if fixed: | ||
| print(f'Fixing Cantera model {model_path} (and creating a backup copy with a .bak extension).') | ||
| logger.info(f'Fixing Cantera model {model_path} (and creating a backup copy with a .bak extension).') | ||
| else: | ||
| os.remove(model_path + '.bak') | ||
| return done |
There was a problem hiding this comment.
fix_cantera()’s docstring says it returns whether the model was fixed, but it currently returns done (i.e., whether the file loads cleanly after the loop). This makes the return value ambiguous for callers (a model that needed no fixes returns True, and a model that was modified but still doesn’t load returns False). Consider returning fixed (or a tuple like (done, fixed)), and/or update the docstring to match the intended semantics.
| idt_sa_dict_all = compute_idt_sa(reactor_idt_dict=self.reactor_idt_dict, | ||
| perturbed_idt_dict=sa_dict, | ||
| delta_h=delta_h, | ||
| delta_k=delta_k, | ||
| ) |
There was a problem hiding this comment.
_get_sa_coefficients_brute_force() can submit perturbations with per-species delta_h (adaptive sizing) and also retries failures with halved delta_h/delta_k, but compute_idt_sa() is later called with the original global delta_h/delta_k. That makes the normalized SA coefficients incorrect whenever adaptive sizing or retries occur. Track the actual perturbation used per task (e.g., return it from the worker or keep a task->delta map) and normalize each coefficient with its own delta, or disable delta changes when computing coefficients.
| new_paths = {**paths, 'cantera annotated': perturbed_model_path} | ||
| ct_idt_adapter = CanteraIDT(t3=t3, paths=new_paths, rmg=rmg, logger=logger) | ||
| return ct_idt_adapter.simulate_idt_for_all_reactors(save_yaml=False, | ||
| save_fig=False, | ||
| energy='on', | ||
| max_idt=max_idt) |
There was a problem hiding this comment.
The worker() constructs CanteraIDT(t3=t3, paths=new_paths, rmg=rmg, logger=logger) without passing the parent adapter’s integration / SA tolerances (atol, rtol, sa_atol, sa_rtol). This can make perturbed-IDT runs inconsistent with the baseline IDT run and skew SA coefficients. Pass these tolerances explicitly (or serialize them into t3['sensitivity'] and read them in __init__) so baseline and perturbed simulations use identical numerical settings.
| if (idt_index_dc_dt > len(times) - 10 | ||
| or idt < 1e-12 | ||
| or max(concentration) < concentration[0] * 100): |
There was a problem hiding this comment.
compute_idt() uses max(concentration) < concentration[0] * 100 as a no-ignition filter. If the initial radical mole fraction is 0 (common), this condition becomes max(concentration) < 0, so it never filters anything and tiny numerical noise can be misclassified as ignition. Consider using a nonzero reference such as max(concentration[0], eps) and/or an absolute peak threshold (e.g., max(conc) > 1e-12) in addition to a relative-rise check.
| if (idt_index_dc_dt > len(times) - 10 | |
| or idt < 1e-12 | |
| or max(concentration) < concentration[0] * 100): | |
| peak_concentration = float(np.max(concentration)) | |
| reference_concentration = max(float(concentration[0]), np.finfo(float).eps) | |
| if (idt_index_dc_dt > len(times) - 10 | |
| or idt < 1e-12 | |
| or peak_concentration <= 1e-12 | |
| or peak_concentration < reference_concentration * 100): |
Updated reference and examples
Added adjoint SA for IDT sim Added the Cantera RCM simulation adapter Added the Cantera PFR T Profile sim adapter
Adds Cantera-based Ignition Delay Time (IDT) simulation, RCM adapter, φ-driven fuel/oxidizer/diluent mixture composition, adjoint + brute-force IDT sensitivity analysis, IDT-driven refinement in the main T3 loop, a cleaner restart() control flow, and experimental-data comparison. Plus docs, schema extensions, Cantera-model auto-fix extensions, and tests.