gridstate 2.3.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.3.0 → gridstate-2.4.0}/CHANGELOG.md +21 -0
  2. {gridstate-2.3.0 → gridstate-2.4.0}/PKG-INFO +1 -1
  3. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/_version.py +3 -3
  4. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/api.py +33 -0
  5. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/contract/__init__.py +2 -0
  6. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/contract/tables.py +39 -1
  7. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/contract/validate.py +25 -1
  8. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/contract/version.py +3 -1
  9. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/ipm_hints.py +16 -43
  10. gridstate-2.4.0/gridstate/load_characteristics.py +99 -0
  11. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/pipeline/config.py +24 -0
  12. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/pipeline/passes.py +1 -0
  13. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/pipeline/preparation.py +1 -1
  14. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/post_processing.py +34 -0
  15. gridstate-2.4.0/gridstate/solution_hints.py +171 -0
  16. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/shunts.py +25 -12
  17. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/working.py +19 -5
  18. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate.egg-info/PKG-INFO +1 -1
  19. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate.egg-info/SOURCES.txt +4 -0
  20. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate.egg-info/scm_file_list.json +4 -0
  21. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate.egg-info/scm_version.json +2 -2
  22. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_breaker_reactance.py +13 -0
  23. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/golden/case118.json +6 -6
  24. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/golden/case118.npz +0 -0
  25. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/golden/case118_degraded.json +6 -6
  26. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/golden/case118_degraded.npz +0 -0
  27. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/golden/case14.json +6 -6
  28. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/golden/case14.npz +0 -0
  29. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/golden/case14_degraded.json +6 -6
  30. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/golden/case14_degraded.npz +0 -0
  31. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/golden/case30.json +6 -6
  32. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/golden/case30.npz +0 -0
  33. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/golden/case30_degraded.json +6 -6
  34. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/golden/case30_degraded.npz +0 -0
  35. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/golden/manifest.json +75 -12
  36. gridstate-2.4.0/tests/test_load_characteristic_segments.py +159 -0
  37. gridstate-2.4.0/tests/test_solution_hints.py +40 -0
  38. {gridstate-2.3.0 → gridstate-2.4.0}/.github/workflows/ci.yml +0 -0
  39. {gridstate-2.3.0 → gridstate-2.4.0}/.github/workflows/release.yml +0 -0
  40. {gridstate-2.3.0 → gridstate-2.4.0}/.gitignore +0 -0
  41. {gridstate-2.3.0 → gridstate-2.4.0}/.pre-commit-config.yaml +0 -0
  42. {gridstate-2.3.0 → gridstate-2.4.0}/LICENSE +0 -0
  43. {gridstate-2.3.0 → gridstate-2.4.0}/Makefile +0 -0
  44. {gridstate-2.3.0 → gridstate-2.4.0}/README.md +0 -0
  45. {gridstate-2.3.0 → gridstate-2.4.0}/build_cython.py +0 -0
  46. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/__init__.py +0 -0
  47. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/__pyinstaller/__init__.py +0 -0
  48. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/__pyinstaller/hook-gridstate.py +0 -0
  49. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/_arrays.py +0 -0
  50. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/adapters/__init__.py +0 -0
  51. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/adapters/from_pandapower.py +0 -0
  52. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/algebra/__init__.py +0 -0
  53. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/algebra/_selinv_kernels.py +0 -0
  54. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/algebra/base.py +0 -0
  55. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/algebra/estimators.py +0 -0
  56. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/algebra/jacobian_pattern.py +0 -0
  57. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/algebra/normalized_residuals.py +0 -0
  58. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/algorithms/__init__.py +0 -0
  59. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/algorithms/_gram_numba.py +0 -0
  60. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/algorithms/ipm.py +0 -0
  61. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/algorithms/ipm_pd.py +0 -0
  62. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/algorithms/kkt_solver.py +0 -0
  63. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/algorithms/status.py +0 -0
  64. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/algorithms/wls.py +0 -0
  65. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/bad_data_repass.py +0 -0
  66. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/bounds.py +0 -0
  67. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/constants.py +0 -0
  68. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/contract/_legacy.py +0 -0
  69. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/contract/containers.py +0 -0
  70. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/contract/derived.py +0 -0
  71. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/contract/derived_tables.py +0 -0
  72. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/contract/serialize.py +0 -0
  73. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/losses.py +0 -0
  74. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/measurement_ids.py +0 -0
  75. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/pipeline/__init__.py +0 -0
  76. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/pipeline/context.py +0 -0
  77. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/pipeline/manifest.py +0 -0
  78. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/pipeline/orchestration.py +0 -0
  79. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/pipeline/runner.py +0 -0
  80. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/pipeline/steps.py +0 -0
  81. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/preprocessing/__init__.py +0 -0
  82. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/preprocessing/_scan.py +0 -0
  83. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/preprocessing/ipm_setup.py +0 -0
  84. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/preprocessing/meas_rows.py +0 -0
  85. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/preprocessing/mirror_voltage.py +0 -0
  86. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/preprocessing/node_props.py +0 -0
  87. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/preprocessing/pseudo_measurements.py +0 -0
  88. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/py.typed +0 -0
  89. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/quality_summary.py +0 -0
  90. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/resolve_pass.py +0 -0
  91. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/result.py +0 -0
  92. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/runtime.py +0 -0
  93. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/shunt_sanity.py +0 -0
  94. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/state.py +0 -0
  95. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/__init__.py +0 -0
  96. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/_filters.py +0 -0
  97. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/apply_resolved.py +0 -0
  98. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/generators.py +0 -0
  99. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/kinds.py +0 -0
  100. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/load_only_injection.py +0 -0
  101. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/loss_filter.py +0 -0
  102. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/measurements.py +0 -0
  103. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/noise.py +0 -0
  104. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/on_line.py +0 -0
  105. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/quality.py +0 -0
  106. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/rpn.py +0 -0
  107. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/sigma_floor.py +0 -0
  108. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/units.py +0 -0
  109. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/voltage_filter.py +0 -0
  110. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/telemetry/voltage_nominal.py +0 -0
  111. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/topology.py +0 -0
  112. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/units.py +0 -0
  113. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/utils.py +0 -0
  114. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/v_mirror.py +0 -0
  115. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/v_refine.py +0 -0
  116. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/validation/__init__.py +0 -0
  117. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/validation/_diagnostics.py +0 -0
  118. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/validation/bad_data.py +0 -0
  119. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/validation/chi2_test.py +0 -0
  120. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/validation/observability.py +0 -0
  121. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/ybus.py +0 -0
  122. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate/z_vector.py +0 -0
  123. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate.egg-info/dependency_links.txt +0 -0
  124. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate.egg-info/entry_points.txt +0 -0
  125. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate.egg-info/requires.txt +0 -0
  126. {gridstate-2.3.0 → gridstate-2.4.0}/gridstate.egg-info/top_level.txt +0 -0
  127. {gridstate-2.3.0 → gridstate-2.4.0}/pyproject.toml +0 -0
  128. {gridstate-2.3.0 → gridstate-2.4.0}/setup.cfg +0 -0
  129. {gridstate-2.3.0 → gridstate-2.4.0}/tests/__init__.py +0 -0
  130. {gridstate-2.3.0 → gridstate-2.4.0}/tests/_golden.py +0 -0
  131. {gridstate-2.3.0 → gridstate-2.4.0}/tests/conftest.py +0 -0
  132. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_adapters_lazy_import.py +0 -0
  133. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_aggregate_generators.py +0 -0
  134. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_algebra_base.py +0 -0
  135. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_anti_overshoot.py +0 -0
  136. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_arrays_helpers.py +0 -0
  137. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_bad_data.py +0 -0
  138. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_bad_data_repass.py +0 -0
  139. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_bounds.py +0 -0
  140. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_boxed_balance.py +0 -0
  141. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_chain_mirror.py +0 -0
  142. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_contract.py +0 -0
  143. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_contract_edge_cases.py +0 -0
  144. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_contract_runtime.py +0 -0
  145. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/ieee/case118.npz +0 -0
  146. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/ieee/case118_truth.npz +0 -0
  147. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/ieee/case14.npz +0 -0
  148. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/ieee/case14_truth.npz +0 -0
  149. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/ieee/case30.npz +0 -0
  150. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_data/ieee/case30_truth.npz +0 -0
  151. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_derived_tables.py +0 -0
  152. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_enum_codes.py +0 -0
  153. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_flow_sigma_floor.py +0 -0
  154. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_from_pandapower.py +0 -0
  155. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_generator_shunt_kernels.py +0 -0
  156. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_golden.py +0 -0
  157. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_gram_numba.py +0 -0
  158. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_ieee_cases.py +0 -0
  159. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_ipm.py +0 -0
  160. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_ipm_balance_modes.py +0 -0
  161. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_ipm_hints.py +0 -0
  162. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_ipm_injection_roles.py +0 -0
  163. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_ipm_integration.py +0 -0
  164. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_ipm_pd.py +0 -0
  165. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_jacobian_pattern.py +0 -0
  166. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_kkt_solver.py +0 -0
  167. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_load_only_injection.py +0 -0
  168. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_losses_breakdown.py +0 -0
  169. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_losses_q_convention.py +0 -0
  170. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_losses_trafo_classification.py +0 -0
  171. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_materialize_kernel.py +0 -0
  172. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_measurement_filter_kernels.py +0 -0
  173. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_measurement_ids.py +0 -0
  174. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_mirror_voltage_kernel.py +0 -0
  175. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_near_critical_ranking.py +0 -0
  176. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_neutral_wording.py +0 -0
  177. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_noise_model.py +0 -0
  178. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_normalized_residuals.py +0 -0
  179. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_numpy_only_runtime.py +0 -0
  180. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_observability.py +0 -0
  181. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_on_line_kernel.py +0 -0
  182. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_perf_synthetic_network.py +0 -0
  183. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_pipeline.py +0 -0
  184. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_pipeline_config_validation.py +0 -0
  185. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_pipeline_idempotent.py +0 -0
  186. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_pipeline_invariant_errors.py +0 -0
  187. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_pipeline_step_order.py +0 -0
  188. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_pipeline_warm_start.py +0 -0
  189. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_pipeline_working_layer.py +0 -0
  190. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_post_processing.py +0 -0
  191. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_preparation_steps.py +0 -0
  192. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_prepare_network.py +0 -0
  193. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_pseudo_unknown_load.py +0 -0
  194. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_pseudo_v_unobservable.py +0 -0
  195. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_quality_summary_paths.py +0 -0
  196. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_reconcile_balance.py +0 -0
  197. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_refine_merge.py +0 -0
  198. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_resolve_pass.py +0 -0
  199. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_result_quality_summary.py +0 -0
  200. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_rn_selected_inversion.py +0 -0
  201. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_serialize_npz.py +0 -0
  202. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_shunt_sanity.py +0 -0
  203. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_solve_status.py +0 -0
  204. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_state.py +0 -0
  205. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_state_range.py +0 -0
  206. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_telemetry_apply_kernel.py +0 -0
  207. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_telemetry_bounds.py +0 -0
  208. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_telemetry_physics_kernels.py +0 -0
  209. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_tm_code_quality.py +0 -0
  210. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_topology_kernels.py +0 -0
  211. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_topology_port.py +0 -0
  212. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_transformer_from_shunt.py +0 -0
  213. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_units.py +0 -0
  214. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_units_array_kernels.py +0 -0
  215. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_v_mirror.py +0 -0
  216. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_v_refine.py +0 -0
  217. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_variance_charging.py +0 -0
  218. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_version.py +0 -0
  219. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_voltage_calibration_gen_nodes.py +0 -0
  220. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_voltage_range_filter_fallback.py +0 -0
  221. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_wls.py +0 -0
  222. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_wls_robustness.py +0 -0
  223. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_working_container.py +0 -0
  224. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_ybus.py +0 -0
  225. {gridstate-2.3.0 → gridstate-2.4.0}/tests/test_z_vector.py +0 -0
  226. {gridstate-2.3.0 → gridstate-2.4.0}/tools/gen_ieee_fixtures.py +0 -0
  227. {gridstate-2.3.0 → gridstate-2.4.0}/tools/golden.py +0 -0
