gridstate 2.7.0__tar.gz → 2.9.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 (232) hide show
  1. {gridstate-2.7.0 → gridstate-2.9.0}/.gitignore +3 -0
  2. {gridstate-2.7.0 → gridstate-2.9.0}/CHANGELOG.md +56 -0
  3. {gridstate-2.7.0 → gridstate-2.9.0}/PKG-INFO +1 -1
  4. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/_version.py +3 -3
  5. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/algorithms/ipm_pd.py +86 -2
  6. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/api.py +73 -2
  7. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/pipeline/config.py +41 -8
  8. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/pipeline/manifest.py +5 -0
  9. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/pipeline/passes.py +1 -0
  10. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/pipeline/preparation.py +1 -0
  11. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/preprocessing/pseudo_measurements.py +54 -3
  12. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/state.py +52 -1
  13. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate.egg-info/PKG-INFO +1 -1
  14. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate.egg-info/SOURCES.txt +1 -0
  15. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate.egg-info/scm_file_list.json +1 -0
  16. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate.egg-info/scm_version.json +2 -2
  17. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/golden/case118.json +29 -29
  18. gridstate-2.9.0/tests/test_data/golden/case118.npz +0 -0
  19. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/golden/case118_degraded.json +40 -40
  20. gridstate-2.9.0/tests/test_data/golden/case118_degraded.npz +0 -0
  21. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/golden/case14.json +29 -29
  22. gridstate-2.9.0/tests/test_data/golden/case14.npz +0 -0
  23. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/golden/case14_degraded.json +36 -36
  24. gridstate-2.9.0/tests/test_data/golden/case14_degraded.npz +0 -0
  25. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/golden/manifest.json +52 -3
  26. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_ipm_balance_modes.py +21 -0
  27. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_ipm_pd.py +57 -1
  28. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_pipeline.py +24 -0
  29. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_pipeline_warm_start.py +2 -2
  30. gridstate-2.9.0/tests/test_pseudo_v_scope.py +196 -0
  31. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_state.py +54 -0
  32. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_state_range.py +5 -5
  33. gridstate-2.7.0/tests/test_data/golden/case118.npz +0 -0
  34. gridstate-2.7.0/tests/test_data/golden/case118_degraded.npz +0 -0
  35. gridstate-2.7.0/tests/test_data/golden/case14.npz +0 -0
  36. gridstate-2.7.0/tests/test_data/golden/case14_degraded.npz +0 -0
  37. {gridstate-2.7.0 → gridstate-2.9.0}/.github/workflows/ci.yml +0 -0
  38. {gridstate-2.7.0 → gridstate-2.9.0}/.github/workflows/release.yml +0 -0
  39. {gridstate-2.7.0 → gridstate-2.9.0}/.pre-commit-config.yaml +0 -0
  40. {gridstate-2.7.0 → gridstate-2.9.0}/LICENSE +0 -0
  41. {gridstate-2.7.0 → gridstate-2.9.0}/Makefile +0 -0
  42. {gridstate-2.7.0 → gridstate-2.9.0}/README.md +0 -0
  43. {gridstate-2.7.0 → gridstate-2.9.0}/build_cython.py +0 -0
  44. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/__init__.py +0 -0
  45. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/__pyinstaller/__init__.py +0 -0
  46. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/__pyinstaller/hook-gridstate.py +0 -0
  47. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/_arrays.py +0 -0
  48. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/adapters/__init__.py +0 -0
  49. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/adapters/from_pandapower.py +0 -0
  50. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/algebra/__init__.py +0 -0
  51. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/algebra/_selinv_kernels.py +0 -0
  52. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/algebra/base.py +0 -0
  53. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/algebra/estimators.py +0 -0
  54. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/algebra/jacobian_pattern.py +0 -0
  55. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/algebra/normalized_residuals.py +0 -0
  56. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/algorithms/__init__.py +0 -0
  57. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/algorithms/_gram_numba.py +0 -0
  58. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/algorithms/ipm.py +0 -0
  59. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/algorithms/kkt_solver.py +0 -0
  60. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/algorithms/status.py +0 -0
  61. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/algorithms/wls.py +0 -0
  62. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/bad_data_repass.py +0 -0
  63. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/bounds.py +0 -0
  64. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/constants.py +0 -0
  65. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/contract/__init__.py +0 -0
  66. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/contract/_legacy.py +0 -0
  67. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/contract/containers.py +0 -0
  68. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/contract/derived.py +0 -0
  69. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/contract/derived_tables.py +0 -0
  70. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/contract/serialize.py +0 -0
  71. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/contract/tables.py +0 -0
  72. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/contract/validate.py +0 -0
  73. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/contract/version.py +0 -0
  74. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/ipm_hints.py +0 -0
  75. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/load_characteristics.py +0 -0
  76. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/losses.py +0 -0
  77. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/measurement_ids.py +0 -0
  78. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/pipeline/__init__.py +0 -0
  79. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/pipeline/context.py +0 -0
  80. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/pipeline/orchestration.py +0 -0
  81. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/pipeline/runner.py +0 -0
  82. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/pipeline/steps.py +0 -0
  83. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/post_processing.py +0 -0
  84. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/preprocessing/__init__.py +0 -0
  85. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/preprocessing/_scan.py +0 -0
  86. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/preprocessing/ipm_setup.py +0 -0
  87. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/preprocessing/meas_rows.py +0 -0
  88. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/preprocessing/mirror_voltage.py +0 -0
  89. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/preprocessing/node_props.py +0 -0
  90. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/py.typed +0 -0
  91. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/quality_summary.py +0 -0
  92. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/resolve_pass.py +0 -0
  93. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/result.py +0 -0
  94. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/runtime.py +0 -0
  95. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/shunt_sanity.py +0 -0
  96. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/solution_hints.py +0 -0
  97. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/__init__.py +0 -0
  98. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/_filters.py +0 -0
  99. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/apply_resolved.py +0 -0
  100. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/generators.py +0 -0
  101. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/kinds.py +0 -0
  102. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/load_only_injection.py +0 -0
  103. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/loss_filter.py +0 -0
  104. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/measurements.py +0 -0
  105. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/noise.py +0 -0
  106. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/on_line.py +0 -0
  107. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/quality.py +0 -0
  108. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/rpn.py +0 -0
  109. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/shunts.py +0 -0
  110. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/sigma_floor.py +0 -0
  111. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/units.py +0 -0
  112. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/voltage_filter.py +0 -0
  113. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/telemetry/voltage_nominal.py +0 -0
  114. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/topology.py +0 -0
  115. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/units.py +0 -0
  116. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/utils.py +0 -0
  117. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/v_mirror.py +0 -0
  118. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/v_refine.py +0 -0
  119. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/validation/__init__.py +0 -0
  120. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/validation/_diagnostics.py +0 -0
  121. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/validation/bad_data.py +0 -0
  122. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/validation/chi2_test.py +0 -0
  123. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/validation/observability.py +0 -0
  124. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/working.py +0 -0
  125. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/ybus.py +0 -0
  126. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate/z_vector.py +0 -0
  127. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate.egg-info/dependency_links.txt +0 -0
  128. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate.egg-info/entry_points.txt +0 -0
  129. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate.egg-info/requires.txt +0 -0
  130. {gridstate-2.7.0 → gridstate-2.9.0}/gridstate.egg-info/top_level.txt +0 -0
  131. {gridstate-2.7.0 → gridstate-2.9.0}/pyproject.toml +0 -0
  132. {gridstate-2.7.0 → gridstate-2.9.0}/setup.cfg +0 -0
  133. {gridstate-2.7.0 → gridstate-2.9.0}/tests/__init__.py +0 -0
  134. {gridstate-2.7.0 → gridstate-2.9.0}/tests/_golden.py +0 -0
  135. {gridstate-2.7.0 → gridstate-2.9.0}/tests/conftest.py +0 -0
  136. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_adapters_lazy_import.py +0 -0
  137. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_aggregate_generators.py +0 -0
  138. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_algebra_base.py +0 -0
  139. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_anti_overshoot.py +0 -0
  140. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_arrays_helpers.py +0 -0
  141. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_bad_data.py +0 -0
  142. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_bad_data_repass.py +0 -0
  143. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_bounds.py +0 -0
  144. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_boxed_balance.py +0 -0
  145. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_breaker_reactance.py +0 -0
  146. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_chain_mirror.py +0 -0
  147. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_contract.py +0 -0
  148. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_contract_edge_cases.py +0 -0
  149. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_contract_runtime.py +0 -0
  150. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/golden/case30.json +0 -0
  151. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/golden/case30.npz +0 -0
  152. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/golden/case30_degraded.json +0 -0
  153. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/golden/case30_degraded.npz +0 -0
  154. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/ieee/case118.npz +0 -0
  155. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/ieee/case118_truth.npz +0 -0
  156. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/ieee/case14.npz +0 -0
  157. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/ieee/case14_truth.npz +0 -0
  158. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/ieee/case30.npz +0 -0
  159. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_data/ieee/case30_truth.npz +0 -0
  160. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_derived_tables.py +0 -0
  161. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_enum_codes.py +0 -0
  162. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_flow_sigma_floor.py +0 -0
  163. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_from_pandapower.py +0 -0
  164. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_generator_shunt_kernels.py +0 -0
  165. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_golden.py +0 -0
  166. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_gram_numba.py +0 -0
  167. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_ieee_cases.py +0 -0
  168. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_ipm.py +0 -0
  169. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_ipm_hints.py +0 -0
  170. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_ipm_injection_roles.py +0 -0
  171. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_ipm_integration.py +0 -0
  172. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_jacobian_pattern.py +0 -0
  173. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_kkt_solver.py +0 -0
  174. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_load_characteristic_segments.py +0 -0
  175. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_load_only_injection.py +0 -0
  176. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_losses_breakdown.py +0 -0
  177. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_losses_q_convention.py +0 -0
  178. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_losses_trafo_classification.py +0 -0
  179. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_materialize_kernel.py +0 -0
  180. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_measurement_filter_kernels.py +0 -0
  181. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_measurement_ids.py +0 -0
  182. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_mirror_voltage_kernel.py +0 -0
  183. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_near_critical_ranking.py +0 -0
  184. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_neutral_wording.py +0 -0
  185. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_noise_model.py +0 -0
  186. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_normalized_residuals.py +0 -0
  187. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_numpy_only_runtime.py +0 -0
  188. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_observability.py +0 -0
  189. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_on_line_kernel.py +0 -0
  190. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_perf_synthetic_network.py +0 -0
  191. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_pipeline_config_validation.py +0 -0
  192. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_pipeline_idempotent.py +0 -0
  193. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_pipeline_invariant_errors.py +0 -0
  194. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_pipeline_step_order.py +0 -0
  195. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_pipeline_working_layer.py +0 -0
  196. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_post_processing.py +0 -0
  197. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_preparation_steps.py +0 -0
  198. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_prepare_network.py +0 -0
  199. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_pseudo_unknown_load.py +0 -0
  200. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_pseudo_v_unobservable.py +0 -0
  201. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_quality_summary_paths.py +0 -0
  202. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_reconcile_balance.py +0 -0
  203. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_refine_merge.py +0 -0
  204. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_resolve_pass.py +0 -0
  205. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_result_quality_summary.py +0 -0
  206. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_rn_selected_inversion.py +0 -0
  207. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_serialize_npz.py +0 -0
  208. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_shunt_sanity.py +0 -0
  209. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_solution_hints.py +0 -0
  210. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_solve_status.py +0 -0
  211. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_telemetry_apply_kernel.py +0 -0
  212. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_telemetry_bounds.py +0 -0
  213. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_telemetry_physics_kernels.py +0 -0
  214. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_tm_code_quality.py +0 -0
  215. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_topology_kernels.py +0 -0
  216. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_topology_port.py +0 -0
  217. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_transformer_from_shunt.py +0 -0
  218. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_units.py +0 -0
  219. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_units_array_kernels.py +0 -0
  220. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_v_mirror.py +0 -0
  221. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_v_refine.py +0 -0
  222. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_variance_charging.py +0 -0
  223. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_version.py +0 -0
  224. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_voltage_calibration_gen_nodes.py +0 -0
  225. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_voltage_range_filter_fallback.py +0 -0
  226. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_wls.py +0 -0
  227. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_wls_robustness.py +0 -0
  228. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_working_container.py +0 -0
  229. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_ybus.py +0 -0
  230. {gridstate-2.7.0 → gridstate-2.9.0}/tests/test_z_vector.py +0 -0
  231. {gridstate-2.7.0 → gridstate-2.9.0}/tools/gen_ieee_fixtures.py +0 -0
  232. {gridstate-2.7.0 → gridstate-2.9.0}/tools/golden.py +0 -0
