gridstate 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. {gridstate-2.2.0 → gridstate-2.3.0}/CHANGELOG.md +26 -0
  2. {gridstate-2.2.0 → gridstate-2.3.0}/PKG-INFO +1 -1
  3. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/_version.py +3 -3
  4. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/algorithms/ipm.py +39 -1
  5. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/algorithms/ipm_pd.py +166 -5
  6. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/api.py +44 -5
  7. gridstate-2.3.0/gridstate/ipm_hints.py +282 -0
  8. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/pipeline/config.py +22 -0
  9. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/pipeline/passes.py +2 -0
  10. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate.egg-info/PKG-INFO +1 -1
  11. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate.egg-info/SOURCES.txt +2 -0
  12. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate.egg-info/scm_file_list.json +2 -0
  13. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate.egg-info/scm_version.json +2 -2
  14. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_ipm_balance_modes.py +25 -0
  15. gridstate-2.3.0/tests/test_ipm_hints.py +113 -0
  16. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_ipm_pd.py +86 -1
  17. {gridstate-2.2.0 → gridstate-2.3.0}/.github/workflows/ci.yml +0 -0
  18. {gridstate-2.2.0 → gridstate-2.3.0}/.github/workflows/release.yml +0 -0
  19. {gridstate-2.2.0 → gridstate-2.3.0}/.gitignore +0 -0
  20. {gridstate-2.2.0 → gridstate-2.3.0}/.pre-commit-config.yaml +0 -0
  21. {gridstate-2.2.0 → gridstate-2.3.0}/LICENSE +0 -0
  22. {gridstate-2.2.0 → gridstate-2.3.0}/Makefile +0 -0
  23. {gridstate-2.2.0 → gridstate-2.3.0}/README.md +0 -0
  24. {gridstate-2.2.0 → gridstate-2.3.0}/build_cython.py +0 -0
  25. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/__init__.py +0 -0
  26. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/__pyinstaller/__init__.py +0 -0
  27. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/__pyinstaller/hook-gridstate.py +0 -0
  28. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/_arrays.py +0 -0
  29. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/adapters/__init__.py +0 -0
  30. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/adapters/from_pandapower.py +0 -0
  31. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/algebra/__init__.py +0 -0
  32. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/algebra/_selinv_kernels.py +0 -0
  33. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/algebra/base.py +0 -0
  34. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/algebra/estimators.py +0 -0
  35. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/algebra/jacobian_pattern.py +0 -0
  36. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/algebra/normalized_residuals.py +0 -0
  37. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/algorithms/__init__.py +0 -0
  38. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/algorithms/_gram_numba.py +0 -0
  39. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/algorithms/kkt_solver.py +0 -0
  40. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/algorithms/status.py +0 -0
  41. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/algorithms/wls.py +0 -0
  42. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/bad_data_repass.py +0 -0
  43. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/bounds.py +0 -0
  44. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/constants.py +0 -0
  45. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/contract/__init__.py +0 -0
  46. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/contract/_legacy.py +0 -0
  47. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/contract/containers.py +0 -0
  48. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/contract/derived.py +0 -0
  49. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/contract/derived_tables.py +0 -0
  50. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/contract/serialize.py +0 -0
  51. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/contract/tables.py +0 -0
  52. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/contract/validate.py +0 -0
  53. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/contract/version.py +0 -0
  54. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/losses.py +0 -0
  55. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/measurement_ids.py +0 -0
  56. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/pipeline/__init__.py +0 -0
  57. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/pipeline/context.py +0 -0
  58. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/pipeline/manifest.py +0 -0
  59. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/pipeline/orchestration.py +0 -0
  60. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/pipeline/preparation.py +0 -0
  61. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/pipeline/runner.py +0 -0
  62. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/pipeline/steps.py +0 -0
  63. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/post_processing.py +0 -0
  64. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/preprocessing/__init__.py +0 -0
  65. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/preprocessing/_scan.py +0 -0
  66. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/preprocessing/ipm_setup.py +0 -0
  67. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/preprocessing/meas_rows.py +0 -0
  68. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/preprocessing/mirror_voltage.py +0 -0
  69. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/preprocessing/node_props.py +0 -0
  70. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/preprocessing/pseudo_measurements.py +0 -0
  71. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/py.typed +0 -0
  72. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/quality_summary.py +0 -0
  73. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/resolve_pass.py +0 -0
  74. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/result.py +0 -0
  75. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/runtime.py +0 -0
  76. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/shunt_sanity.py +0 -0
  77. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/state.py +0 -0
  78. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/__init__.py +0 -0
  79. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/_filters.py +0 -0
  80. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/apply_resolved.py +0 -0
  81. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/generators.py +0 -0
  82. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/kinds.py +0 -0
  83. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/load_only_injection.py +0 -0
  84. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/loss_filter.py +0 -0
  85. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/measurements.py +0 -0
  86. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/noise.py +0 -0
  87. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/on_line.py +0 -0
  88. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/quality.py +0 -0
  89. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/rpn.py +0 -0
  90. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/shunts.py +0 -0
  91. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/sigma_floor.py +0 -0
  92. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/units.py +0 -0
  93. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/voltage_filter.py +0 -0
  94. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/telemetry/voltage_nominal.py +0 -0
  95. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/topology.py +0 -0
  96. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/units.py +0 -0
  97. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/utils.py +0 -0
  98. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/v_mirror.py +0 -0
  99. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/v_refine.py +0 -0
  100. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/validation/__init__.py +0 -0
  101. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/validation/_diagnostics.py +0 -0
  102. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/validation/bad_data.py +0 -0
  103. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/validation/chi2_test.py +0 -0
  104. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/validation/observability.py +0 -0
  105. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/working.py +0 -0
  106. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/ybus.py +0 -0
  107. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate/z_vector.py +0 -0
  108. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate.egg-info/dependency_links.txt +0 -0
  109. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate.egg-info/entry_points.txt +0 -0
  110. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate.egg-info/requires.txt +0 -0
  111. {gridstate-2.2.0 → gridstate-2.3.0}/gridstate.egg-info/top_level.txt +0 -0
  112. {gridstate-2.2.0 → gridstate-2.3.0}/pyproject.toml +0 -0
  113. {gridstate-2.2.0 → gridstate-2.3.0}/setup.cfg +0 -0
  114. {gridstate-2.2.0 → gridstate-2.3.0}/tests/__init__.py +0 -0
  115. {gridstate-2.2.0 → gridstate-2.3.0}/tests/_golden.py +0 -0
  116. {gridstate-2.2.0 → gridstate-2.3.0}/tests/conftest.py +0 -0
  117. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_adapters_lazy_import.py +0 -0
  118. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_aggregate_generators.py +0 -0
  119. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_algebra_base.py +0 -0
  120. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_anti_overshoot.py +0 -0
  121. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_arrays_helpers.py +0 -0
  122. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_bad_data.py +0 -0
  123. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_bad_data_repass.py +0 -0
  124. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_bounds.py +0 -0
  125. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_boxed_balance.py +0 -0
  126. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_breaker_reactance.py +0 -0
  127. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_chain_mirror.py +0 -0
  128. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_contract.py +0 -0
  129. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_contract_edge_cases.py +0 -0
  130. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_contract_runtime.py +0 -0
  131. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/golden/case118.json +0 -0
  132. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/golden/case118.npz +0 -0
  133. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/golden/case118_degraded.json +0 -0
  134. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/golden/case118_degraded.npz +0 -0
  135. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/golden/case14.json +0 -0
  136. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/golden/case14.npz +0 -0
  137. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/golden/case14_degraded.json +0 -0
  138. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/golden/case14_degraded.npz +0 -0
  139. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/golden/case30.json +0 -0
  140. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/golden/case30.npz +0 -0
  141. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/golden/case30_degraded.json +0 -0
  142. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/golden/case30_degraded.npz +0 -0
  143. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/golden/manifest.json +0 -0
  144. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/ieee/case118.npz +0 -0
  145. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/ieee/case118_truth.npz +0 -0
  146. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/ieee/case14.npz +0 -0
  147. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/ieee/case14_truth.npz +0 -0
  148. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/ieee/case30.npz +0 -0
  149. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_data/ieee/case30_truth.npz +0 -0
  150. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_derived_tables.py +0 -0
  151. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_enum_codes.py +0 -0
  152. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_flow_sigma_floor.py +0 -0
  153. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_from_pandapower.py +0 -0
  154. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_generator_shunt_kernels.py +0 -0
  155. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_golden.py +0 -0
  156. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_gram_numba.py +0 -0
  157. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_ieee_cases.py +0 -0
  158. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_ipm.py +0 -0
  159. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_ipm_injection_roles.py +0 -0
  160. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_ipm_integration.py +0 -0
  161. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_jacobian_pattern.py +0 -0
  162. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_kkt_solver.py +0 -0
  163. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_load_only_injection.py +0 -0
  164. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_losses_breakdown.py +0 -0
  165. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_losses_q_convention.py +0 -0
  166. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_losses_trafo_classification.py +0 -0
  167. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_materialize_kernel.py +0 -0
  168. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_measurement_filter_kernels.py +0 -0
  169. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_measurement_ids.py +0 -0
  170. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_mirror_voltage_kernel.py +0 -0
  171. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_near_critical_ranking.py +0 -0
  172. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_neutral_wording.py +0 -0
  173. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_noise_model.py +0 -0
  174. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_normalized_residuals.py +0 -0
  175. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_numpy_only_runtime.py +0 -0
  176. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_observability.py +0 -0
  177. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_on_line_kernel.py +0 -0
  178. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_perf_synthetic_network.py +0 -0
  179. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_pipeline.py +0 -0
  180. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_pipeline_config_validation.py +0 -0
  181. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_pipeline_idempotent.py +0 -0
  182. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_pipeline_invariant_errors.py +0 -0
  183. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_pipeline_step_order.py +0 -0
  184. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_pipeline_warm_start.py +0 -0
  185. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_pipeline_working_layer.py +0 -0
  186. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_post_processing.py +0 -0
  187. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_preparation_steps.py +0 -0
  188. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_prepare_network.py +0 -0
  189. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_pseudo_unknown_load.py +0 -0
  190. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_pseudo_v_unobservable.py +0 -0
  191. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_quality_summary_paths.py +0 -0
  192. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_reconcile_balance.py +0 -0
  193. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_refine_merge.py +0 -0
  194. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_resolve_pass.py +0 -0
  195. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_result_quality_summary.py +0 -0
  196. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_rn_selected_inversion.py +0 -0
  197. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_serialize_npz.py +0 -0
  198. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_shunt_sanity.py +0 -0
  199. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_solve_status.py +0 -0
  200. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_state.py +0 -0
  201. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_state_range.py +0 -0
  202. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_telemetry_apply_kernel.py +0 -0
  203. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_telemetry_bounds.py +0 -0
  204. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_telemetry_physics_kernels.py +0 -0
  205. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_tm_code_quality.py +0 -0
  206. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_topology_kernels.py +0 -0
  207. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_topology_port.py +0 -0
  208. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_transformer_from_shunt.py +0 -0
  209. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_units.py +0 -0
  210. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_units_array_kernels.py +0 -0
  211. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_v_mirror.py +0 -0
  212. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_v_refine.py +0 -0
  213. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_variance_charging.py +0 -0
  214. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_version.py +0 -0
  215. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_voltage_calibration_gen_nodes.py +0 -0
  216. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_voltage_range_filter_fallback.py +0 -0
  217. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_wls.py +0 -0
  218. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_wls_robustness.py +0 -0
  219. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_working_container.py +0 -0
  220. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_ybus.py +0 -0
  221. {gridstate-2.2.0 → gridstate-2.3.0}/tests/test_z_vector.py +0 -0
  222. {gridstate-2.2.0 → gridstate-2.3.0}/tools/gen_ieee_fixtures.py +0 -0
  223. {gridstate-2.2.0 → gridstate-2.3.0}/tools/golden.py +0 -0
