gridstate 2.2.0__tar.gz → 2.4.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 (227) hide show
  1. {gridstate-2.2.0 → gridstate-2.4.0}/CHANGELOG.md +47 -0
  2. {gridstate-2.2.0 → gridstate-2.4.0}/PKG-INFO +1 -1
  3. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/_version.py +3 -3
  4. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/algorithms/ipm.py +39 -1
  5. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/algorithms/ipm_pd.py +166 -5
  6. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/api.py +77 -5
  7. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/contract/__init__.py +2 -0
  8. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/contract/tables.py +39 -1
  9. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/contract/validate.py +25 -1
  10. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/contract/version.py +3 -1
  11. gridstate-2.4.0/gridstate/ipm_hints.py +255 -0
  12. gridstate-2.4.0/gridstate/load_characteristics.py +99 -0
  13. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/pipeline/config.py +46 -0
  14. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/pipeline/passes.py +3 -0
  15. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/pipeline/preparation.py +1 -1
  16. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/post_processing.py +34 -0
  17. gridstate-2.4.0/gridstate/solution_hints.py +171 -0
  18. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/shunts.py +25 -12
  19. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/working.py +19 -5
  20. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate.egg-info/PKG-INFO +1 -1
  21. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate.egg-info/SOURCES.txt +6 -0
  22. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate.egg-info/scm_file_list.json +6 -0
  23. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate.egg-info/scm_version.json +2 -2
  24. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_breaker_reactance.py +13 -0
  25. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/golden/case118.json +6 -6
  26. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/golden/case118.npz +0 -0
  27. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/golden/case118_degraded.json +6 -6
  28. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/golden/case118_degraded.npz +0 -0
  29. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/golden/case14.json +6 -6
  30. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/golden/case14.npz +0 -0
  31. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/golden/case14_degraded.json +6 -6
  32. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/golden/case14_degraded.npz +0 -0
  33. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/golden/case30.json +6 -6
  34. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/golden/case30.npz +0 -0
  35. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/golden/case30_degraded.json +6 -6
  36. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/golden/case30_degraded.npz +0 -0
  37. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/golden/manifest.json +75 -12
  38. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_ipm_balance_modes.py +25 -0
  39. gridstate-2.4.0/tests/test_ipm_hints.py +113 -0
  40. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_ipm_pd.py +86 -1
  41. gridstate-2.4.0/tests/test_load_characteristic_segments.py +159 -0
  42. gridstate-2.4.0/tests/test_solution_hints.py +40 -0
  43. {gridstate-2.2.0 → gridstate-2.4.0}/.github/workflows/ci.yml +0 -0
  44. {gridstate-2.2.0 → gridstate-2.4.0}/.github/workflows/release.yml +0 -0
  45. {gridstate-2.2.0 → gridstate-2.4.0}/.gitignore +0 -0
  46. {gridstate-2.2.0 → gridstate-2.4.0}/.pre-commit-config.yaml +0 -0
  47. {gridstate-2.2.0 → gridstate-2.4.0}/LICENSE +0 -0
  48. {gridstate-2.2.0 → gridstate-2.4.0}/Makefile +0 -0
  49. {gridstate-2.2.0 → gridstate-2.4.0}/README.md +0 -0
  50. {gridstate-2.2.0 → gridstate-2.4.0}/build_cython.py +0 -0
  51. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/__init__.py +0 -0
  52. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/__pyinstaller/__init__.py +0 -0
  53. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/__pyinstaller/hook-gridstate.py +0 -0
  54. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/_arrays.py +0 -0
  55. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/adapters/__init__.py +0 -0
  56. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/adapters/from_pandapower.py +0 -0
  57. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/algebra/__init__.py +0 -0
  58. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/algebra/_selinv_kernels.py +0 -0
  59. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/algebra/base.py +0 -0
  60. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/algebra/estimators.py +0 -0
  61. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/algebra/jacobian_pattern.py +0 -0
  62. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/algebra/normalized_residuals.py +0 -0
  63. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/algorithms/__init__.py +0 -0
  64. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/algorithms/_gram_numba.py +0 -0
  65. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/algorithms/kkt_solver.py +0 -0
  66. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/algorithms/status.py +0 -0
  67. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/algorithms/wls.py +0 -0
  68. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/bad_data_repass.py +0 -0
  69. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/bounds.py +0 -0
  70. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/constants.py +0 -0
  71. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/contract/_legacy.py +0 -0
  72. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/contract/containers.py +0 -0
  73. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/contract/derived.py +0 -0
  74. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/contract/derived_tables.py +0 -0
  75. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/contract/serialize.py +0 -0
  76. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/losses.py +0 -0
  77. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/measurement_ids.py +0 -0
  78. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/pipeline/__init__.py +0 -0
  79. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/pipeline/context.py +0 -0
  80. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/pipeline/manifest.py +0 -0
  81. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/pipeline/orchestration.py +0 -0
  82. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/pipeline/runner.py +0 -0
  83. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/pipeline/steps.py +0 -0
  84. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/preprocessing/__init__.py +0 -0
  85. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/preprocessing/_scan.py +0 -0
  86. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/preprocessing/ipm_setup.py +0 -0
  87. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/preprocessing/meas_rows.py +0 -0
  88. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/preprocessing/mirror_voltage.py +0 -0
  89. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/preprocessing/node_props.py +0 -0
  90. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/preprocessing/pseudo_measurements.py +0 -0
  91. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/py.typed +0 -0
  92. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/quality_summary.py +0 -0
  93. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/resolve_pass.py +0 -0
  94. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/result.py +0 -0
  95. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/runtime.py +0 -0
  96. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/shunt_sanity.py +0 -0
  97. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/state.py +0 -0
  98. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/__init__.py +0 -0
  99. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/_filters.py +0 -0
  100. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/apply_resolved.py +0 -0
  101. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/generators.py +0 -0
  102. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/kinds.py +0 -0
  103. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/load_only_injection.py +0 -0
  104. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/loss_filter.py +0 -0
  105. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/measurements.py +0 -0
  106. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/noise.py +0 -0
  107. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/on_line.py +0 -0
  108. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/quality.py +0 -0
  109. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/rpn.py +0 -0
  110. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/sigma_floor.py +0 -0
  111. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/units.py +0 -0
  112. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/voltage_filter.py +0 -0
  113. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/telemetry/voltage_nominal.py +0 -0
  114. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/topology.py +0 -0
  115. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/units.py +0 -0
  116. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/utils.py +0 -0
  117. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/v_mirror.py +0 -0
  118. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/v_refine.py +0 -0
  119. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/validation/__init__.py +0 -0
  120. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/validation/_diagnostics.py +0 -0
  121. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/validation/bad_data.py +0 -0
  122. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/validation/chi2_test.py +0 -0
  123. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/validation/observability.py +0 -0
  124. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/ybus.py +0 -0
  125. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate/z_vector.py +0 -0
  126. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate.egg-info/dependency_links.txt +0 -0
  127. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate.egg-info/entry_points.txt +0 -0
  128. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate.egg-info/requires.txt +0 -0
  129. {gridstate-2.2.0 → gridstate-2.4.0}/gridstate.egg-info/top_level.txt +0 -0
  130. {gridstate-2.2.0 → gridstate-2.4.0}/pyproject.toml +0 -0
  131. {gridstate-2.2.0 → gridstate-2.4.0}/setup.cfg +0 -0
  132. {gridstate-2.2.0 → gridstate-2.4.0}/tests/__init__.py +0 -0
  133. {gridstate-2.2.0 → gridstate-2.4.0}/tests/_golden.py +0 -0
  134. {gridstate-2.2.0 → gridstate-2.4.0}/tests/conftest.py +0 -0
  135. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_adapters_lazy_import.py +0 -0
  136. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_aggregate_generators.py +0 -0
  137. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_algebra_base.py +0 -0
  138. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_anti_overshoot.py +0 -0
  139. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_arrays_helpers.py +0 -0
  140. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_bad_data.py +0 -0
  141. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_bad_data_repass.py +0 -0
  142. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_bounds.py +0 -0
  143. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_boxed_balance.py +0 -0
  144. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_chain_mirror.py +0 -0
  145. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_contract.py +0 -0
  146. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_contract_edge_cases.py +0 -0
  147. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_contract_runtime.py +0 -0
  148. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/ieee/case118.npz +0 -0
  149. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/ieee/case118_truth.npz +0 -0
  150. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/ieee/case14.npz +0 -0
  151. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/ieee/case14_truth.npz +0 -0
  152. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/ieee/case30.npz +0 -0
  153. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_data/ieee/case30_truth.npz +0 -0
  154. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_derived_tables.py +0 -0
  155. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_enum_codes.py +0 -0
  156. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_flow_sigma_floor.py +0 -0
  157. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_from_pandapower.py +0 -0
  158. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_generator_shunt_kernels.py +0 -0
  159. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_golden.py +0 -0
  160. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_gram_numba.py +0 -0
  161. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_ieee_cases.py +0 -0
  162. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_ipm.py +0 -0
  163. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_ipm_injection_roles.py +0 -0
  164. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_ipm_integration.py +0 -0
  165. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_jacobian_pattern.py +0 -0
  166. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_kkt_solver.py +0 -0
  167. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_load_only_injection.py +0 -0
  168. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_losses_breakdown.py +0 -0
  169. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_losses_q_convention.py +0 -0
  170. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_losses_trafo_classification.py +0 -0
  171. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_materialize_kernel.py +0 -0
  172. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_measurement_filter_kernels.py +0 -0
  173. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_measurement_ids.py +0 -0
  174. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_mirror_voltage_kernel.py +0 -0
  175. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_near_critical_ranking.py +0 -0
  176. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_neutral_wording.py +0 -0
  177. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_noise_model.py +0 -0
  178. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_normalized_residuals.py +0 -0
  179. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_numpy_only_runtime.py +0 -0
  180. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_observability.py +0 -0
  181. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_on_line_kernel.py +0 -0
  182. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_perf_synthetic_network.py +0 -0
  183. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_pipeline.py +0 -0
  184. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_pipeline_config_validation.py +0 -0
  185. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_pipeline_idempotent.py +0 -0
  186. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_pipeline_invariant_errors.py +0 -0
  187. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_pipeline_step_order.py +0 -0
  188. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_pipeline_warm_start.py +0 -0
  189. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_pipeline_working_layer.py +0 -0
  190. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_post_processing.py +0 -0
  191. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_preparation_steps.py +0 -0
  192. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_prepare_network.py +0 -0
  193. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_pseudo_unknown_load.py +0 -0
  194. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_pseudo_v_unobservable.py +0 -0
  195. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_quality_summary_paths.py +0 -0
  196. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_reconcile_balance.py +0 -0
  197. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_refine_merge.py +0 -0
  198. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_resolve_pass.py +0 -0
  199. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_result_quality_summary.py +0 -0
  200. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_rn_selected_inversion.py +0 -0
  201. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_serialize_npz.py +0 -0
  202. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_shunt_sanity.py +0 -0
  203. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_solve_status.py +0 -0
  204. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_state.py +0 -0
  205. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_state_range.py +0 -0
  206. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_telemetry_apply_kernel.py +0 -0
  207. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_telemetry_bounds.py +0 -0
  208. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_telemetry_physics_kernels.py +0 -0
  209. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_tm_code_quality.py +0 -0
  210. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_topology_kernels.py +0 -0
  211. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_topology_port.py +0 -0
  212. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_transformer_from_shunt.py +0 -0
  213. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_units.py +0 -0
  214. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_units_array_kernels.py +0 -0
  215. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_v_mirror.py +0 -0
  216. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_v_refine.py +0 -0
  217. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_variance_charging.py +0 -0
  218. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_version.py +0 -0
  219. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_voltage_calibration_gen_nodes.py +0 -0
  220. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_voltage_range_filter_fallback.py +0 -0
  221. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_wls.py +0 -0
  222. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_wls_robustness.py +0 -0
  223. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_working_container.py +0 -0
  224. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_ybus.py +0 -0
  225. {gridstate-2.2.0 → gridstate-2.4.0}/tests/test_z_vector.py +0 -0
  226. {gridstate-2.2.0 → gridstate-2.4.0}/tools/gen_ieee_fixtures.py +0 -0
  227. {gridstate-2.2.0 → gridstate-2.4.0}/tools/golden.py +0 -0