@@ -100,3 +100,6 @@ tests/test_data/**/*.log
100
100
 
101
101
  # Local-only specs / extracted material
102
102
  .specs/
103
+
104
+ # Library: no lock file in the repository
105
+ uv.lock
@@ -9,6 +9,62 @@ 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.9.0 (2026-10-18)
13
+
14
+ ### Feat
15
+
16
+ - `pseudo_voltage_scope` (`add_pseudo_measurements(voltage_scope=...)`): the
17
+ nodes whose pseudo-V prior keeps its σ. `"all"` (default) keeps the current
18
+ behavior. `"generation"` keeps it only at the slack and the nodes with
19
+ generation (`exist_gen` or an active generator); elsewhere the prior variance
20
+ is multiplied by `v_out_of_scope_loose_factor` (default 1e4, σ × 100) and the
21
+ prior stays for observability only. A prior at the nominal V on a load node
22
+ without a V measurement pulls its estimate down, and the solver makes up the
23
+ difference with the load Q. `"none"` loosens every pseudo-V prior, the slack
24
+ and the generation nodes included: V is then held by the network, the
25
+ measurements and the voltage limits (meant for `ipm` / `ipm_pd`). The step
26
+ reports `v_priors_loose` in both modes.
27
+
28
+ ### Fix
29
+
30
+ - The UI manifest showed the IPM settings (balance modes, balance weights and
31
+ the BUS-equivalent prior) only for `algorithm="ipm"`, although `ipm_pd` uses
32
+ them too. Their `depends` is now `{"algorithm": ["ipm", "ipm_pd"]}`: a list
33
+ value in `depends` means "one of". `ipm_trust_region_scope` acts in the
34
+ primal solver only and keeps `{"algorithm": "ipm"}`.
35
+
36
+ ## v2.8.0 (2026-10-18)
37
+
38
+ ### Behavior changes
39
+
40
+ - `init="flat"` starts V consistent with the transformer ratios instead of
41
+ 1 p.u. everywhere: `log V` from a least squares over the branches
42
+ (`log V_to − log V_from = −log k`, weight `1/|z|`, `log V_slack = 0`). A node
43
+ whose nominal voltage does not match its transformer ratio (or an off-nominal
44
+ tap on a low-impedance branch) no longer starts with a mismatch of hundreds
45
+ of p.u. Applies to every algorithm; without off-nominal ratios the start is
46
+ unchanged. WLS on an ill-conditioned model may converge to a neighbouring
47
+ solution (on the test models p95 |ΔV| against the reference within 0.05 kV).
48
+ - `ipm_pd` voltage guard on by default (`ipm_voltage_guard` /
49
+ `voltage_guard`, ignored by other algorithms): every node V is kept within a
50
+ box from its `voltage_min` / `voltage_max` (unset or implausible limits fall
51
+ back to 0.55 / 1.8 p.u., 0.7–1.5 p.u. with neither set) and one step changes
52
+ V by at most 20 %, an angle by at most 0.6 rad and a branch angle difference
53
+ by at most 0.35 rad. Where a node limit is active the estimate sits on it.
54
+
55
+ ### Fix
56
+
57
+ - `ipm_pd` with the global hard balance no longer collapses a generator node
58
+ into the low-voltage power-flow branch from a flat start (V → 0, the node
59
+ angle undetermined, a formally converged V ≈ 0.2 p.u.).
60
+
61
+ ### Feat
62
+
63
+ - `solve_ipm_pd`: `state_box_*` (barrier boxes on state coordinates that stay
64
+ under the trust region and the stopping test, without box damping) and the
65
+ `step_rel_*` / `step_abs_*` / `step_diff_*` step limits.
66
+ - `gridstate.state.tap_consistent_voltages`.
67
+
12
68
  ## v2.7.0 (2026-10-18)