@@ -9,6 +9,32 @@ types of the merged pull requests; breaking and behavior-changing items are
9
9
  listed separately under "Breaking changes" and "Behavior changes". A release is
10
10
  cut by tagging the merged main commit; the version is derived from that tag.
11
11
 
12
+ ## v2.3.0 (2026-10-17)
13
+
14
+ ### Behavior changes
15
+
16
+ - `ipm_pd` uses a fixed Huber threshold `c_eff = huber_c` by default
17
+ (`ipm_huber_threshold="fixed"`). The previous per-iteration adaptive raise
18
+ made the estimate depend on the solver path; `"adaptive"` restores it. On
19
+ regional models the quality is unchanged; `ipm_pd` estimates move slightly
20
+ (≤ 6e-5 p.u. V on the test models). `wls` / `ipm` are unaffected (#111)
21
+ - `ipm_pd` selects its step by Armijo backtracking on a merit function
22
+ (barrier objective, augmented Lagrangian of the equality rows, slack
23
+ penalty). It now converges on weakly determined cases (e.g. unmeasured
24
+ units with a wide box under `injection_roles`) that needed the primal
25
+ fallback; `ipm_pd` estimates can move slightly. The pipeline's `ipm_pd`
26
+ iteration limit is 300 (100 under a hard balance); the solve stops after
27
+ `max_search_failures` iterations without merit decrease (#106)
28
+
29
+ ### Feat
30
+
31
+ - Convergence hints: a solve that does not converge, falls back from
32
+ `ipm_pd`, or takes more than `ipm_hint_iterations` iterations with a
33
+ dominant step limiter logs a WARNING naming the limiting nodes / variables
34
+ (with a suggestion), loose unmeasured box variables, the largest normalized
35
+ residuals and part measurements outside their range. `IPMResult` gains
36
+ `step_limiters` / `limiting_rows` (#110)
37
+
12
38
  ## v2.2.0 (2026-10-17)
13
39
 
14
40
  All new behaviour is opt-in; with the defaults the results are unchanged.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridstate
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Power system State Estimation: pure-Python WLS/IPM on numpy/scipy, input via a versioned file contract (npz).
5
5
  Author-email: Evgeny Istomin <gena@alpisto.eu>
6
6
  License: MIT
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.2.0'
22
- __version_tuple__ = version_tuple = (2, 2, 0)
21
+ __version__ = version = '2.3.0'
22
+ __version_tuple__ = version_tuple = (2, 3, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g06a6dcf24'
24
+ __commit_id__ = commit_id = 'gef79dd7f7'
@@ -46,7 +46,7 @@ from __future__ import annotations
46
46
  import logging
47
47
  import math
48
48
  from collections.abc import Callable
49
- from dataclasses import dataclass
49
+ from dataclasses import dataclass, field
50
50
 
51
51
  import numpy as np
52
52
  from scipy.sparse import csr_matrix
@@ -106,6 +106,26 @@ class IPMResult:
106
106
  solve_status: SolveStatus = SolveStatus.ERROR
107
107
  # max |c| over the equality rows at the final point (p.u.); nan without them.
108
108
  eq_inf_final: float = float("nan")
109
+ # Diagnostics of slow solves (see :mod:`gridstate.ipm_hints`): state index →
110
+ # number of iterations whose step that coordinate limited (it set the
111
+ # trust-region clip or the boundary rule, or dominated a step the line
112
+ # search cut).
113
+ step_limiters: dict[int, int] = field(default_factory=dict)
114
+ # ipm_pd only: measurement-box row → iterations whose step its bound limited.
115
+ limiting_rows: dict[int, int] = field(default_factory=dict)
116
+
117
+
118
+ def _count(counts: dict[int, int], key: int) -> None:
119
+ counts[key] = counts.get(key, 0) + 1
120
+
121
+
122
+ def _blocking(cur: np.ndarray, step: np.ndarray, lo: np.ndarray, hi: np.ndarray) -> int:
123
+ """Position of the coordinate that first reaches its bound along ``step`` (−1: none)."""
124
+ with np.errstate(divide="ignore", invalid="ignore"):
125
+ ratio = np.where(step < 0, (lo - cur) / step, np.where(step > 0, (hi - cur) / step, np.inf))
126
+ ratio = np.where(ratio > 0, ratio, np.inf)
127
+ k = int(np.argmin(ratio)) if ratio.size else -1
128
+ return k if k >= 0 and np.isfinite(ratio[k]) else -1
109
129
 
110
130
 
111
131
  def _project_to_interior(
@@ -450,6 +470,15 @@ def solve_ipm(
450
470
  # it, so the next iteration (and the final objective) reuse it instead of
451
471
  # calling residual_fn again at the same point.
452
472
  r_at_x: np.ndarray | None = None
473
+ limiters: dict[int, int] = {}
474
+ tr_pos = np.flatnonzero(tr_mask) if tr_mask is not None else None
475
+
476
+ def _dominant(step: np.ndarray) -> int:
477
+ """State index of the largest step component inside the trust region."""
478
+ if tr_pos is None:
479
+ return int(np.argmax(np.abs(step)))
480
+ return int(tr_pos[np.argmax(np.abs(step[tr_pos]))]) if tr_pos.size else 0
481
+
453
482
  for outer_iter in range(outer_max):
454
483
  any_inner_step = False # Lazy outer: if no step was made → break.
455
484
  # inner exited by tolerance (grad/step < inner_tol) — a stationary
@@ -536,6 +565,7 @@ def solve_ipm(
536
565
  step_inf_raw = float(np.max(np.abs(dx_tr))) if dx_tr.size else 0.0
537
566
  if step_inf_raw > tr_radius:
538
567
  dx = dx * (tr_radius / step_inf_raw)
568
+ _count(limiters, _dominant(dx))
539
569
  step_inf = float(np.max(np.abs(dx))) if dx.size else 0.0
540
570
  if step_inf < inner_tol:
541
571
  inner_converged = True
@@ -551,6 +581,11 @@ def solve_ipm(
551
581
  fraction_to_boundary,
552
582
  )
553
583
  alpha = min(alpha_max, 1.0) if math.isfinite(alpha_max) else 1.0
584
+ if alpha < 1.0:
585
+ k = _blocking(x[box_idx], dx[box_idx], box_lo, box_hi)
586
+ if k >= 0:
587
+ _count(limiters, int(box_idx[k]))
588
+ alpha_first = alpha
554
589
 
555
590
  phi_cur = 0.5 * float(r @ (R_inv @ r)) + _barrier_value(
556
591
  x,
@@ -584,6 +619,8 @@ def solve_ipm(
584
619
  break
585
620
 
586
621
  iter_inner_total += 1
622
+ if not accepted or alpha < alpha_first:
623
+ _count(limiters, _dominant(dx))
587
624
  if not accepted:
588
625
  inner_stalled = True
589
626
  # No admissible step found — the outer loop will increase μ and try again.
@@ -684,6 +721,7 @@ def solve_ipm(
684
721
  status=status,
685
722
  solve_status=solve_status,
686
723
  eq_inf_final=eq_inf,
724
+ step_limiters=limiters,
687
725
  )
688
726
 
689
727
 
@@ -33,6 +33,22 @@ Iteration (one factorization, two solves)::
33
33
  σ = clamp((μ_aff/μ)³, σ_min, 1)
34
34
  corrector: target σμ − Δs_aff·Δy_aff, same factor → Δ
35
35
  αp, αd = separate fraction-to-boundary steps (τ = 0.99995) for (x, s) and (λ, y)
36
+ αp ← Armijo backtracking on the merit φ (below)
37
+
38
+ Step selection. The fraction-to-boundary rule alone lets the iteration cycle
39
+ on weakly determined directions (e.g. an unmeasured unit with a wide box):
40
+ long steps along them change neither the state estimate nor the residuals by
41
+ much, yet they never settle. The primal step is therefore backtracked on the
42
+ merit of the iteration (weights, ``λ`` and the target ``σμ`` fixed)::
43
+
44
+ φ(x, s) = ½ Σ w r² + λᵀc + ½ Σ ρ c² − σμ Σ log s + ν (‖g − l − s_l‖₁ + ‖u − g − s_u‖₁)
45
+
46
+ with ``ν ≥ 2·max y`` kept non-decreasing. At consistent slacks the pure
47
+ centering direction (target ``σμ − s·y``) is the Newton step on ``φ`` with the
48
+ positive definite primal-dual Hessian, hence a descent direction; the
49
+ corrector's second-order term may spoil descent, and then the centering
50
+ direction is taken instead (one more solve on the same factor). The dual step
51
+ keeps its own fraction-to-boundary length.
36
52
 
37
53
  Start on the central path: ``s`` = half-range of a two-sided constraint (the
38
54
  distance for a one-sided one), ``y = μ0/s``; the initial point need not satisfy
@@ -42,6 +58,7 @@ distance for a one-sided one), ``y = μ0/s``; the initial point need not satisfy
42
58
  from __future__ import annotations
43
59
 
44
60
  import logging
61
+ import math
45
62
  from collections.abc import Callable
46
63
  from dataclasses import dataclass
47
64
 
@@ -49,7 +66,7 @@ import numpy as np
49
66
  from scipy.sparse import csr_matrix
50
67
 
51
68
  from gridstate.algebra.estimators import HuberReweighter
52
- from gridstate.algorithms.ipm import IPMResult, _project_to_interior
69
+ from gridstate.algorithms.ipm import IPMResult, _count, _project_to_interior
53
70
  from gridstate.algorithms.kkt_solver import KKTSolver
54
71
  from gridstate.algorithms.status import SolveStatus
55
72
 
@@ -65,6 +82,20 @@ def _max_step(v: np.ndarray, dv: np.ndarray) -> float:
65
82
  return min(1.0, float(np.min(-v[neg] / dv[neg])))
66
83
 
67
84
 
85
+ def _blocking_constraint(
86
+ s_l: np.ndarray, ds_l: np.ndarray, s_u: np.ndarray, ds_u: np.ndarray
87
+ ) -> tuple[int, bool]:
88
+ """Position (in ``il`` or ``iu``) of the slack that first reaches zero, and whether it is lower."""
89
+ with np.errstate(divide="ignore"):
90
+ rl = np.where(ds_l < 0, -s_l / ds_l, np.inf)
91
+ ru = np.where(ds_u < 0, -s_u / ds_u, np.inf)
92
+ kl = int(np.argmin(rl)) if rl.size else -1
93
+ ku = int(np.argmin(ru)) if ru.size else -1
94
+ if ku < 0 or (kl >= 0 and rl[kl] <= ru[ku]):
95
+ return kl, True
96
+ return ku, False
97
+
98
+
68
99
  Direction = tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]
69
100
 
70
101
 
@@ -120,6 +151,44 @@ class _Linearization:
120
151
  return dx, ds_l, ds_u, dy_l, dy_u, dlam
121
152
 
122
153
 
154
+ @dataclass
155
+ class _Merit:
156
+ """The merit ``φ`` of one iteration (see the module docstring); ``mu`` is the target ``σμ``."""
157
+
158
+ w_data: np.ndarray
159
+ lam: np.ndarray
160
+ rho: np.ndarray
161
+ eq_rows: np.ndarray
162
+ mu: float
163
+ nu: float
164
+ lo_l: np.ndarray
165
+ hi_u: np.ndarray
166
+ il: np.ndarray
167
+ iu: np.ndarray
168
+
169
+ def value(self, r: np.ndarray, g: np.ndarray, s_l: np.ndarray, s_u: np.ndarray) -> float:
170
+ c = -r[self.eq_rows]
171
+ phi = 0.5 * float(r @ (self.w_data * r)) + float(self.lam @ c)
172
+ phi += 0.5 * float(c @ (self.rho * c))
173
+ if self.mu > 0.0:
174
+ phi -= self.mu * (float(np.sum(np.log(s_l))) + float(np.sum(np.log(s_u))))
175
+ phi += self.nu * (
176
+ float(np.sum(np.abs(g[self.il] - self.lo_l - s_l)))
177
+ + float(np.sum(np.abs(self.hi_u - g[self.iu] - s_u)))
178
+ )
179
+ return phi
180
+
181
+ def slope(
182
+ self, lin: _Linearization, hdx: np.ndarray, ds_l: np.ndarray, ds_u: np.ndarray
183
+ ) -> float:
184
+ """Directional derivative of :meth:`value` along a direction at the linearization point."""
185
+ d = -float((self.w_data * lin.r) @ hdx)
186
+ d += float((self.lam + self.rho * lin.c) @ hdx[self.eq_rows])
187
+ d -= self.mu * (float(np.sum(ds_l / lin.s_l)) + float(np.sum(ds_u / lin.s_u)))
188
+ d -= self.nu * (float(np.sum(np.abs(lin.r_l))) + float(np.sum(np.abs(lin.r_u))))
189
+ return d
190
+
191
+
123
192
  def solve_ipm_pd(
124
193
  x_init: np.ndarray,
125
194
  residual_fn: Callable[[np.ndarray], np.ndarray],
@@ -138,6 +207,10 @@ def solve_ipm_pd(
138
207
  mu_min: float = 1e-6,
139
208
  sigma_min: float = 1e-3,
140
209
  fraction_to_boundary: float = 0.99995,
210
+ armijo_c: float = 1e-4,
211
+ armijo_backtrack: float = 0.5,
212
+ armijo_max: int = 10,
213
+ max_search_failures: int = 20,
141
214
  step_tol: float = 1e-4,
142
215
  feas_tol: float = 1e-6,
143
216
  max_iter: int = 100,
@@ -168,6 +241,12 @@ def solve_ipm_pd(
168
241
  mu_min: stop once the complementarity μ is below it (with the other tests).
169
242
  sigma_min: lower clamp of the centering parameter σ.
170
243
  fraction_to_boundary: τ of the step-length rule.
244
+ armijo_c, armijo_backtrack, armijo_max: Armijo backtracking of the
245
+ primal step on the merit (see the module docstring); at most
246
+ ``armijo_max`` trial points, ``0`` disables the search.
247
+ max_search_failures: stop (``"stalled"``) once this many iterations found
248
+ no sufficient decrease: the iteration is not converging and the
249
+ remaining budget would be spent for nothing.
171
250
  step_tol: stop once the last primal step ``|αp·Δx|∞`` over the coordinates
172
251
  outside ``box_idx`` (V/δ) is below it. Box variables may still move
173
252
  along directions the data do not see (the common level of a node's
@@ -195,7 +274,8 @@ def solve_ipm_pd(
195
274
 
196
275
  Returns:
197
276
  :class:`~gridstate.algorithms.ipm.IPMResult`; ``status`` is ``"kkt"`` when
198
- every stopping test holds, ``"stalled"`` at ``max_iter``, ``"error"`` on a
277
+ every stopping test holds, ``"stalled"`` at ``max_iter`` or after
278
+ ``max_search_failures``, ``"error"`` on a
199
279
  failed or non-finite solve. ``iterations_outer`` = ``iterations_inner_total``
200
280
  = the number of iterations.
201
281
  """
@@ -267,6 +347,9 @@ def solve_ipm_pd(
267
347
 
268
348
  tr_mask = np.ones(n, dtype=bool)
269
349
  tr_mask[box_idx] = False
350
+ tr_pos = np.flatnonzero(tr_mask)
351
+ limiters: dict[int, int] = {}
352
+ limiting_rows: dict[int, int] = {}
270
353
 
271
354
  def g_of(xv: np.ndarray, r: np.ndarray) -> np.ndarray:
272
355
  return np.concatenate([xv[box_idx], -r[mb_rows]])
@@ -287,6 +370,10 @@ def solve_ipm_pd(
287
370
  y_l = mu0 / s_l
288
371
  y_u = mu0 / s_u
289
372
  lam = np.zeros(eq_rows.size, dtype=np.float64)
373
+ nu = 0.0
374
+ n_centering = 0
375
+ n_ls_fail = 0
376
+ stop_search = False
290
377
 
291
378
  w = w_base
292
379
  mu = mu0 if n_pairs else 0.0
@@ -388,21 +475,87 @@ def solve_ipm_pd(
388
475
  status, message = "error", f"non-finite step (iteration {it})"
389
476
  break
390
477
 
478
+ # ---- merit backtracking ----
479
+ mu_t = target if n_pairs else 0.0
480
+ nu = max(nu, 2.0 * float(max(np.max(y_l, initial=0.0), np.max(y_u, initial=0.0))))
481
+ merit = _Merit(
482
+ w_data=w_data,
483
+ lam=lam,
484
+ rho=rho,
485
+ eq_rows=eq_rows,
486
+ mu=mu_t,
487
+ nu=nu,
488
+ lo_l=lo[il],
489
+ hi_u=hi[iu],
490
+ il=il,
491
+ iu=iu,
492
+ )
493
+ slope = merit.slope(lin, H @ dx, ds_l, ds_u)
494
+ if slope >= 0.0 and n_pairs:
495
+ # The corrector's second-order term can spoil descent; the pure
496
+ # centering direction is a Newton step on the merit and descends
497
+ # whenever the slacks are consistent.
498
+ dx_c, ds_l_c, ds_u_c, dy_l_c, dy_u_c, dlam_c = lin.direction(
499
+ target - s_l * y_l, target - s_u * y_u
500
+ )
501
+ slope_c = merit.slope(lin, H @ dx_c, ds_l_c, ds_u_c)
502
+ if slope_c < 0.0 and np.all(np.isfinite(dx_c)):
503
+ dx, ds_l, ds_u, dy_l, dy_u, dlam = dx_c, ds_l_c, ds_u_c, dy_l_c, dy_u_c, dlam_c
504
+ slope = slope_c
505
+ n_centering += 1
506
+
391
507
  tau = fraction_to_boundary
392
508
  a_p = min(_max_step(s_l, ds_l / tau), _max_step(s_u, ds_u / tau)) if n_pairs else 1.0
393
509
  a_d = min(_max_step(y_l, dy_l / tau), _max_step(y_u, dy_u / tau)) if n_pairs else 1.0
394
510
  free_inf = float(np.max(np.abs(dx[tr_mask]))) if tr_mask.any() else 0.0
395
511
  a_bound = a_p
512
+ if a_bound < 1.0:
513
+ k, lower = _blocking_constraint(s_l, ds_l, s_u, ds_u)
514
+ if k >= 0:
515
+ kc = int(il[k] if lower else iu[k])
516
+ if kc < nb:
517
+ _count(limiters, int(box_idx[kc]))
518
+ else:
519
+ _count(limiting_rows, int(mb_rows[kc - nb]))
396
520
  if free_inf * a_p > tr_radius:
397
521
  a_p = tr_radius / free_inf
522
+ # The stopping test measures the Newton step, not how far the search cut it.
523
+ step_inf = a_p * free_inf
524
+ a_full = a_p
525
+ r_new = None
526
+ # A direction that does not descend (inconsistent slacks of a nonlinear
527
+ # measurement box) is taken unsearched, as without the merit.
528
+ if slope < 0.0 and armijo_max > 0:
529
+ phi0 = merit.value(r, g, s_l, s_u)
530
+ for _ in range(armijo_max):
531
+ x_try = x + a_p * dx
532
+ r_try = residual_fn(x_try)
533
+ s_l_try = s_l + a_p * ds_l
534
+ s_u_try = s_u + a_p * ds_u
535
+ phi = merit.value(r_try, g_of(x_try, r_try), s_l_try, s_u_try)
536
+ r_new = r_try
537
+ if math.isfinite(phi) and phi <= phi0 + armijo_c * a_p * slope:
538
+ break
539
+ a_p *= armijo_backtrack
540
+ r_new = None
541
+ else:
542
+ n_ls_fail += 1
543
+ if n_ls_fail >= max_search_failures:
544
+ stop_search = True
545
+ if r_new is None:
546
+ # No sufficient decrease: keep the last (shortest) trial step.
547
+ a_p /= armijo_backtrack
548
+ if (a_p < a_full or a_full < a_bound) and tr_pos.size:
549
+ _count(limiters, int(tr_pos[np.argmax(np.abs(dx[tr_pos]))]))
398
550
 
399
551
  logger.debug(
400
- "ipm_pd it=%d μ=%.2e σ=%.2e αp=%.3f (bound %.3f) αd=%.3f |Δx_free|∞=%.2e "
552
+ "ipm_pd it=%d μ=%.2e σ=%.2e αp=%.3f (before search %.3f, bound %.3f) αd=%.3f |Δx_free|∞=%.2e "
401
553
  "|Δx|∞=%.2e |c|∞=%.2e feas=%.2e",
402
554
  it,
403
555
  mu,
404
556
  sigma,
405
557
  a_p,
558
+ a_full,
406
559
  a_bound,
407
560
  a_d,
408
561
  free_inf,
@@ -416,9 +569,10 @@ def solve_ipm_pd(
416
569
  y_l = y_l + a_d * dy_l
417
570
  y_u = y_u + a_d * dy_u
418
571
  lam = lam + a_d * dlam
419
- step_inf = a_p * free_inf
420
- r = residual_fn(x)
572
+ r = residual_fn(x) if r_new is None else r_new
421
573
  g = g_of(x, r)
574
+ if stop_search:
575
+ break
422
576
  else:
423
577
  it = max_iter
424
578
 
@@ -429,12 +583,17 @@ def solve_ipm_pd(
429
583
  detail = f"it={iterations}, μ={mu:.2e}, |step|∞={step_inf:.2e}, |grad|∞={grad_inf:.2e}" + (
430
584
  f", |c_eq|∞={eq_inf:.2e}" if eq_rows.size else ""
431
585
  )
586
+ if n_centering or n_ls_fail:
587
+ detail += f", centering={n_centering}, search failures={n_ls_fail}"
432
588
  if status == "kkt":
433
589
  message = f"converged (kkt): {detail}"
434
590
  solve_status = SolveStatus.KKT
435
591
  elif status == "error":
436
592
  message = f"{message}: {detail}"
437
593
  solve_status = SolveStatus.ERROR
594
+ elif stop_search:
595
+ message = f"no merit decrease: {detail}"
596
+ solve_status = SolveStatus.STALLED
438
597
  else:
439
598
  message = f"iteration limit: {detail}"
440
599
  solve_status = SolveStatus.MAX_ITER
@@ -450,6 +609,8 @@ def solve_ipm_pd(
450
609
  status=status,
451
610
  solve_status=solve_status,
452
611
  eq_inf_final=eq_inf,
612
+ step_limiters=limiters,
613
+ limiting_rows=limiting_rows,
453
614
  )
454
615
 
455
616
 
@@ -28,6 +28,7 @@ from gridstate.algorithms.status import (
28
28
  success_from_status,
29
29
  )
30
30
  from gridstate.algorithms.wls import solve_wls
31
+ from gridstate.ipm_hints import has_dominant_limiter, ipm_convergence_hints
31
32
  from gridstate.post_processing import (
32
33
  apply_load_characteristic,
33
34
  reconcile_node_balance,
@@ -676,6 +677,8 @@ def _run_ipm(
676
677
  measurement_bounds: bool = False,
677
678
  injection_roles: bool = False,
678
679
  role_estimates: dict[int, float] | None = None,
680
+ hint_iterations: int = 100,
681
+ huber_threshold: str = "fixed",
679
682
  **ipm_kwargs: Any,
680
683
  ) -> IPMResult:
681
684
  """IPM mode: extends the state vector with box-vars and solves the primal log-barrier WLS.
@@ -710,7 +713,18 @@ def _run_ipm(
710
713
  variables; ``role_estimates`` (when given) receives the estimate of each
711
714
  part row in MW / MVAr by measurement id, ``nan`` for a part row folded into a
712
715
  net injection.
716
+
717
+ A solve that does not converge, falls back from ``ipm_pd`` or takes more
718
+ than ``hint_iterations`` iterations (``0`` — no iteration trigger) logs a
719
+ WARNING with convergence hints (:mod:`gridstate.ipm_hints`).
720
+
721
+ ``huber_threshold`` (``"ipm_pd"`` only): ``"fixed"`` keeps the Huber
722
+ threshold at ``huber_c``; ``"adaptive"`` raises it to ``k·median|r/σ|``,
723
+ recomputed every iteration, so the estimate follows the solver path. The
724
+ primal IPM keeps its adaptive threshold (recomputed once per outer step).
713
725
  """
726
+ if huber_threshold not in ("fixed", "adaptive"):
727
+ raise ValueError(f"huber_threshold={huber_threshold!r} is not one of fixed/adaptive")
714
728
  if measurement_bounds and solver != "ipm_pd":
715
729
  raise ValueError("measurement_bounds requires algorithm='ipm_pd'")
716
730
  if trust_region_scope not in ("all", "state"):
@@ -780,6 +794,7 @@ def _run_ipm(
780
794
  return algebra.evaluate_jacobian(v, delta)
781
795
 
782
796
  eq_idx = setup.balance_rows if setup.balance_tol is not None else None
797
+ pd_result: IPMResult | None = None
783
798
  if solver == "ipm_pd":
784
799
  mb_rows = mb_lo = mb_hi = None
785
800
  if measurement_bounds and measurements is not None:
@@ -807,20 +822,24 @@ def _run_ipm(
807
822
  eq_tol=setup.balance_tol,
808
823
  mu_init=1.0,
809
824
  step_tol=max(tolerance, 1e-4),
810
- max_iter=max(max_iterations, 100),
825
+ # Weakly determined units need up to ~260 iterations with separate
826
+ # injection parts; a hard balance does not converge in that budget
827
+ # either way, so it keeps the short one before the fallback.
828
+ max_iter=max(max_iterations, 100 if eq_idx is not None else 300),
811
829
  tr_radius=0.5,
812
830
  huber_c=huber_c,
813
831
  huber_mask=huber_mask,
814
832
  huber_w_floor=huber_w_floor,
815
833
  huber_use_mad=huber_use_mad,
816
834
  huber_warmup_iters=2,
817
- huber_adaptive_k=2.0,
835
+ huber_adaptive_k=0.0 if huber_threshold == "fixed" else 2.0,
818
836
  kkt_solver=kkt,
819
837
  )
838
+ pd_result = result
820
839
  if result.solve_status is not SolveStatus.KKT:
821
- # The primal-dual iteration has no merit line search and may cycle
822
- # on a weakly determined direction; the primal solver's Armijo
823
- # search does not. Measurement boxes have no primal counterpart.
840
+ # Safety net: on a weakly determined direction (e.g. an unmeasured
841
+ # unit with a wide box) the primal-dual iteration can still crawl
842
+ # past its limit. Measurement boxes have no primal counterpart.
824
843
  logger.warning(
825
844
  "ipm_pd did not converge (%s), falling back to the primal IPM", result.message
826
845
  )
@@ -863,6 +882,26 @@ def _run_ipm(
863
882
  if solver == "ipm_pd":
864
883
  result.message = f"ipm_pd fallback to ipm ({pd_message}): {result.message}"
865
884
 
885
+ slow: IPMResult | None = None
886
+ if pd_result is not None and pd_result is not result:
887
+ slow, reason = pd_result, "ipm_pd fell back to the primal IPM"
888
+ elif not result.success:
889
+ slow, reason = result, f"not converged ({result.status})"
890
+ elif 0 < hint_iterations < result.iterations_inner_total and has_dominant_limiter(
891
+ result, result.iterations_inner_total
892
+ ):
893
+ slow, reason = result, f"{result.iterations_inner_total} iterations > {hint_iterations}"
894
+ if slow is not None:
895
+ hints = ipm_convergence_hints(
896
+ setup=setup,
897
+ network_pu=network_pu,
898
+ result=slow,
899
+ residual=residual_fn(result.x),
900
+ iterations=slow.iterations_inner_total,
901
+ )
902
+ if hints:
903
+ logger.warning("%s convergence hints (%s):\n%s", solver, reason, "\n".join(hints))
904
+
866
905
  # Extract the box-vars and write them into the node table (MW/MVAr = p.u. * BASE_MVA).
867
906
  _, _, pgen, qgen, pnag, qnag = unpack_full(result.x, layout_ipm)
868
907