@@ -9,6 +9,27 @@ 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
+
12
33
  ## v2.3.0 (2026-10-17)
13
34
 
14
35
  ### Behavior changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridstate
3
- Version: 2.3.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.3.0'
22
- __version_tuple__ = version_tuple = (2, 3, 0)
21
+ __version__ = version = '2.4.0'
22
+ __version_tuple__ = version_tuple = (2, 4, 0)
23
23
 
24
- __commit_id__ = commit_id = 'gef79dd7f7'
24
+ __commit_id__ = commit_id = 'g81767e831'
@@ -48,6 +48,7 @@ from gridstate.quality_summary import (
48
48
  top_worst_residuals,
49
49
  )
50
50
  from gridstate.result import MeasurementNormalizedResiduals, SEResult, extract_output_tables
51
+ from gridstate.solution_hints import low_voltage_hints, low_voltage_nodes, residual_hints
51
52
  from gridstate.state import (
52
53
  InitialPoint,
53
54
  StateLayout,
@@ -100,6 +101,7 @@ def estimate(
100
101
  quality_summary_rn_refine_below: float = SELINV_REFINE_BELOW,
101
102
  quality_summary_near_critical: float = NEAR_CRITICAL_THRESHOLD,
102
103
  success_policy: SuccessPolicy = "legacy",
104
+ implausible_v_ratio: float = 0.3,
103
105
  **ipm_kwargs: Any,
104
106
  ) -> SEResult:
105
107
  """Run state estimation for a model and telemetry.
@@ -181,6 +183,13 @@ def estimate(
181
183
  :func:`gridstate.algorithms.status.success_from_status`
182
184
  (``kkt`` or ``mu_schedule_done`` only). ``solve_status`` and
183
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.
184
193
  **ipm_kwargs: IPM-mode knobs, ignored for WLS. ``trust_region_scope``
185
194
  (``"state"`` default | ``"all"``) selects the coordinates the IPM
186
195
  trust region is measured on (see ``_run_ipm``); everything else is
@@ -310,6 +319,30 @@ def estimate(
310
319
  )
311
320
  if success_policy == "status" and not success:
312
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))
313
346
  write_results_to_model(model, v_pu, delta, network_pu, yf=yf, yt=yt, ybus=ybus)
314
347
  # 7. Post-processing of measurements: estimated_si/value/residual.
315
348
  write_measurement_estimates(
@@ -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",
@@ -670,6 +670,41 @@ LOAD_CHARACTERISTICS = TableSchema(
670
670
  ),
671
671
  )
672
672
 
673
+ # Piecewise segments of the static load characteristics, one row per segment.
674
+ # Optional: a (characteristic, quantity) pair with segments replaces its quadratic
675
+ # coefficients of LOAD_CHARACTERISTICS; a pair without segments keeps them.
676
+ LOAD_CHARACTERISTIC_SEGMENTS = TableSchema(
677
+ name="load_characteristic_segments",
678
+ key=("load_model_id", "quantity", "v_lo"),
679
+ doc=(
680
+ "Piecewise P(V)/Q(V) load characteristics: segment k of a (load_model_id, quantity) "
681
+ "pair applies for v_lo[k] < V ≤ v_lo[k+1] (V = |V|/Vnom; a value on a boundary "
682
+ "belongs to the lower segment); the first segment extends to V = 0 whatever its "
683
+ "v_lo. Factor = c0 + c1·V + c2·V² + c3·V³ + c4·V⁴ (1.0 = nominal)."
684
+ ),
685
+ columns=(
686
+ ColumnSpec(
687
+ "load_model_id",
688
+ "i4",
689
+ Role.KEY,
690
+ doc="Characteristic (load_characteristics.id, 0-based); must exist there.",
691
+ ),
692
+ ColumnSpec("quantity", "i1", Role.KEY, doc="0 = P, 1 = Q."),
693
+ ColumnSpec(
694
+ "v_lo",
695
+ "f8",
696
+ Role.KEY,
697
+ doc="Lower bound of |V|/Vnom; strictly increasing within a pair; "
698
+ "treated as -inf for the first segment.",
699
+ ),
700
+ ColumnSpec("c0", "f8", Role.INPUT, doc="Coefficient of V⁰."),
701
+ ColumnSpec("c1", "f8", Role.INPUT, doc="Coefficient of V¹."),
702
+ ColumnSpec("c2", "f8", Role.INPUT, doc="Coefficient of V²."),
703
+ ColumnSpec("c3", "f8", Role.INPUT, doc="Coefficient of V³."),
704
+ ColumnSpec("c4", "f8", Role.INPUT, doc="Coefficient of V⁴."),
705
+ ),
706
+ )
707
+
673
708
  # Shunt devices (reactors/capacitor banks), per device. The core aggregates the active ones
674
709
  # into node.shunt_g/b for the Y-bus (a numeric step, replacing apply_reactors_to_node_shunt) and
675
710
  # in the future switches steps (group_id/step — reserved).
@@ -708,7 +743,8 @@ SHUNTS = TableSchema(
708
743
  class SEInputSchema:
709
744
  """Input contract schema: a set of tables (roles KEY/INPUT/WORKING).
710
745
 
711
- The domain tables ``tap_steps``/``load_characteristics``/``shunts`` are
746
+ The domain tables ``tap_steps``/``load_characteristics``/``shunts``/
747
+ ``load_characteristic_segments`` are
712
748
  format-agnostic numeric inputs (tap changer position selection, unit conversion,
713
749
  aggregation of shunts are done in an external data source). Input-only
714
750
  (roles KEY/INPUT).
@@ -721,6 +757,7 @@ class SEInputSchema:
721
757
  tap_steps: TableSchema
722
758
  load_characteristics: TableSchema
723
759
  shunts: TableSchema
760
+ load_characteristic_segments: TableSchema
724
761
 
725
762
  def tables(self) -> tuple[TableSchema, ...]:
726
763
  """All tables in the order of field declaration (single source — the dataclass)."""
@@ -748,6 +785,7 @@ SE_INPUT = SEInputSchema(
748
785
  tap_steps=TAP_STEPS,
749
786
  load_characteristics=LOAD_CHARACTERISTICS,
750
787
  shunts=SHUNTS,
788
+ load_characteristic_segments=LOAD_CHARACTERISTIC_SEGMENTS,
751
789
  )
752
790
 
753
791
  SE_OUTPUT = SEOutputSchema(
@@ -18,6 +18,7 @@ from typing import Any
18
18
 
19
19
  from gridstate.contract.tables import SE_INPUT, Role, SEInputSchema
20
20
  from gridstate.contract.version import CONTRACT_VERSION_KEY, check_compatibility
21
+ from gridstate.load_characteristics import segment_problems
21
22
 
22
23
 
23
24
  @dataclass(frozen=True)
@@ -25,7 +26,7 @@ class ValidationIssue:
25
26
  """A single input validation problem."""
26
27
 
27
28
  table: str
28
- kind: str # "missing_column" | "missing_table" | "version_incompatible"
29
+ kind: str # "missing_column" | "missing_table" | "version_incompatible" | "invalid_rows"
29
30
  detail: str
30
31
  column: str | None = None
31
32
 
@@ -75,6 +76,20 @@ def _available_columns(source: Any, table_name: str) -> set[str] | None:
75
76
  return set(names or ())
76
77
 
77
78
 
79
+ #: Input tables a source may omit (added in a minor contract version).
80
+ _OPTIONAL_TABLES = frozenset({"load_characteristic_segments"})
81
+
82
+
83
+ def _table_array(source: Any, table_name: str) -> Any:
84
+ """The structured array of table ``table_name`` (``None`` if absent)."""
85
+ if isinstance(source, Mapping):
86
+ return source.get(table_name)
87
+ collection = getattr(source, table_name, None)
88
+ if collection is None or not hasattr(collection, "to_numpy"):
89
+ return None
90
+ return collection.to_numpy()
91
+
92
+
78
93
  def _data_version(source: Any, explicit: str | None) -> str | None:
79
94
  """Data contract version: explicit > ``metadata[contract_version]`` > None."""
80
95
  if explicit is not None:
@@ -128,6 +143,8 @@ def validate_input(
128
143
  for table in schema.tables():
129
144
  available = _available_columns(source, table.name)
130
145
  if available is None:
146
+ if table.name in _OPTIONAL_TABLES:
147
+ continue
131
148
  report.add(
132
149
  ValidationIssue(table.name, "missing_table", "table is missing from the input")
133
150
  )
@@ -143,6 +160,13 @@ def validate_input(
143
160
  )
144
161
  )
145
162
 
163
+ # --- row-level rules of the piecewise load characteristics ---
164
+ segments = _table_array(source, "load_characteristic_segments")
165
+ if segments is not None and len(segments) > 0:
166
+ lc = _table_array(source, "load_characteristics")
167
+ for problem in segment_problems(segments, 0 if lc is None else len(lc)):
168
+ report.add(ValidationIssue("load_characteristic_segments", "invalid_rows", problem))
169
+
146
170
  if strict:
147
171
  report.raise_if_invalid()
148
172
  return report
@@ -48,7 +48,9 @@ from dataclasses import dataclass
48
48
  # explicit legacy path ``load_se_input_npz(path, trusted=True)``.
49
49
  # 3.1.0 (MINOR): the optional derived table ``telemetry_bounds``
50
50
  # (``DerivedInputs.telemetry_bounds``).
51
- CONTRACT_VERSION = "3.1.0"
51
+ # 3.2.0 (MINOR): the optional input table ``load_characteristic_segments``
52
+ # (piecewise load characteristics).
53
+ CONTRACT_VERSION = "3.2.0"
52
54
 
53
55
  # Major of the data the explicit legacy path of the ``.npz`` loader still reads
54
56
  # (``load_se_input_npz(path, trusted=True)``).
@@ -25,34 +25,16 @@ import numpy as np
25
25
 
26
26
  from gridstate.algorithms.ipm import IPMResult
27
27
  from gridstate.preprocessing.ipm_setup import IPMSetup
28
+ from gridstate.solution_hints import KIND_NAMES, describe_row, residual_hints
28
29
  from gridstate.units import BASE_MVA, NetworkPU
29
30
  from gridstate.z_vector import (
30
31
  KIND_BOX_PRIOR_PGEN,
31
32
  KIND_BOX_PRIOR_PNAG,
32
33
  KIND_BOX_PRIOR_QGEN,
33
34
  KIND_BOX_PRIOR_QNAG,
34
- KIND_NODE_BALANCE_P,
35
- KIND_NODE_BALANCE_Q,
36
35
  )
37
36
 
38
37
 
39
- #: Row kinds by name (``MeasurementType`` plus the rows the IPM setup appends).
40
- _KIND_NAMES = {
41
- 0: "P flow",
42
- 1: "Q flow",
43
- 2: "V",
44
- 3: "I",
45
- 4: "P injection",
46
- 5: "Q injection",
47
- KIND_NODE_BALANCE_P: "P balance",
48
- KIND_NODE_BALANCE_Q: "Q balance",
49
- KIND_BOX_PRIOR_PGEN: "Pgen",
50
- KIND_BOX_PRIOR_QGEN: "Qgen",
51
- KIND_BOX_PRIOR_PNAG: "Pload",
52
- KIND_BOX_PRIOR_QNAG: "Qload",
53
- }
54
-
55
-
56
38
  def _box_sections(setup: IPMSetup) -> list[tuple[int, int, np.ndarray]]:
57
39
  lay = setup.layout
58
40
  return [
@@ -120,7 +102,7 @@ class _Context:
120
102
  for bkind, _off, _pos in _box_sections(self.setup):
121
103
  rows = self.rows(pos, bkind)
122
104
  if rows.size and not self.is_role[rows].any():
123
- free_parts.append(_KIND_NAMES[bkind])
105
+ free_parts.append(KIND_NAMES[bkind])
124
106
  label = f"node {node} {var} ({n_nbr} neighbour node{'s' if n_nbr != 1 else ''})"
125
107
  if n_nbr <= 1 and free_parts:
126
108
  hint = (
@@ -135,7 +117,7 @@ class _Context:
135
117
  lo = float(self.setup.box_lo[k]) * BASE_MVA if k is not None else float("nan")
136
118
  hi = float(self.setup.box_hi[k]) * BASE_MVA if k is not None else float("nan")
137
119
  measured, sigma = self.box_variable_state(pos, kind)
138
- name = _KIND_NAMES[kind]
120
+ name = KIND_NAMES[kind]
139
121
  label = f"node {node} {name} range [{lo:.1f}, {hi:.1f}]"
140
122
  if measured:
141
123
  label += ", measured"
@@ -151,17 +133,7 @@ class _Context:
151
133
 
152
134
  def describe_row(self, row: int) -> str:
153
135
  mi = self.setup.meas_index
154
- kind = int(mi.kind[row])
155
- name = _KIND_NAMES.get(kind, f"kind {kind}")
156
- okind, opos = int(mi.object_kind[row]), int(mi.object_pos[row])
157
- if okind == 1:
158
- side = "from" if int(mi.branch_side[row]) == 0 else "to"
159
- obj = f"branch {int(self.net.branch_ids[opos])} ({side})"
160
- else:
161
- obj = f"node {self.node_id(opos)}"
162
- mid = int(mi.meas_id[row])
163
- what = "part measurement" if self.is_role[row] else ("measurement" if mid > 0 else "row")
164
- return f"{name} {what} {mid} at {obj}" if mid > 0 else f"{name} {what} at {obj}"
136
+ return describe_row(mi, self.net, row, part=bool(self.is_role[row]))
165
137
 
166
138
 
167
139
  def _top(counts: Mapping[int, int], n: int, floor: float) -> list[tuple[int, int]]:
@@ -240,17 +212,18 @@ def ipm_convergence_hints(
240
212
  lines.extend(f" {label}" for _, label in weak[:top])
241
213
 
242
214
  sigma = ctx.sigma
243
- rn = np.abs(residual) / np.where(sigma > 0, sigma, np.inf)
244
- if setup.balance_tol is not None:
245
- # Equality rows: their σ is a penalty, not a measurement error.
246
- rn[setup.balance_rows] = 0.0
247
- order = np.argsort(-rn)[:top]
248
- if order.size and rn[order[0]] > 0:
249
- lines.append("largest normalized residuals |r|/σ:")
250
- for i in order.tolist():
251
- kind = int(setup.meas_index.kind[i])
252
- r_txt = f"{residual[i]:.4f} p.u." if kind in (2, 3) else f"{residual[i] * BASE_MVA:.2f}"
253
- lines.append(f" {ctx.describe_row(i)}: {rn[i]:.1f}σ (r = {r_txt})")
215
+ lines.extend(
216
+ residual_hints(
217
+ residual,
218
+ sigma,
219
+ meas_index=setup.meas_index,
220
+ network_pu=network_pu,
221
+ # Equality rows: their σ is a penalty, not a measurement error.
222
+ skip=setup.balance_rows if setup.balance_tol is not None else None,
223
+ part_rows=setup.role_rows,
224
+ top=top,
225
+ )
226
+ )
254
227
 
255
228
  outside: list[tuple[float, str]] = []
256
229
  for row in setup.role_rows.tolist():
@@ -0,0 +1,99 @@
1
+ """Piecewise static load characteristics (``load_characteristic_segments``).
2
+
3
+ A characteristic may define, separately for P and Q (``quantity`` 0 / 1), a set
4
+ of segments: segment ``k`` applies for ``v_lo[k] < V ≤ v_lo[k+1]``
5
+ (``V = |V|/Vnom``; a value on a boundary belongs to the lower segment), the
6
+ first one extends to ``V = 0`` whatever its ``v_lo``. The load factor on a
7
+ segment is ``c0 + c1·V + c2·V² + c3·V³ + c4·V⁴`` (1.0 = nominal).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from itertools import pairwise
13
+
14
+ import numpy as np
15
+
16
+
17
+ #: ``quantity`` codes of ``load_characteristic_segments``.
18
+ QUANTITY_P = 0
19
+ QUANTITY_Q = 1
20
+
21
+ #: ``(load_model_id, quantity) → (boundaries, coefficients)``: the upper
22
+ #: boundaries of all segments but the last (``v_lo[1:]``, increasing) and the
23
+ #: ``(n_segments, 5)`` coefficient matrix.
24
+ SegmentPlan = dict[tuple[int, int], tuple[np.ndarray, np.ndarray]]
25
+
26
+
27
+ def segment_problems(segments: np.ndarray, n_models: int) -> list[str]:
28
+ """Contract violations of a ``load_characteristic_segments`` table (empty = valid).
29
+
30
+ ``n_models`` is the number of rows of ``load_characteristics`` (ids are
31
+ 0-based positions).
32
+ """
33
+ problems: list[str] = []
34
+ if len(segments) == 0:
35
+ return problems
36
+ ids = np.asarray(segments["load_model_id"], dtype=np.int64)
37
+ qty = np.asarray(segments["quantity"], dtype=np.int64)
38
+ v_lo = np.asarray(segments["v_lo"], dtype=np.float64)
39
+ unknown = sorted(set(ids[(ids < 0) | (ids >= n_models)].tolist()))
40
+ if unknown:
41
+ problems.append(f"load_model_id not in load_characteristics: {unknown}")
42
+ bad_q = sorted(set(qty[(qty != QUANTITY_P) & (qty != QUANTITY_Q)].tolist()))
43
+ if bad_q:
44
+ problems.append(f"quantity must be 0 (P) or 1 (Q), got {bad_q}")
45
+ coeffs = np.column_stack([np.asarray(segments[f"c{k}"], dtype=np.float64) for k in range(5)])
46
+ if not np.all(np.isfinite(coeffs)):
47
+ problems.append("non-finite coefficients")
48
+ order = np.lexsort((v_lo, qty, ids))
49
+ for a, b in pairwise(order):
50
+ # The first segment's v_lo is ignored, so only later bounds must be
51
+ # finite; equal bounds make an empty segment.
52
+ same_pair = ids[a] == ids[b] and qty[a] == qty[b]
53
+ if same_pair and (not np.isfinite(v_lo[b]) or v_lo[b] <= v_lo[a]):
54
+ problems.append(
55
+ f"v_lo must be finite and strictly increasing within "
56
+ f"(load_model_id={ids[a]}, quantity={qty[a]})"
57
+ )
58
+ return problems
59
+
60
+
61
+ def segment_plan(segments: np.ndarray, n_models: int) -> SegmentPlan:
62
+ """Group the segments by ``(load_model_id, quantity)``.
63
+
64
+ Raises:
65
+ ValueError: the table violates the contract (see :func:`segment_problems`).
66
+ """
67
+ problems = segment_problems(segments, n_models)
68
+ if problems:
69
+ raise ValueError("invalid load_characteristic_segments: " + "; ".join(problems))
70
+ plan: SegmentPlan = {}
71
+ if len(segments) == 0:
72
+ return plan
73
+ ids = np.asarray(segments["load_model_id"], dtype=np.int64)
74
+ qty = np.asarray(segments["quantity"], dtype=np.int64)
75
+ v_lo = np.asarray(segments["v_lo"], dtype=np.float64)
76
+ coeffs = np.column_stack([np.asarray(segments[f"c{k}"], dtype=np.float64) for k in range(5)])
77
+ order = np.lexsort((v_lo, qty, ids))
78
+ for key in sorted(set(zip(ids.tolist(), qty.tolist(), strict=True))):
79
+ rows = order[(ids[order] == key[0]) & (qty[order] == key[1])]
80
+ plan[key] = (v_lo[rows][1:].copy(), coeffs[rows].copy())
81
+ return plan
82
+
83
+
84
+ def segment_factor(bounds: np.ndarray, coeffs: np.ndarray, v: float) -> float:
85
+ """Load factor at ``v`` (``bounds`` / ``coeffs`` of one plan entry)."""
86
+ # Number of upper bounds strictly below v: on a boundary, the lower segment.
87
+ k = int(np.searchsorted(bounds, v, side="left"))
88
+ c = coeffs[k]
89
+ return float(c[0] + v * (c[1] + v * (c[2] + v * (c[3] + v * c[4]))))
90
+
91
+
92
+ __all__ = [
93
+ "QUANTITY_P",
94
+ "QUANTITY_Q",
95
+ "SegmentPlan",
96
+ "segment_factor",
97
+ "segment_plan",
98
+ "segment_problems",
99
+ ]
@@ -94,6 +94,19 @@ class PipelineConfig:
94
94
  label="Нормализация X короткозамыкателей",
95
95
  help="volt-aware X_pu для R=X=0 ветвей (убирает β-выбросы dV_max на LV).",
96
96
  )
97
+ breaker_x_pu: float | None = _param(
98
+ None,
99
+ group=_G_INPUT,
100
+ label="Breaker X, p.u.",
101
+ control="number",
102
+ min=1e-6,
103
+ max=1e-1,
104
+ depends={"normalize_breakers": True},
105
+ help="X of a breaker branch given by the input format's marker (R=0, X=1 Ohm). "
106
+ "Unset (default): X = 110·min_x·Vnom Ohm with min_x = 4e-6 (≈0.044/Vnom p.u.: "
107
+ "8.8e-5 at 500 kV, 4e-4 at 110 kV). A number: that X in p.u. on any voltage "
108
+ "class. Breaker branches with any other X are taken from the model as they are.",
109
+ )
97
110
  apply_voltage_nominal: bool = _toggle(
98
111
  False,
99
112
  group=_G_INPUT,
@@ -372,6 +385,17 @@ class PipelineConfig:
372
385
  max=500,
373
386
  help="default региональной модели 80.",
374
387
  )
388
+ implausible_v_ratio: float = _param(
389
+ 0.3,
390
+ group=_G_EST,
391
+ label="Implausible V/Vnom",
392
+ control="number",
393
+ min=0.0,
394
+ max=0.9,
395
+ help="A solved state with V/Vnom below this at any node is marked not converged "
396
+ "and logged with hints (e.g. the low-voltage root at a node whose V is barely "
397
+ "observable). 0 disables the check.",
398
+ )
375
399
  kkt_solver: str = _param(
376
400
  "auto",
377
401
  group=_G_EST,
@@ -84,6 +84,7 @@ def _estimate_kwargs(cfg: PipelineConfig, *, init: str, **overrides: Any) -> dic
84
84
  "reconcile_balance": cfg.reconcile_balance,
85
85
  "reconcile_respect_bounds": cfg.reconcile_respect_bounds,
86
86
  "success_policy": cfg.success_policy,
87
+ "implausible_v_ratio": cfg.implausible_v_ratio,
87
88
  }
88
89
  kw.update(overrides)
89
90
  if cfg.algorithm in ("ipm", "ipm_pd"):
@@ -46,7 +46,7 @@ from gridstate.topology import (
46
46
 
47
47
 
48
48
  def _s_normalize_breakers(ctx: _Ctx) -> dict:
49
- return dict(normalize_breaker_reactance(ctx.model) or {})
49
+ return dict(normalize_breaker_reactance(ctx.model, eps_pu=ctx.cfg.breaker_x_pu) or {})
50
50
 
51
51
 
52
52
  def _s_voltage_nominal(ctx: _Ctx) -> dict:
@@ -34,6 +34,7 @@ from gridstate.algebra.base import (
34
34
  BaseAlgebra,
35
35
  )
36
36
  from gridstate.bounds import is_reactive_only_generation, resolve_bounds
37
+ from gridstate.load_characteristics import QUANTITY_P, QUANTITY_Q, segment_factor, segment_plan
37
38
  from gridstate.measurement_ids import ANTI_OVERSHOOT_ID_BASE, IdAllocator
38
39
  from gridstate.preprocessing.meas_rows import pseudo_node_measurement
39
40
  from gridstate.resolve_pass import (
@@ -618,6 +619,14 @@ def apply_load_characteristic(model: Working) -> dict[str, int]:
618
619
  no binding). For PQ-const models (a0=1, a1=a2=0, b0=1, b1=b2=0) the recomputation
619
620
  yields exactly ``load_*_nominal``.
620
621
 
622
+ A characteristic with rows in ``load_characteristic_segments`` for P (resp.
623
+ Q) is piecewise: the segment containing ``V_pu`` (a value on a boundary
624
+ belongs to the lower segment) replaces the quadratic P (resp. Q) row, with
625
+ a polynomial up to ``V_pu⁴`` (see :mod:`gridstate.load_characteristics`).
626
+ A piecewise P (Q) is applied only where the nominal ``load_p`` (``load_q``)
627
+ is positive; elsewhere the estimate is kept. The quadratic rows keep their
628
+ previous behaviour.
629
+
621
630
  Applied **only in the WLS mode** (see ``gridstate/api.py::estimate``)
622
631
  after ``write_node_estimates_from_inj``. The IPM box vars already account for
623
632
  V at the final state — no repeated recomputation is needed.
@@ -637,6 +646,12 @@ def apply_load_characteristic(model: Working) -> dict[str, int]:
637
646
  ``load_characteristics`` table (a dangling reference).
638
647
  * ``no_load_models`` — 1 if the model has no
639
648
  ``load_characteristics`` table (then zeros are returned).
649
+ * ``piecewise_p`` / ``piecewise_q`` — loads recomputed through a
650
+ piecewise P / Q characteristic.
651
+
652
+ Raises:
653
+ ValueError: ``load_characteristic_segments`` violates the contract
654
+ (unknown ``load_model_id``, bad ``quantity``, non-increasing ``v_lo``).
640
655
  """
641
656
  out = {
642
657
  "updated": 0,
@@ -644,6 +659,8 @@ def apply_load_characteristic(model: Working) -> dict[str, int]:
644
659
  "skipped_no_load": 0,
645
660
  "skipped_bad_sxn": 0,
646
661
  "no_load_models": 0,
662
+ "piecewise_p": 0,
663
+ "piecewise_q": 0,
647
664
  }
648
665
 
649
666
  # The source of the load-characteristic coefficients is the ``load_characteristics`` table; nodes carry
@@ -662,6 +679,9 @@ def apply_load_characteristic(model: Working) -> dict[str, int]:
662
679
  b0 = np.asarray(coeff["coeff_q_b0"], dtype=np.float64)
663
680
  b1 = np.asarray(coeff["coeff_q_b1"], dtype=np.float64)
664
681
  b2 = np.asarray(coeff["coeff_q_b2"], dtype=np.float64)
682
+ seg_coll = getattr(model, "load_characteristic_segments", None)
683
+ seg_arr = seg_coll.to_numpy() if seg_coll is not None else None
684
+ plan = segment_plan(seg_arr, n_lm) if seg_arr is not None else {}
665
685
 
666
686
  for row in nodes_arr:
667
687
  if not bool(row["status"]):
@@ -699,6 +719,20 @@ def apply_load_characteristic(model: Working) -> dict[str, int]:
699
719
 
700
720
  load_p_est = load_p_nom * factor_p
701
721
  load_q_est = load_q_nom * factor_q
722
+ seg_p = plan.get((idx, QUANTITY_P))
723
+ if seg_p is not None:
724
+ if load_p_nom > 0.0:
725
+ load_p_est = load_p_nom * segment_factor(*seg_p, v_pu)
726
+ out["piecewise_p"] += 1
727
+ else:
728
+ load_p_est = float(row["load_p_estimated"])
729
+ seg_q = plan.get((idx, QUANTITY_Q))
730
+ if seg_q is not None:
731
+ if load_q_nom > 0.0:
732
+ load_q_est = load_q_nom * segment_factor(*seg_q, v_pu)
733
+ out["piecewise_q"] += 1
734
+ else:
735
+ load_q_est = float(row["load_q_estimated"])
702
736
 
703
737
  try:
704
738
  model.nodes.update(