13
69
 
14
70
  ### Behavior changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridstate
3
- Version: 2.7.0
3
+ Version: 2.9.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.7.0'
22
- __version_tuple__ = version_tuple = (2, 7, 0)
21
+ __version__ = version = '2.9.0'
22
+ __version_tuple__ = version_tuple = (2, 9, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g9810ca9e9'
24
+ __commit_id__ = commit_id = 'gaa9a1ec6e'
@@ -172,6 +172,37 @@ class _Linearization:
172
172
  return dx, ds_l, ds_u, dy_l, dy_u, dlam
173
173
 
174
174
 
175
+ def _step_cap(
176
+ dx: np.ndarray,
177
+ x: np.ndarray,
178
+ rel_idx: np.ndarray,
179
+ rel_max: float,
180
+ abs_idx: np.ndarray,
181
+ abs_max: float,
182
+ pair_i: np.ndarray,
183
+ pair_j: np.ndarray,
184
+ diff_max: float,
185
+ ) -> float:
186
+ """Largest primal step length allowed by the per-coordinate step limits."""
187
+ cap = math.inf
188
+ if rel_max > 0.0 and rel_idx.size:
189
+ d = np.abs(dx[rel_idx])
190
+ lim = rel_max * np.abs(x[rel_idx])
191
+ k = d > 0.0
192
+ if k.any():
193
+ cap = min(cap, float(np.min(lim[k] / d[k])))
194
+ if abs_max > 0.0 and abs_idx.size:
195
+ d_max = float(np.max(np.abs(dx[abs_idx])))
196
+ if d_max > 0.0:
197
+ cap = min(cap, abs_max / d_max)
198
+ if diff_max > 0.0 and pair_i.size:
199
+ dz = np.append(dx, 0.0)
200
+ d_max = float(np.max(np.abs(dz[pair_i] - dz[pair_j])))
201
+ if d_max > 0.0:
202
+ cap = min(cap, diff_max / d_max)
203
+ return cap
204
+
205
+
175
206
  @dataclass
176
207
  class _Merit:
177
208
  """The merit ``φ`` of one iteration (see the module docstring); ``mu`` is the target ``σμ``."""
@@ -249,6 +280,15 @@ def solve_ipm_pd(
249
280
  eq_prox: float = 1e-9,
250
281
  box_prox: float = 1e-6,
251
282
  box_prox_max: float = 1e-2,
283
+ state_box_idx: np.ndarray | None = None,
284
+ state_box_lo: np.ndarray | None = None,
285
+ state_box_hi: np.ndarray | None = None,
286
+ step_rel_idx: np.ndarray | None = None,
287
+ step_rel_max: float = 0.0,
288
+ step_abs_idx: np.ndarray | None = None,
289
+ step_abs_max: float = 0.0,
290
+ step_diff_pairs: tuple[np.ndarray, np.ndarray] | None = None,
291
+ step_diff_max: float = 0.0,
252
292
  ) -> IPMResult:
253
293
  """Mehrotra predictor-corrector IPM (see the module docstring).
254
294
 
@@ -297,6 +337,17 @@ def solve_ipm_pd(
297
337
  conflicts into the residuals and make this markedly worse. ``0``
298
338
  reweights to the end.
299
339
  kkt_solver: Newton-system solver (``None`` — scipy).
340
+ state_box_idx, state_box_lo, state_box_hi: barrier boxes on state
341
+ coordinates (e.g. V). Unlike ``box_idx`` they stay under the trust
342
+ region and the stopping test and get no box damping: the box only
343
+ keeps the iteration away from an inadmissible region (V → 0, where
344
+ the node angle is undetermined).
345
+ step_rel_idx, step_rel_max: ``|αp·Δx_i| ≤ step_rel_max·|x_i|`` on these
346
+ coordinates (``0`` — off).
347
+ step_abs_idx, step_abs_max: ``|αp·Δx_i| ≤ step_abs_max`` (``0`` — off).
348
+ step_diff_pairs, step_diff_max: ``|αp·(Δx_i − Δx_j)| ≤ step_diff_max`` for
349
+ each pair ``(i, j)``; an index ``-1`` stands for a fixed zero (the
350
+ slack angle). ``0`` — off.
300
351
  eq_prox: with equality rows, a proximal term ``eq_prox·max(ρ)`` on the
301
352
  diagonal of the box coordinates. A hard balance fixes only the net
302
353
  injection of a node, so the common level of its generation and load
@@ -335,6 +386,28 @@ def solve_ipm_pd(
335
386
  raise ValueError("box_lo/box_hi contain pairs with lo >= hi")
336
387
  else:
337
388
  b_lo = b_hi = np.zeros(0)
389
+ n_var_box = box_idx.size
390
+ if state_box_idx is not None and np.size(state_box_idx):
391
+ if state_box_lo is None or state_box_hi is None:
392
+ raise ValueError("state_box_lo and state_box_hi are required with state_box_idx")
393
+ sb_lo = np.asarray(state_box_lo, dtype=np.float64)
394
+ sb_hi = np.asarray(state_box_hi, dtype=np.float64)
395
+ if np.any(sb_lo >= sb_hi):
396
+ raise ValueError("state_box_lo/state_box_hi contain pairs with lo >= hi")
397
+ box_idx = np.concatenate([box_idx, np.asarray(state_box_idx, dtype=np.int64)])
398
+ b_lo = np.concatenate([b_lo, sb_lo])
399
+ b_hi = np.concatenate([b_hi, sb_hi])
400
+ var_box_idx = box_idx[:n_var_box]
401
+ rel_idx = np.asarray(step_rel_idx if step_rel_idx is not None else [], dtype=np.int64)
402
+ abs_idx = np.asarray(step_abs_idx if step_abs_idx is not None else [], dtype=np.int64)
403
+ if step_diff_pairs is not None:
404
+ # -1 -> the appended zero coordinate.
405
+ pair_i = np.asarray(step_diff_pairs[0], dtype=np.int64)
406
+ pair_j = np.asarray(step_diff_pairs[1], dtype=np.int64)
407
+ pair_i = np.where(pair_i < 0, n, pair_i)
408
+ pair_j = np.where(pair_j < 0, n, pair_j)
409
+ else:
410
+ pair_i = pair_j = np.zeros(0, dtype=np.int64)
338
411
  mb_rows = np.asarray(meas_box_rows if meas_box_rows is not None else [], dtype=np.int64)
339
412
  if mb_rows.size:
340
413
  if meas_box_lo is None or meas_box_hi is None:
@@ -386,7 +459,8 @@ def solve_ipm_pd(
386
459
  )
387
460
 
388
461
  tr_mask = np.ones(n, dtype=bool)
389
- tr_mask[box_idx] = False
462
+ tr_mask[var_box_idx] = False
463
+ prox_on = np.arange(nb) < n_var_box
390
464
  tr_pos = np.flatnonzero(tr_mask)
391
465
  limiters: dict[int, int] = {}
392
466
  limiting_rows: dict[int, int] = {}
@@ -456,7 +530,9 @@ def solve_ipm_pd(
456
530
  w_mat[eq_rows] = rho
457
531
  w_mat[mb_rows] += d_k[nb:]
458
532
  diag = np.zeros(n)
459
- diag[box_idx] = d_k[:nb] + max(eq_prox_abs, box_scale * w_median)
533
+ diag[box_idx] = d_k[:nb] + np.where(
534
+ prox_on, max(eq_prox_abs, box_scale * w_median), eq_prox_abs
535
+ )
460
536
  # Stationarity residual of the current point (for reporting).
461
537
  y_dual = np.zeros(nb + mb_rows.size)
462
538
  y_dual[il] += y_l
@@ -568,6 +644,14 @@ def solve_ipm_pd(
568
644
  # The stopping test measures the Newton step, not how far the search cut it.
569
645
  step_inf = a_p * free_inf
570
646
  fit_inf = a_p * float(np.max(np.sqrt(w_data) * np.abs(hdx))) if m else 0.0
647
+ # The step limits shorten the step like the search below, after
648
+ # step_inf has measured the Newton step.
649
+ a_p = min(
650
+ a_p,
651
+ _step_cap(
652
+ dx, x, rel_idx, step_rel_max, abs_idx, step_abs_max, pair_i, pair_j, step_diff_max
653
+ ),
654
+ )
571
655
  a_full = a_p
572
656
  r_new = None
573
657
  # A direction that does not descend (inconsistent slacks of a nonlinear
@@ -55,6 +55,7 @@ from gridstate.state import (
55
55
  flat_start,
56
56
  flat_start_with_box,
57
57
  pack,
58
+ tap_consistent_voltages,
58
59
  unpack,
59
60
  unpack_full,
60
61
  )
@@ -120,7 +121,9 @@ def estimate(
120
121
  ``"ipm_pd"`` (the same problem by the primal-dual Mehrotra
121
122
  predictor-corrector, :mod:`gridstate.algorithms.ipm_pd`).
122
123
  init: initial guess strategy:
123
- - ``"flat"`` — V=1 p.u., δ=0 for all nodes;
124
+ - ``"flat"`` — δ=0 for all nodes, V consistent with the
125
+ transformer ratios (:func:`gridstate.state.tap_consistent_voltages`;
126
+ 1 p.u. without off-nominal ratios);
124
127
  - ``"results"`` — take the current ``voltage_magnitude``/``voltage_angle``
125
128
  from ``model``;
126
129
  - ``"slack"`` — V=V_slack for all nodes, δ=0.
@@ -651,7 +654,9 @@ def _initial_state_from_mode(
651
654
  ) -> np.ndarray:
652
655
  """``e_init`` of the ``init`` strategy alone."""
653
656
  if init == "flat":
654
- return flat_start(layout)
657
+ e = flat_start(layout)
658
+ e[layout.offset_v : layout.offset_v + layout.n_bus] = tap_consistent_voltages(network_pu)
659
+ return e
655
660
 
656
661
  if init == "results":
657
662
  nodes_arr = model.nodes.to_numpy()
@@ -684,6 +689,66 @@ def _initial_state_from_mode(
684
689
  raise ValueError(f"Unknown init mode init={init!r}")
685
690
 
686
691
 
692
+ # Voltage guard of ipm_pd (``voltage_guard=True``): node V box in p.u. of the
693
+ # nominal voltage and the per-iteration step limits.
694
+ _VG_LO_MIN = 0.5 # voltage_min below this share of nominal is ignored ...
695
+ _VG_LO_FALLBACK = 0.55 # ... and replaced by this
696
+ _VG_HI_MAX = 2.0 # voltage_max above this share of nominal is ignored ...
697
+ _VG_HI_FALLBACK = 1.8 # ... and replaced by this
698
+ _VG_BOTH_UNSET = (0.7, 1.5) # neither limit set
699
+ _VG_V_STEP_REL = 0.2 # |αp·ΔV| ≤ 0.2·V
700
+ _VG_DELTA_STEP = 0.6 # |αp·Δδ| ≤ 0.6 rad
701
+ _VG_BRANCH_DELTA_STEP = 0.35 # |αp·(Δδ_i − Δδ_j)| ≤ 0.35 rad over a branch
702
+
703
+
704
+ def _voltage_guard_kwargs(
705
+ model: Working, network_pu: NetworkPU, layout: StateLayout
706
+ ) -> dict[str, Any]:
707
+ """``solve_ipm_pd`` kwargs of the voltage guard: V boxes and step limits.
708
+
709
+ The box of a node comes from its ``voltage_min`` / ``voltage_max`` (kV): a
710
+ limit that is unset (0) or implausible (below half / above twice the
711
+ nominal) falls back to 0.55 / 1.8 p.u.; with neither limit set the box is
712
+ 0.7…1.5 p.u. It keeps the iteration out of the collapsed region (V → 0,
713
+ where the node angle is undetermined); the step limits keep one Newton
714
+ step from crossing into it.
715
+ """
716
+ nodes = model.nodes.to_numpy()
717
+ id_to_pos = id_to_pos_map(network_pu.bus_ids)
718
+ n = network_pu.n_bus
719
+ umin = np.zeros(n)
720
+ umax = np.zeros(n)
721
+ for row in nodes[nodes["status"]]:
722
+ pos = id_to_pos.get(int(row["id"]))
723
+ if pos is not None:
724
+ umin[pos] = float(row["voltage_min"])
725
+ umax[pos] = float(row["voltage_max"])
726
+ vn = np.asarray(network_pu.bus_vn_kv, dtype=np.float64)
727
+ with np.errstate(divide="ignore", invalid="ignore"):
728
+ lo_pu = np.where(vn > 0, umin / vn, 0.0)
729
+ hi_pu = np.where(vn > 0, umax / vn, 0.0)
730
+ lo = np.where((umin > 0) & (lo_pu >= _VG_LO_MIN), lo_pu, _VG_LO_FALLBACK)
731
+ hi = np.where((umax > 0) & (hi_pu <= _VG_HI_MAX), hi_pu, _VG_HI_FALLBACK)
732
+ unset = (umin <= 0) & (umax <= 0)
733
+ lo[unset], hi[unset] = _VG_BOTH_UNSET
734
+ bad = lo >= hi
735
+ lo[bad], hi[bad] = _VG_LO_FALLBACK, _VG_HI_FALLBACK
736
+ v_idx = np.arange(layout.offset_v, layout.offset_v + n, dtype=np.int64)
737
+ delta_of = np.full(n, -1, dtype=np.int64)
738
+ delta_of[layout.non_slack_idx] = layout.offset_delta + np.arange(n - 1)
739
+ return {
740
+ "state_box_idx": v_idx,
741
+ "state_box_lo": lo,
742
+ "state_box_hi": hi,
743
+ "step_rel_idx": v_idx,
744
+ "step_rel_max": _VG_V_STEP_REL,
745
+ "step_abs_idx": delta_of[layout.non_slack_idx],
746
+ "step_abs_max": _VG_DELTA_STEP,
747
+ "step_diff_pairs": (delta_of[network_pu.from_idx], delta_of[network_pu.to_idx]),
748
+ "step_diff_max": _VG_BRANCH_DELTA_STEP,
749
+ }
750
+
751
+
687
752
  def _run_ipm(
688
753
  model: Working,
689
754
  network_pu: NetworkPU,
@@ -712,6 +777,7 @@ def _run_ipm(
712
777
  role_estimates: dict[int, float] | None = None,
713
778
  hint_iterations: int = 100,
714
779
  huber_threshold: str = "fixed",
780
+ voltage_guard: bool = True,
715
781
  **ipm_kwargs: Any,
716
782
  ) -> IPMResult:
717
783
  """IPM mode: extends the state vector with box-vars and solves the primal log-barrier WLS.
@@ -751,6 +817,10 @@ def _run_ipm(
751
817
  than ``hint_iterations`` iterations (``0`` — no iteration trigger) logs a
752
818
  WARNING with convergence hints (:mod:`gridstate.ipm_hints`).
753
819
 
820
+ ``voltage_guard`` (``"ipm_pd"`` only, ignored by ``"ipm"``): node V boxes
821
+ from ``voltage_min`` / ``voltage_max`` and per-step limits on V/δ (see
822
+ :func:`_voltage_guard_kwargs`).
823
+
754
824
  ``huber_threshold`` (``"ipm_pd"`` only): ``"fixed"`` keeps the Huber
755
825
  threshold at ``huber_c``; ``"adaptive"`` raises it to ``k·median|r/σ|``,
756
826
  recomputed every iteration, so the estimate follows the solver path. The
@@ -866,6 +936,7 @@ def _run_ipm(
866
936
  huber_warmup_iters=2,
867
937
  huber_adaptive_k=0.0 if huber_threshold == "fixed" else 2.0,
868
938
  kkt_solver=kkt,
939
+ **(_voltage_guard_kwargs(model, network_pu, layout_ipm) if voltage_guard else {}),
869
940
  )
870
941
  pd_result = result
871
942
  if result.solve_status is not SolveStatus.KKT:
@@ -10,7 +10,9 @@ the UI manifest (:func:`gridstate.pipeline.manifest`):
10
10
  * ``help``: hint text;
11
11
  * ``control``: UI control hint (``"switch"`` / ``"number"`` / ``"select"``);
12
12
  * ``min`` / ``max``: bounds (numbers); ``choices``: allowed values (select);
13
- * ``depends``: show only when another field equals a value (e.g. ``algorithm=ipm``).
13
+ * ``depends``: show only when every named field matches: a scalar value means
14
+ equality, a list means one of its values (e.g. ``algorithm`` in
15
+ ``["ipm", "ipm_pd"]``).
14
16
 
15
17
  ``choices`` / ``min`` / ``max`` are enforced: :meth:`PipelineConfig.validate`
16
18
  runs on construction and at the start of a run.
@@ -79,6 +81,8 @@ _G_MODE = "Режим (нагрузка/генерация)"
79
81
  _G_CASCADE = "Каскад и статусы"
80
82
  _G_PSEUDO = "Псевдо-измерения"
81
83
  _G_EST = "Оценка состояния"
84
+ # Algorithms that read the IPM settings (``depends`` of the IPM group).
85
+ _IPM_SOLVERS = ["ipm", "ipm_pd"]
82
86
  _G_IPM = "IPM"
83
87
  _G_POST = "Пост-обработка"
84
88
 
@@ -313,6 +317,24 @@ class PipelineConfig:
313
317
  "measurements."
314
318
  ),
315
319
  )