@@ -9,6 +9,53 @@ 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.4.0 (2026-10-17)
13
+
14
+ ### Behavior changes
15
+
16
+ - Breaker branches marked by the input format (R = 0, X = 1 Ohm) get
17
+ X = 110·min_x·Vn Ohm with min_x = 4e-6 (≈ 0.044/Vn p.u.) instead of a fixed
18
+ 1e-3 p.u.; `breaker_x_pu` (new) sets a fixed p.u. value instead. Estimates
19
+ move on models carrying the marker (≤ 4.5e-3 p.u. V with `ipm_pd` on the
20
+ test models) (#115)
21
+ - A solved state with V/Vnom below `implausible_v_ratio` (new, default 0.3)
22
+ at any node is marked not converged whatever the solver reports, with a
23
+ WARNING listing the nodes and hints; a non-converged WLS solve logs its
24
+ largest normalized residuals (#114, #115)
25
+
26
+ ### Feat
27
+
28
+ - Piecewise static load characteristics: optional input table
29
+ `load_characteristic_segments` (contract 3.2.0) with per-segment
30
+ coefficients up to V⁴ for P and Q; `apply_load_characteristic` uses it in
31
+ place of the quadratic row; without the table nothing changes (#113, #115)
32
+
33
+ ## v2.3.0 (2026-10-17)
34
+
35
+ ### Behavior changes
36
+
37
+ - `ipm_pd` uses a fixed Huber threshold `c_eff = huber_c` by default
38
+ (`ipm_huber_threshold="fixed"`). The previous per-iteration adaptive raise
39
+ made the estimate depend on the solver path; `"adaptive"` restores it. On
40
+ regional models the quality is unchanged; `ipm_pd` estimates move slightly
41
+ (≤ 6e-5 p.u. V on the test models). `wls` / `ipm` are unaffected (#111)
42
+ - `ipm_pd` selects its step by Armijo backtracking on a merit function
43
+ (barrier objective, augmented Lagrangian of the equality rows, slack
44
+ penalty). It now converges on weakly determined cases (e.g. unmeasured
45
+ units with a wide box under `injection_roles`) that needed the primal
46
+ fallback; `ipm_pd` estimates can move slightly. The pipeline's `ipm_pd`
47
+ iteration limit is 300 (100 under a hard balance); the solve stops after
48
+ `max_search_failures` iterations without merit decrease (#106)
49
+
50
+ ### Feat
51
+
52
+ - Convergence hints: a solve that does not converge, falls back from
53
+ `ipm_pd`, or takes more than `ipm_hint_iterations` iterations with a
54
+ dominant step limiter logs a WARNING naming the limiting nodes / variables
55
+ (with a suggestion), loose unmeasured box variables, the largest normalized
56
+ residuals and part measurements outside their range. `IPMResult` gains
57
+ `step_limiters` / `limiting_rows` (#110)
58
+
12
59
  ## v2.2.0 (2026-10-17)
13
60
 
14
61
  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.4.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.4.0'
22
+ __version_tuple__ = version_tuple = (2, 4, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g06a6dcf24'
24
+ __commit_id__ = commit_id = 'g81767e831'
@@ -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,
@@ -47,6 +48,7 @@ from gridstate.quality_summary import (
47
48
  top_worst_residuals,
48
49
  )
49
50
  from gridstate.result import MeasurementNormalizedResiduals, SEResult, extract_output_tables
51
+ from gridstate.solution_hints import low_voltage_hints, low_voltage_nodes, residual_hints
50
52
  from gridstate.state import (
51
53
  InitialPoint,
52
54
  StateLayout,
@@ -99,6 +101,7 @@ def estimate(
99
101
  quality_summary_rn_refine_below: float = SELINV_REFINE_BELOW,
100
102
  quality_summary_near_critical: float = NEAR_CRITICAL_THRESHOLD,
101
103
  success_policy: SuccessPolicy = "legacy",
104
+ implausible_v_ratio: float = 0.3,
102
105
  **ipm_kwargs: Any,
103
106
  ) -> SEResult:
104
107
  """Run state estimation for a model and telemetry.
@@ -180,6 +183,13 @@ def estimate(
180
183
  :func:`gridstate.algorithms.status.success_from_status`
181
184
  (``kkt`` or ``mu_schedule_done`` only). ``solve_status`` and
182
185
  ``objective_wls`` are reported under both policies.
186
+ implausible_v_ratio: a solved state with ``V/Vnom`` below it at any
187
+ node is not a usable estimate whatever the solver reports (e.g. the
188
+ low-voltage root at a node whose V is barely observable):
189
+ ``success`` becomes ``False``, the message names the nodes and a
190
+ WARNING lists hints (:mod:`gridstate.solution_hints`). ``0``
191
+ disables the check. A WLS solve that does not converge logs its
192
+ largest normalized residuals.
183
193
  **ipm_kwargs: IPM-mode knobs, ignored for WLS. ``trust_region_scope``
184
194
  (``"state"`` default | ``"all"``) selects the coordinates the IPM
185
195
  trust region is measured on (see ``_run_ipm``); everything else is
@@ -309,6 +319,30 @@ def estimate(
309
319
  )
310
320
  if success_policy == "status" and not success:
311
321
  message = f"solve status {solve_status.value}: {message}"
322
+ low_v = low_voltage_nodes(v_pu, implausible_v_ratio)
323
+ if low_v.size:
324
+ ids = network_pu.bus_ids[low_v][:5].tolist()
325
+ more = ", ..." if low_v.size > 5 else ""
326
+ note = (
327
+ f"implausible solution: V/Vnom < {implausible_v_ratio:g} at "
328
+ f"{low_v.size} node(s) {ids}{more}"
329
+ )
330
+ success = False
331
+ message = f"{note}; {message}" if message else note
332
+ hints = low_voltage_hints(
333
+ low_v, v_pu=v_pu, network_pu=network_pu, meas_index=meas_index, z=z
334
+ )
335
+ logger.warning("%s %s:\n%s", algorithm, note, "\n".join(hints))
336
+ elif not success and algorithm == "wls" and z.shape[0]:
337
+ algebra = BaseAlgebra(ybus, yf, yt, meas_index, layout, network_pu)
338
+ hints = residual_hints(
339
+ z - algebra.evaluate_h(v_pu, delta),
340
+ np.sqrt(floored_sigma2(r_matrix.diagonal())),
341
+ meas_index=meas_index,
342
+ network_pu=network_pu,
343
+ )
344
+ if hints:
345
+ logger.warning("wls did not converge (%s):\n%s", message, "\n".join(hints))
312
346
  write_results_to_model(model, v_pu, delta, network_pu, yf=yf, yt=yt, ybus=ybus)
313
347
  # 7. Post-processing of measurements: estimated_si/value/residual.
314
348
  write_measurement_estimates(
@@ -676,6 +710,8 @@ def _run_ipm(
676
710
  measurement_bounds: bool = False,
677
711
  injection_roles: bool = False,
678
712
  role_estimates: dict[int, float] | None = None,
713
+ hint_iterations: int = 100,
714
+ huber_threshold: str = "fixed",
679
715
  **ipm_kwargs: Any,
680
716
  ) -> IPMResult:
681
717
  """IPM mode: extends the state vector with box-vars and solves the primal log-barrier WLS.
@@ -710,7 +746,18 @@ def _run_ipm(
710
746
  variables; ``role_estimates`` (when given) receives the estimate of each
711
747
  part row in MW / MVAr by measurement id, ``nan`` for a part row folded into a
712
748
  net injection.
749
+
750
+ A solve that does not converge, falls back from ``ipm_pd`` or takes more
751
+ than ``hint_iterations`` iterations (``0`` — no iteration trigger) logs a
752
+ WARNING with convergence hints (:mod:`gridstate.ipm_hints`).
753
+
754
+ ``huber_threshold`` (``"ipm_pd"`` only): ``"fixed"`` keeps the Huber
755
+ threshold at ``huber_c``; ``"adaptive"`` raises it to ``k·median|r/σ|``,
756
+ recomputed every iteration, so the estimate follows the solver path. The
757
+ primal IPM keeps its adaptive threshold (recomputed once per outer step).
713
758
  """
759
+ if huber_threshold not in ("fixed", "adaptive"):
760
+ raise ValueError(f"huber_threshold={huber_threshold!r} is not one of fixed/adaptive")
714
761
  if measurement_bounds and solver != "ipm_pd":
715
762
  raise ValueError("measurement_bounds requires algorithm='ipm_pd'")
716
763
  if trust_region_scope not in ("all", "state"):
@@ -780,6 +827,7 @@ def _run_ipm(
780
827
  return algebra.evaluate_jacobian(v, delta)
781
828
 
782
829
  eq_idx = setup.balance_rows if setup.balance_tol is not None else None
830
+ pd_result: IPMResult | None = None
783
831
  if solver == "ipm_pd":
784
832
  mb_rows = mb_lo = mb_hi = None
785
833
  if measurement_bounds and measurements is not None:
@@ -807,20 +855,24 @@ def _run_ipm(
807
855
  eq_tol=setup.balance_tol,
808
856
  mu_init=1.0,
809
857
  step_tol=max(tolerance, 1e-4),
810
- max_iter=max(max_iterations, 100),
858
+ # Weakly determined units need up to ~260 iterations with separate
859
+ # injection parts; a hard balance does not converge in that budget
860
+ # either way, so it keeps the short one before the fallback.
861
+ max_iter=max(max_iterations, 100 if eq_idx is not None else 300),
811
862
  tr_radius=0.5,
812
863
  huber_c=huber_c,
813
864
  huber_mask=huber_mask,
814
865
  huber_w_floor=huber_w_floor,
815
866
  huber_use_mad=huber_use_mad,
816
867
  huber_warmup_iters=2,
817
- huber_adaptive_k=2.0,
868
+ huber_adaptive_k=0.0 if huber_threshold == "fixed" else 2.0,
818
869
  kkt_solver=kkt,
819
870
  )
871
+ pd_result = result
820
872
  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.
873
+ # Safety net: on a weakly determined direction (e.g. an unmeasured
874
+ # unit with a wide box) the primal-dual iteration can still crawl
875
+ # past its limit. Measurement boxes have no primal counterpart.
824
876
  logger.warning(
825
877
  "ipm_pd did not converge (%s), falling back to the primal IPM", result.message
826
878
  )
@@ -863,6 +915,26 @@ def _run_ipm(
863
915
  if solver == "ipm_pd":
864
916
  result.message = f"ipm_pd fallback to ipm ({pd_message}): {result.message}"
865
917
 
918
+ slow: IPMResult | None = None
919
+ if pd_result is not None and pd_result is not result:
920
+ slow, reason = pd_result, "ipm_pd fell back to the primal IPM"
921
+ elif not result.success:
922
+ slow, reason = result, f"not converged ({result.status})"
923
+ elif 0 < hint_iterations < result.iterations_inner_total and has_dominant_limiter(
924
+ result, result.iterations_inner_total
925
+ ):
926
+ slow, reason = result, f"{result.iterations_inner_total} iterations > {hint_iterations}"
927
+ if slow is not None:
928
+ hints = ipm_convergence_hints(
929
+ setup=setup,
930
+ network_pu=network_pu,
931
+ result=slow,
932
+ residual=residual_fn(result.x),
933
+ iterations=slow.iterations_inner_total,
934
+ )
935
+ if hints:
936
+ logger.warning("%s convergence hints (%s):\n%s", solver, reason, "\n".join(hints))
937
+
866
938
  # Extract the box-vars and write them into the node table (MW/MVAr = p.u. * BASE_MVA).
867
939
  _, _, pgen, qgen, pnag, qnag = unpack_full(result.x, layout_ipm)
868
940
 
@@ -26,6 +26,7 @@ from gridstate.contract.tables import (
26
26
  BRANCHES,
27
27
  BRANCHES_OUTPUT,
28
28
  GENERATORS,
29
+ LOAD_CHARACTERISTIC_SEGMENTS,
29
30
  LOAD_CHARACTERISTICS,
30
31
  MEASUREMENTS,
31
32
  MEASUREMENTS_OUTPUT,
@@ -70,6 +71,7 @@ __all__ = [
70
71
  "CONTRACT_VERSION_KEY",
71
72
  "GENERATORS",
72
73
  "LOAD_CHARACTERISTICS",
74
+ "LOAD_CHARACTERISTIC_SEGMENTS",
73
75
  "MEASUREMENTS",
74
76
  "MEASUREMENTS_OUTPUT",
75
77
  "NODES",