320
+ pseudo_voltage_scope: Literal["all", "generation", "none"] = _param(
321
+ "all",
322
+ group=_G_PSEUDO,
323
+ label="Узлы с псевдо-U",
324
+ control="select",
325
+ choices=["all", "generation", "none"],
326
+ depends={"add_pseudo": True},
327
+ help="Nodes without a V measurement whose pseudo-V prior keeps its σ. "
328
+ "all — every node (default); generation — only the slack and the nodes "
329
+ "with generation (exist_gen or an active generator), which can hold their "
330
+ "voltage. Elsewhere the prior is loosened (σ × 100) and stays for "
331
+ "observability only: a prior at the nominal V on a load node pulls its "
332
+ "estimate down and the solver makes up the difference with the load Q. "
333
+ "none — every pseudo-V prior is loosened, the slack and the generation "
334
+ "nodes included: V is held by the network, the measurements and the "
335
+ "voltage limits. Meant for ipm/ipm_pd (their barrier keeps V within the "
336
+ "limits); with WLS V of an unmeasured area rests on the loosened prior.",
337
+ )
316
338
  unobservable_v_sigma_frac: float = _param(
317
339
  0.02,
318
340
  group=_G_PSEUDO,
@@ -502,7 +524,7 @@ class PipelineConfig:
502
524
  control="number",
503
525
  min=1e-4,
504
526
  max=1e6,
505
- depends={"algorithm": "ipm"},
527
+ depends={"algorithm": _IPM_SOLVERS},
506
528
  help="Меньше → balance мягче. default 0.1. Soft balance only (ipm_balance_mode=soft).",
507
529
  )
508
530
  ipm_balance_mode: str = _param(
@@ -511,7 +533,7 @@ class PipelineConfig:
511
533
  label="Balance mode",
512
534
  control="select",
513
535
  choices=["soft", "hard"],
514
- depends={"algorithm": "ipm"},
536
+ depends={"algorithm": _IPM_SOLVERS},
515
537
  help="soft — the nodal P/Q balance is a weighted pseudo-measurement "
516
538
  "(ipm_balance_weight_factor); hard — an equality constraint enforced with "
517
539
  "Lagrange multipliers (ipm: method of multipliers; ipm_pd: regularised KKT) "
@@ -552,6 +574,16 @@ class PipelineConfig:
552
574
  "min_value/max_value set is kept within [min_value, max_value] as an "
553
575
  "inequality of the interior-point problem. Default OFF.",
554
576
  )
577
+ ipm_voltage_guard: bool = _toggle(
578
+ True,
579
+ group=_G_IPM,
580
+ label="Voltage guard (ipm_pd)",
581
+ help="ipm_pd only (ignored otherwise): every node V is kept within a box "
582
+ "from its voltage_min/voltage_max (unset or implausible limits fall back "
583
+ "to 0.55/1.8 p.u. of nominal, 0.7..1.5 with neither set), and one step "
584
+ "changes V by at most 20 %, an angle by at most 0.6 rad and a branch "
585
+ "angle difference by at most 0.35 rad. Default ON.",
586
+ )
555
587
  ipm_injection_roles: bool = _toggle(
556
588
  False,
557
589
  group=_G_IPM,
@@ -593,7 +625,7 @@ class PipelineConfig:
593
625
  control="number",
594
626
  min=0.0,
595
627
  max=1e6,
596
- depends={"algorithm": "ipm"},
628
+ depends={"algorithm": _IPM_SOLVERS},
597
629
  help="Nodes whose injection range is fully set by their generation/load boxes get a "
598
630
  "balance row with variance median(σ²_data) / factor. The box carries the node's "
599
631
  "freedom; a loose balance only hides data conflicts as an imbalance. 0 — off.",
@@ -605,7 +637,7 @@ class PipelineConfig:
605
637
  control="number",
606
638
  min=0.0,
607
639
  max=1e6,
608
- depends={"algorithm": "ipm"},
640
+ depends={"algorithm": _IPM_SOLVERS},
609
641
  help="Transit nodes (no exist_gen/exist_load) get a balance row with variance "
610
642
  "median(σ²_data) / factor. Their injection is known to be zero; a loose balance "
611
643
  "turns data conflicts into a fictitious injection. 0 — off.",
@@ -616,7 +648,7 @@ class PipelineConfig:
616
648
  label="Balance mode of transit nodes",
617
649
  control="select",
618
650
  choices=["soft", "hard"],
619
- depends={"algorithm": "ipm"},
651
+ depends={"algorithm": _IPM_SOLVERS},
620
652
  help="soft — the balance of a transit node (no exist_gen/exist_load) is a "
621
653
  "weighted row like the others (ipm_transit_balance_weight_factor); hard — an "
622
654
  "equality constraint to ipm_transit_balance_tol_p_mw / _q_mvar, the other "
@@ -649,7 +681,7 @@ class PipelineConfig:
649
681
  control="number",
650
682
  min=0.0,
651
683
  max=10.0,
652
- depends={"algorithm": "ipm"},
684
+ depends={"algorithm": _IPM_SOLVERS},
653
685
  help="default 0.01 p.u.².",
654
686
  )
655
687
  ipm_trust_region_scope: str = _param(
@@ -659,7 +691,8 @@ class PipelineConfig:
659
691
  control="select",
660
692
  choices=["all", "state"],
661
693
  depends={"algorithm": "ipm"},
662
- help="Coordinates whose Newton step is bounded by the trust region (0.5 p.u.). "
694
+ help="Primal ipm only (with ipm_pd: its fallback solve). "
695
+ "Coordinates whose Newton step is bounded by the trust region (0.5 p.u.). "
663
696
  "all — the whole state vector, generation/load box variables included "
664
697
  "(the previous default); state (default) — V and δ only: a box variable "
665
698
  "that has to move far no longer shrinks the V/δ step (boxes stay protected "
@@ -26,6 +26,11 @@ def manifest(config: PipelineConfig | type[PipelineConfig] | None = None) -> dic
26
26
  toggle list), grouped by ``group``. It takes the defaults from here instead
27
27
  of hard-coding them.
28
28
 
29
+ ``depends`` maps field names to the values under which the control is shown;
30
+ every entry must match. A scalar value matches by equality, a list matches
31
+ any of its values: ``{"algorithm": ["ipm", "ipm_pd"]}`` shows the control
32
+ for both IPM solvers.
33
+
29
34
  ``config`` selects the source of defaults and of the iterated fields:
30
35
 
31
36
  * ``None`` (default) -- ``PipelineConfig()``, the library defaults;
@@ -61,6 +61,7 @@ def _ipm_kwargs(cfg: PipelineConfig) -> dict:
61
61
  "balance_tol_q_mvar": cfg.ipm_balance_tol_q_mvar,
62
62
  "fixed_injection_from_input": cfg.ipm_fixed_injection_from_input,
63
63
  "measurement_bounds": cfg.ipm_measurement_bounds,
64
+ "voltage_guard": cfg.ipm_voltage_guard,
64
65
  "injection_roles": cfg.ipm_injection_roles,
65
66
  "hint_iterations": cfg.ipm_hint_iterations,
66
67
  "huber_threshold": cfg.ipm_huber_threshold,
@@ -236,6 +236,7 @@ def _s_add_pseudo(ctx: _Ctx) -> dict:
236
236
  unmeasured_gen_p_nodes=ctx.released_injections.get("P", frozenset()),
237
237
  unmeasured_gen_q_nodes=ctx.released_injections.get("Q", frozenset()),
238
238
  loosen_unknown_load=ctx.cfg.loosen_unknown_load_pseudo,
239
+ voltage_scope=ctx.cfg.pseudo_voltage_scope,
239
240
  )
240
241
  or {}
241
242
  )
@@ -5,7 +5,10 @@ unobservable, so the gain matrix becomes singular. To avoid this, for every
5
5
  active node without a `V/P_inj/Q_inj` measurement we add a weak prior:
6
6
 
7
7
  * `V ≈ V_nominal` (either `V_magnitude` from the loaded model, or `vzd`
8
- for a PV node) with σ=10% of Vnom (slack 0.1%, PV 5%).
8
+ for a PV node) with σ=10% of Vnom (slack 0.1%, PV 5%). With
9
+ ``voltage_scope="generation"`` the V prior of a node that cannot hold its
10
+ voltage (no generation, not the slack) is loosened to an observability prior;
11
+ with ``voltage_scope="none"`` every V prior is.
9
12
  * `P_inj = pg - pn`, `Q_inj = qg - qn` from the node table (for transit
10
13
  nodes this gives 0). σ depends on the node type and empty-model detection.
11
14
  """
@@ -13,7 +16,7 @@ active node without a `V/P_inj/Q_inj` measurement we add a weak prior:
13
16
  from __future__ import annotations
14
17
 
15
18
  from dataclasses import dataclass
16
- from typing import TYPE_CHECKING
19
+ from typing import TYPE_CHECKING, Literal
17
20
 
18
21
  import numpy as np
19
22
 
@@ -70,6 +73,11 @@ class PseudoMeasConfig:
70
73
  # Loosen the P/Q injection prior of a node with load but no load data (no
71
74
  # box, nothing materialized) when the injection is fixed by measurements.
72
75
  loosen_unknown_load: bool = False
76
+ # Nodes whose V prior keeps its σ (generation and slack nodes, or none);
77
+ # ``None`` = every node. Outside it the V prior variance is multiplied by
78
+ # ``v_out_of_scope_loose_factor``.
79
+ v_scope_nodes: frozenset[int] | None = None
80
+ v_out_of_scope_loose_factor: float = 1e4
73
81
 
74
82
 
75
83
  def _generation_range_variance(row: np.void, lo_col: str, hi_col: str, fallback: float) -> float:
@@ -216,6 +224,7 @@ def _build_pseudo_rows(
216
224
  n_zinj_added = 0
217
225
  n_unobs_v = 0
218
226
  n_unknown_load = 0
227
+ n_v_loose = 0
219
228
 
220
229
  new_rows: list[dict] = []
221
230
  for row in nodes_arr:
@@ -261,6 +270,11 @@ def _build_pseudo_rows(
261
270
  # nodes (degree=1, empty-model P_inj σ=100) 10% allowed the SE
262
271
  # to drag V down to 0.3-0.5 p.u.: the pseudo-V did not hold the anchor.
263
272
  sigma = (0.05 * vn) ** 2
273
+ if config.v_scope_nodes is not None and nid not in config.v_scope_nodes:
274
+ # Outside the scope a prior at the nominal or input V would pull
275
+ # the estimate there; keep it for observability only.
276
+ sigma *= config.v_out_of_scope_loose_factor
277
+ n_v_loose += 1
264
278
  new_rows.append(
265
279
  pseudo_node_measurement(ids.next(), nid, KIND_VOLTAGE, v_prior_value, float(sigma))
266
280
  )
@@ -335,6 +349,8 @@ def _build_pseudo_rows(
335
349
  "unobservable_v_tight": n_unobs_v,
336
350
  "unknown_load_loose": n_unknown_load,
337
351
  }
352
+ if config.v_scope_nodes is not None:
353
+ stats["v_priors_loose"] = n_v_loose
338
354
  return new_rows, stats
339
355
 
340
356
 
@@ -411,6 +427,8 @@ def add_pseudo_measurements(
411
427
  unmeasured_gen_p_nodes: frozenset[int] = frozenset(),
412
428
  unmeasured_gen_q_nodes: frozenset[int] = frozenset(),
413
429
  loosen_unknown_load: bool = False,
430
+ voltage_scope: Literal["all", "generation", "none"] = "all",
431
+ v_out_of_scope_loose_factor: float = 1e4,
414
432
  ) -> dict:
415
433
  """Augment the model with pseudo-measurements to remove under-observability.
416
434
 
@@ -480,10 +498,26 @@ def add_pseudo_measurements(
480
498
  at both ends of every incident branch) that prior's variance is
481
499
  multiplied by ``boundary_inj_loose_factor``. Typical nodes: cut ends of
482
500
  lines leaving the model and substations of neighbouring systems.
501
+ voltage_scope: nodes whose V prior keeps its σ. ``"all"`` (default):
502
+ every node. ``"generation"``: only the nodes that can hold their
503
+ voltage, i.e. the slack and the nodes with generation (``exist_gen``
504
+ or an active generator); elsewhere the V prior variance is multiplied
505
+ by ``v_out_of_scope_loose_factor``. A V prior at the nominal (or
506
+ input) V on a load node without a V measurement pulls the estimate
507
+ there, and the solver makes up the difference with the load Q.
508
+ ``"none"``: every V prior is loosened, the slack and the generation
509
+ nodes included; V is then held by the network, the measurements and
510
+ the voltage limits. Meant for ``ipm`` / ``ipm_pd``, whose barrier
511
+ keeps V within the limits; WLS has no such bound, and V of a node seen
512
+ by no measurement rests on the loosened prior alone.
513
+ v_out_of_scope_loose_factor: variance multiplier of the V prior outside
514
+ ``voltage_scope``. Default 1e4 (σ × 100) keeps the prior in the
515
+ system only for observability.
483
516
 
484
517
  Returns:
485
518
  ``{"v_priors_added": N, "zero_inj_added": N, "unobservable_v_tight": N,
486
- "unknown_load_loose": N}`` — counters.
519
+ "unknown_load_loose": N}`` — counters, plus ``"v_priors_loose": N`` with
520
+ ``voltage_scope`` other than ``"all"``.
487
521
  """
488
522
  nodes_arr = model.nodes.to_numpy()
489
523
  meas_arr = model.measurements.to_numpy()
@@ -496,6 +530,21 @@ def add_pseudo_measurements(
496
530
  ):
497
531
  node_load_props = extract_node_load_props_from_model(model)
498
532
 
533
+ if voltage_scope not in ("all", "generation", "none"):
534
+ raise ValueError(
535
+ f"voltage_scope={voltage_scope!r} is not one of ['all', 'generation', 'none']"
536
+ )
537
+ v_scope_nodes: frozenset[int] | None = None
538
+ if voltage_scope == "generation":
539
+ gen = model.generators.to_numpy()
540
+ holds_v = nodes_arr["exist_gen"].astype(bool)
541
+ holds_v |= nodes_arr["node_type"] == int(NodeType.SLACK)
542
+ v_scope_nodes = frozenset(
543
+ nodes_arr["id"][holds_v].tolist() + gen["node_id"][gen["status"].astype(bool)].tolist()
544
+ )
545
+ elif voltage_scope == "none":
546
+ v_scope_nodes = frozenset()
547
+
499
548
  config = PseudoMeasConfig(
500
549
  add_zero_injections=add_zero_injections,
501
550
  slack_voltage_sigma_frac=slack_voltage_sigma_frac,
@@ -510,6 +559,8 @@ def add_pseudo_measurements(
510
559
  unmeasured_gen_p_nodes=frozenset(unmeasured_gen_p_nodes),
511
560
  unmeasured_gen_q_nodes=frozenset(unmeasured_gen_q_nodes),
512
561
  loosen_unknown_load=loosen_unknown_load,
562
+ v_scope_nodes=v_scope_nodes,
563
+ v_out_of_scope_loose_factor=v_out_of_scope_loose_factor,
513
564
  )
514
565
 
515
566
  new_rows, stats = _add_pseudo_measurements_on_arrays(