Finding redundant equations in a underdetermined system of multivariate polynomial equations over $\Bbb R$

173 Views Asked by At

Starting from a geometric problem, I came up with a system of multivariate (many lines and points) polynomial equations where some equations are redundant (because they correspond to redundant geometric constraints. However, the constraints are not sufficient to define a geometry so the system is underdetermined.

In the case of no redundand geometric constraints, if I have $n$ unknowns and $m$ equations ($m$ is always $<n$ if there are no redundant equations), using Python SymPy I'm able to express $m$ variables in terms of the remaining $n-m$ free variables. I actually have to try a few times choosing random samples of $m$ variables to solve for, but the computation is fast.

However, most often, I have redundant constraints, and in such case I don't know how many variables I have to solve for (since I don't know the number of independent equations), so I should try samples of different length but the computation unsurprisingly takes too much time.

How can I find an equivalent system with no redundant equations? May Gröbner basis help?


Practical example

As a practical example please find below the Python with SymPy code to build such underdetermined system from a geometric problem:

import random
import sympy as sy
sy.init_printing(use_unicode=True, wrap_line=False)
from func_timeout import func_timeout, FunctionTimedOut # pip install func-timeout /// conda install -c conda-forge func_timeout

# cross product for 2D entities in homogeous coordinates
# see https://staff.fnwi.uva.nl/r.vandenboomgaard/IPCV20162017/LectureNotes/MATH/homogenous.html
def cross(a,b):
    return a[1]*b[2]-a[2]*b[1],-a[0]*b[2]+a[2]*b[0],a[0]*b[1]-a[1]*b[0]

ne = 8 # number of edges
E = sy.Matrix(sy.MatrixSymbol('E', ne, 3)) # matrix of unknown parameters of edges, in homogenous coordinates
for idx in range(E.shape[0]):
    E[idx,2] = 1 # set initial value of 3rd homogeneus coordinate

ort_E = sy.Matrix(tuple((E[e,1],-E[e,0],0) for e in range(E.shape[0]))) # edges orthogonal to originals passing through origin (0,0)

prl = [(1,3),(2,4),(3,5),(5,7),(6,8)] # parallel edges (human writable indices of edges starting at 1)
# orthogonal edges (human writable indices of edges starting at 1)
ort = [(1,2),(5,6)] # strictly necessary conditions
# ort = [(1,2),(2,3),(3,4),(4,1),(5,6),(6,7),(7,8),(8,1)] # 6 redundant conditions

prl_eqs = [cross(E[e1-1,:],E[e2-1,:])[2] for e1,e2 in prl] # 2 edges in homogeneous coordinates are parallel when the 3rd coordinate of their intersection point is zero
ort_eqs = [cross(ort_E[e1-1,:],E[e2-1,:])[2] for e1,e2 in ort] # 2 edges in homogeneous coordinates are orthogonal when the 3rd coordinate of the intersection point of <edge-orthogonal-to-the-1st> with <2nd-edge> is zero
eqs = [sy.simplify(eq) for eq in prl_eqs + ort_eqs] # list of equations derived from all conditions

print('\nNumber of edges: ',ne)
print('Number of edges DOF (degrees of freedom): ',2*ne)
print('\nNumber of equations (derived from conditions on edges): ',len(eqs))
print('Number of unknowns in equations: ',len(sum(eqs).atoms(sy.matrices.expressions.matexpr.MatrixElement)))

G = sy.groebner(eqs)
print('\nLength of Groebner basis: ',len(G))
for g in G: print(g)

print('\nTry direct solving on 7 random unknowns.\n')
eqs_unknowns = list(sum(eqs).atoms(sy.matrices.expressions.matexpr.MatrixElement))
step = 0
for _ in range(200): # with no redundant equations usually a solution is found within 100 iterations
    step += 1
    solving_unk = random.sample(eqs_unknowns,7)
    print('Step ',step,', trying solving on ', solving_unk,end='       \r')
    try:
        sol = func_timeout(20, sy.solve, args=(eqs,solving_unk)) # fun_timeout runs `sy.solve(eqs,solving_unk)`
        if isinstance(sol,dict) and all(x!=0 for x in sol.values()):
            print('\nSolution found: ',sol)
            break
    except FunctionTimedOut:
        continue
    except Exception as e:
        # print('Solving terminated by error: ',e)
        continue
else:
    print('\nNo solution found.')

To switch between no-redundant/redundant cases you just have to comment/uncomment lines 20-21 accordingly. With no redundant constraint/equations we have:

Number of edges:  9
Number of edges DOF (degrees of freedom):  18
Number of equations (derived from conditions on edges):  7
Number of unknowns in equations:  16
Length of Groebner basis:  19

and usually a solution (just using Sympy solve) is found within 100 iterations of 7 random chosen unknowns to solve the system for. With 6 redundant constraint/equations we have:

Number of edges:  9
Number of edges DOF (degrees of freedom):  18
Number of equations (derived from conditions on edges):  13
Number of unknowns in equations:  16
Length of Groebner basis:  32

and no solutions is found (at least after thousands iterations) for any 7 random chosen unknowns to solve system for. That's why I'm asking if Gröbner basis can help.

Just to know, the constraints define the following geometry:

enter image description here

5

There are 5 best solutions below

1
On

If you want to have a square system and then discard the problem of redundant equations, consider that you have $n$ equations $$f_i=f_i(x_1,x_2,\cdots,x_n)=0 \qquad \text{for}\qquad i=1,2,\cdots m ~>~n$$

Consider now the norm $$\Phi=\Phi(x_1,x_2,\cdots,x_n)=\frac 12\sum_{k=1}^m \Big[f_i(x_1,x_2,\cdots,x_n)\Big]^2$$ and differentiate with respect to $x_i$ (for $i=1,2,\cdots,n$) to face $n$ equations $$g_i(x_1,x_2,\cdots,x_n)=\frac {\partial \Phi}{\partial x_i}=\sum_{k=1}^m f_k ~\frac {\partial f_k}{\partial x_i}=0$$

0
On

You don't have to specify the symbols for which to solve when using SymPy:

>>> from sympy import solve, Dummy()
>>> from sympy.abc import x, y
>>> solve(([x+y-z,x-y-z+2,2*x-y-z-1]+[Dummy()*(x+y-z) for i in range(5)]))
[{x:3, y: 1, z:4}]
6
On

I used a Groebner basis to find a minimal set of 7 symbols randomly chosen as in your script. Here it took 16 random tries:

In [448]: for n in range(10000):
     ...:     print(n)
     ...:     randsyms = random.sample(syms, 7)
     ...:     if len(groebner(eqs, randsyms)) != 1:
     ...:         break
     ...: 
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

In [449]: syms
Out[449]: [E₀₀, E₀₁, E₁₀, E₁₁, E₂₀, E₂₁, E₃₀, E₃₁, E₄₀, E₄₁, E₅₀, E₅₁, E₆₀, E₆₁, E₇₀, E₇₁]

In [450]: randsyms
Out[450]: [E₄₁, E₀₁, E₅₁, E₂₀, E₃₀, E₇₁, E₁₁]

In [451]: G = groebner(eqs, randsyms)

In [452]: for eq in G: pprint(eq.as_numer_denom()[0])
-E₄₀⋅E₆₁ + E₄₁⋅E₆₀
-E₀₀⋅E₆₁ + E₀₁⋅E₆₀
E₅₀⋅E₆₀ + E₅₁⋅E₆₁
E₂₀⋅E₆₁ - E₂₁⋅E₆₀
E₃₀⋅E₆₀ + E₃₁⋅E₆₁
E₆₀⋅E₇₀ + E₆₁⋅E₇₁
E₁₀⋅E₆₀ + E₁₁⋅E₆₁

That reduces the original 13 equations to 7 equations that are linear in the 7 chosen symbols. It is clear that no further reduction would be possible since removing any symbol from randsyms would make the system generically unsolvable.

With that choice of symbols the system can be solved (either the Groebner basis or the original equations):

In [453]: linsolve(list(G), randsyms)
Out[453]: 
⎧⎛E₄₀⋅E₆₁  E₀₀⋅E₆₁  -E₅₀⋅E₆₀   E₂₁⋅E₆₀  -E₃₁⋅E₆₁   -E₆₀⋅E₇₀   -E₁₀⋅E₆₀ ⎞⎫
⎨⎜───────, ───────, ─────────, ───────, ─────────, ─────────, ─────────⎟⎬
⎩⎝  E₆₀      E₆₀       E₆₁       E₆₁       E₆₀        E₆₁        E₆₁   ⎠⎭

In [454]: solve(eqs, randsyms)
Out[454]: 
⎡⎛E₄₀⋅E₆₁  E₀₀⋅E₆₁  -E₅₀⋅E₆₀   E₂₁⋅E₆₀  -E₃₁⋅E₆₁   -E₆₀⋅E₇₀   -E₁₀⋅E₆₀ ⎞⎤
⎢⎜───────, ───────, ─────────, ───────, ─────────, ─────────, ─────────⎟⎥
⎣⎝  E₆₀      E₆₀       E₆₁       E₆₁       E₆₀        E₆₁        E₆₁   ⎠⎦

Your script finds this as well but rejects it because it is checking for a dict and as you can see solve does not return a dict in this case (it will if you call it with dict=True).

1
On

After some followup comments I am going to post a different answer that shows more directly how you could do this with Groebner bases. First look at the basis but be careful about what set of symbols you are using to define the basis and in what order you provide them:

In [113]: syms
Out[113]: [E₀₀, E₀₁, E₁₀, E₁₁, E₂₀, E₂₁, E₃₀, E₃₁, E₄₀, E₄₁, E₅₀, E₅₁, E₆₀, E₆₁, E₇₀, E₇₁]

In [114]: G = groebner(eqs, syms)

In [115]: for eq in G: pprint(factor(eq))
E₀₀⋅E₁₀ + E₀₁⋅E₁₁
E₀₀⋅E₂₁ - E₀₁⋅E₂₀
E₀₀⋅E₃₀ + E₀₁⋅E₃₁
(E₀₀⋅E₄₁ - E₀₁⋅E₄₀)⋅E₅₁⋅E₇₁
(E₀₀⋅E₄₁ - E₀₁⋅E₄₀)⋅E₆₀⋅E₇₁
(E₀₀⋅E₆₁ - E₀₁⋅E₆₀)⋅E₇₁
E₀₀⋅E₇₀ + E₀₁⋅E₇₁
(E₁₀⋅E₅₁ - E₁₁⋅E₅₀)⋅E₀₁⋅E₇₀
(E₁₀⋅E₇₁ - E₁₁⋅E₇₀)⋅E₀₁
(E₂₀⋅E₆₁ - E₂₁⋅E₆₀)⋅E₀₁⋅E₇₁
(E₂₀⋅E₇₀ + E₂₁⋅E₇₁)⋅E₀₁
(E₄₀⋅E₇₀ + E₄₁⋅E₇₁)⋅E₀₁⋅E₂₁
(E₃₀⋅E₅₁ - E₃₁⋅E₅₀)⋅E₀₁⋅E₇₀
(E₃₀⋅E₇₁ - E₃₁⋅E₇₀)⋅E₀₁
E₁₀⋅E₂₀ + E₁₁⋅E₂₁
(E₁₀⋅E₄₀ + E₁₁⋅E₄₁)⋅E₂₁
(E₁₀⋅E₅₁ - E₁₁⋅E₅₀)⋅E₂₁⋅E₄₁
(E₁₀⋅E₆₀ + E₁₁⋅E₆₁)⋅E₂₁⋅E₄₁
(E₁₀⋅E₇₁ - E₁₁⋅E₇₀)⋅E₂₁⋅E₄₁⋅E₆₁
E₁₀⋅E₃₁ - E₁₁⋅E₃₀
E₂₀⋅E₃₀ + E₂₁⋅E₃₁
E₂₀⋅E₄₁ - E₂₁⋅E₄₀
(E₃₀⋅E₄₀ + E₃₁⋅E₄₁)⋅E₂₁
(E₃₀⋅E₅₁ - E₃₁⋅E₅₀)⋅E₂₁⋅E₄₁
(E₃₀⋅E₆₀ + E₃₁⋅E₆₁)⋅E₂₁⋅E₄₁
(E₃₀⋅E₇₁ - E₃₁⋅E₇₀)⋅E₂₁⋅E₄₁⋅E₆₁
E₄₀⋅E₅₀ + E₄₁⋅E₅₁
(E₄₀⋅E₇₀ + E₄₁⋅E₇₁)⋅E₅₁
E₄₀⋅E₆₁ - E₄₁⋅E₆₀
E₅₀⋅E₆₀ + E₅₁⋅E₆₁
E₅₀⋅E₇₁ - E₅₁⋅E₇₀
E₆₀⋅E₇₀ + E₆₁⋅E₇₁

The equations that factorise represent different components of the solution i.e. degenerate cases like when E₅₁ = 0. Those complicate what I am about to do so I will remove them on the presumption that it is really the "generic" solution component that you are interested in:

In [121]: neweqs = [Mul(*(f for f in Mul.make_args(eq.factor()) if f.is_Add)) for eq in G]

In [122]: neweqs = groebner(neweqs, syms)

In [123]: neweqs = [Mul(*(f for f in Mul.make_args(eq.factor()) if f.is_Add)) for eq in neweqs]

In [124]: neweqs = groebner(neweqs, syms)

In [125]: for eq in neweqs: pprint(eq.factor())
E₀₀⋅E₁₀ + E₀₁⋅E₁₁
E₀₀⋅E₂₁ - E₀₁⋅E₂₀
E₀₀⋅E₃₀ + E₀₁⋅E₃₁
E₀₀⋅E₄₁ - E₀₁⋅E₄₀
E₀₀⋅E₅₀ + E₀₁⋅E₅₁
E₀₀⋅E₆₁ - E₀₁⋅E₆₀
E₀₀⋅E₇₀ + E₀₁⋅E₇₁
E₁₀⋅E₂₀ + E₁₁⋅E₂₁
E₁₀⋅E₃₁ - E₁₁⋅E₃₀
E₁₀⋅E₄₀ + E₁₁⋅E₄₁
E₁₀⋅E₅₁ - E₁₁⋅E₅₀
E₁₀⋅E₆₀ + E₁₁⋅E₆₁
E₁₀⋅E₇₁ - E₁₁⋅E₇₀
E₂₀⋅E₃₀ + E₂₁⋅E₃₁
E₂₀⋅E₄₁ - E₂₁⋅E₄₀
E₂₀⋅E₅₀ + E₂₁⋅E₅₁
E₂₀⋅E₆₁ - E₂₁⋅E₆₀
E₂₀⋅E₇₀ + E₂₁⋅E₇₁
E₃₀⋅E₄₀ + E₃₁⋅E₄₁
E₃₀⋅E₅₁ - E₃₁⋅E₅₀
E₃₀⋅E₆₀ + E₃₁⋅E₆₁
E₃₀⋅E₇₁ - E₃₁⋅E₇₀
E₄₀⋅E₅₀ + E₄₁⋅E₅₁
E₄₀⋅E₆₁ - E₄₁⋅E₆₀
E₄₀⋅E₇₀ + E₄₁⋅E₇₁
E₅₀⋅E₆₀ + E₅₁⋅E₆₁
E₅₀⋅E₇₁ - E₅₁⋅E₇₀
E₆₀⋅E₇₀ + E₆₁⋅E₇₁

This is now a basis for an irreducible component. A Groebner basis is a generalisation of the reduced row echelon form (RREF) for a system of linear equations. If were to read off the set of solutions for an underdetermined linear system we would assign free parameters to any symbol that does not have a "pivot". The analogy of a pivot here is based on the order of the symbols. There are different ways to do this but I will show a method that is hopefully clear to understand.

Since E₇₁ is the last symbol we might expect the final equation to involve only E₇₁ and be the pivot for that variable. However the earliest symbol in the final equation is E₆₀. This implies that E₇₀, E₆₁ and E₇₁ can be assigned parameters and then this final equation gives E₆₀ in terms of those others.

Applying this iteratively working back through the equations looking for the earliest occurrences of any given symbol identifies the following symbols that can be given free parameters.

In [126]: free_vars = [E[7,1], E[7,0], E[6,1], E[5,1], E[4,1], E[3,1], E[2,1], E[1,1], E[0,1]]

In [127]: len(free_vars)
Out[127]: 9

In [128]: len(syms)
Out[128]: 16

We have then 9 free parameters leaving 7 symbols no free because of the 7 non-redundant constraints. We can introduce new symbols to be the parameters and new equations making these variables equal to those symbols:

In [129]: a = list(symbols('alpha:9'))

In [130]: extra_eqs = [ai - f for ai, f in zip(a, free_vars)]

In [135]: extra_eqs
Out[135]: [α₀ - E₇₁, α₁ - E₇₀, α₂ - E₆₁, α₃ - E₅₁, α₄ - E₄₁, α₅ - E₃₁, α₆ - E₂₁, α₇ - E₁₁, α₈ - E₀₁]

In [133]: G = groebner(list(neweqs) + extra_eqs, syms + a)

In [134]: for eq in G: pprint(factor(eq))
α₇⋅α₈ + E₀₀⋅E₁₀
α₅⋅α₈ + E₀₀⋅E₃₀
α₃⋅α₈ + E₀₀⋅E₅₀
α₀⋅α₈ + α₁⋅E₀₀
α₂⋅E₀₀ - α₈⋅E₆₀
α₄⋅E₀₀ - α₈⋅E₄₀
α₆⋅E₀₀ - α₈⋅E₂₀
-α₈ + E₀₁
α₆⋅α₇ + E₁₀⋅E₂₀
α₄⋅α₇ + E₁₀⋅E₄₀
α₂⋅α₇ + E₁₀⋅E₆₀
α₀⋅E₁₀ - α₁⋅α₇
α₃⋅E₁₀ - α₇⋅E₅₀
α₅⋅E₁₀ - α₇⋅E₃₀
-α₇ + E₁₁
α₅⋅α₆ + E₂₀⋅E₃₀
α₃⋅α₆ + E₂₀⋅E₅₀
α₀⋅α₆ + α₁⋅E₂₀
α₂⋅E₂₀ - α₆⋅E₆₀
α₄⋅E₂₀ - α₆⋅E₄₀
-α₆ + E₂₁
α₄⋅α₅ + E₃₀⋅E₄₀
α₂⋅α₅ + E₃₀⋅E₆₀
α₀⋅E₃₀ - α₁⋅α₅
α₃⋅E₃₀ - α₅⋅E₅₀
-α₅ + E₃₁
α₃⋅α₄ + E₄₀⋅E₅₀
α₀⋅α₄ + α₁⋅E₄₀
α₂⋅E₄₀ - α₄⋅E₆₀
-α₄ + E₄₁
α₂⋅α₃ + E₅₀⋅E₆₀
α₀⋅E₅₀ - α₁⋅α₃
-α₃ + E₅₁
α₀⋅α₂ + α₁⋅E₆₀
-α₂ + E₆₁
-α₁ + E₇₀
-α₀ + E₇₁

Taking now these equations we can compute the Groebner basis wrt the original symbols leaving the free parameters as parameters:

In [136]: Gfinal = groebner(G, syms)

In [137]: for eq in Gfinal: pprint(factor(eq))
α₀⋅α₈ + α₁⋅E₀₀
-α₈ + E₀₁
α₀⋅E₁₀ - α₁⋅α₇
-α₇ + E₁₁
α₀⋅α₆ + α₁⋅E₂₀
-α₆ + E₂₁
α₀⋅E₃₀ - α₁⋅α₅
-α₅ + E₃₁
α₀⋅α₄ + α₁⋅E₄₀
-α₄ + E₄₁
α₀⋅E₅₀ - α₁⋅α₃
-α₃ + E₅₁
α₀⋅α₂ + α₁⋅E₆₀
-α₂ + E₆₁
-α₁ + E₇₀
-α₀ + E₇₁

This is now a system of 16 equations for the 16 unknowns (the original symbols) and is linear:

In [138]: linsolve(Gfinal, syms)
Out[138]: 
⎧⎛-α₀⋅α₈       α₁⋅α₇      -α₀⋅α₆       α₁⋅α₅      -α₀⋅α₄       α₁⋅α₃      -α₀⋅α₂             ⎞⎫
⎨⎜───────, α₈, ─────, α₇, ───────, α₆, ─────, α₅, ───────, α₄, ─────, α₃, ───────, α₂, α₁, α₀⎟⎬
⎩⎝   α₁          α₀          α₁          α₀          α₁          α₀          α₁              ⎠⎭

In [140]: solve(Gfinal, syms)
Out[140]: 
⎧     -α₀⋅α₈                 α₁⋅α₇                -α₀⋅α₆                 α₁⋅α₅                -α₀⋅α₄                 α₁⋅α₃                -α₀⋅α₂                            ⎫
⎨E₀₀: ───────, E₀₁: α₈, E₁₀: ─────, E₁₁: α₇, E₂₀: ───────, E₂₁: α₆, E₃₀: ─────, E₃₁: α₅, E₄₀: ───────, E₄₁: α₄, E₅₀: ─────, E₅₁: α₃, E₆₀: ───────, E₆₁: α₂, E₇₀: α₁, E₇₁: α₀⎬
⎩        α₁                    α₀                    α₁                    α₀                    α₁                    α₀                    α₁                             ⎭

This is a parametric representation of the main solution component for all 16 symbols in terms of 9 free parameters.

6
On

Following the 2nd Oscar Benjamin's answer I tried to code the procedure he described, with the following caveats:

  • I'm not interested in degenerate cases so, as suggested, I iteratively applied the command [Mul(*(f for f in Mul.make_args(eq.factor()) if f.is_Add)) for eq in G] to eliminate free factors from Groebner basis equations

  • I omitted the extra step of symbols replacement, because this question is just an intermediate step of a bigger geometric problem for which replacing symbols is counter-productive

Here's the code, complete of problem definition:

import random
import sympy as sy
sy.init_printing(use_unicode=True, wrap_line=False)
from func_timeout import func_timeout, FunctionTimedOut # pip install func-timeout /// conda install -c conda-forge func_timeout

# cross product for 2D entities in homogeous coordinates
# see https://staff.fnwi.uva.nl/r.vandenboomgaard/IPCV20162017/LectureNotes/MATH/homogenous.html
def cross(a,b):
    return a[1]*b[2]-a[2]*b[1],-a[0]*b[2]+a[2]*b[0],a[0]*b[1]-a[1]*b[0]

def get_symbols(x):
    return list(x.atoms(sy.matrices.expressions.matexpr.MatrixElement))

##### PROBLEM DEFINITION #####

ne = 8 # number of edges
E = sy.Matrix(sy.MatrixSymbol('E', ne, 3)) # matrix of unknown parameters of edges, in homogenous coordinates
for idx in range(E.shape[0]):
    E[idx,2] = 1 # set initial value of 3rd homogeneus coordinate

ort_E = sy.Matrix(tuple((E[e,1],-E[e,0],0) for e in range(E.shape[0]))) # edges orthogonal to originals passing through origin (0,0)

prl = [(1,3),(2,4),(3,5),(5,7),(6,8)] # parallel edges (human writable indices of edges starting at 1)
# orthogonal edges (human writable indices of edges starting at 1)
# ort = [(1,2),(5,6)] # strictly necessary conditions
ort = [(1,2),(2,3),(3,4),(4,1),(5,6),(6,7),(7,8),(8,1)] # 6 redundant conditions

prl_eqs = [cross(E[e1-1,:],E[e2-1,:])[2] for e1,e2 in prl] # 2 edges in homogeneous coordinates are parallel when the 3rd coordinate of their intersection point is zero
ort_eqs = [cross(ort_E[e1-1,:],E[e2-1,:])[2] for e1,e2 in ort] # 2 edges in homogeneous coordinates are orthogonal when the 3rd coordinate of the intersection point of <edge-orthogonal-to-the-1st> with <2nd-edge> is zero
eqs = [sy.simplify(eq) for eq in prl_eqs + ort_eqs] # list of equations derived from all conditions

print('\nNumber of edges: ',ne)
print('Number of edges DOF (degrees of freedom): ',2*ne)
print('\nNumber of equations (derived from conditions on edges): ',len(eqs))
print('Number of unknowns in equations: ',len(sum(eqs).atoms(sy.matrices.expressions.matexpr.MatrixElement)))

##### SOLUTIONS SEEKING #####

def seek_sol(E,eqs):
    eqs_unknowns = list(E[:,:-1])
    random.shuffle(eqs_unknowns)
    def sym_idx(x,sym_list=eqs_unknowns): # return the index of symbol `x` in the unknowns list, according to the ordering used to compute Groebner basis
        return sym_list.index(x)
    # "cleaning" Groebner basis from degenerate cases
    G0 = sy.groebner(eqs, eqs_unknowns)
    G = None
    for step in range(10):
        if G == (G := sy.groebner(eqs, eqs_unknowns)):
            break
        eqs = [sy.Mul(*(f for f in sy.Mul.make_args(eq.factor()) if f.is_Add)) for eq in G]
    else:
        print('Could not find an irreducible Groebner basis after 10 steps.')
    # finding pivot equations, solving unknowns and free_symbols
    seen_syms = [] # symbols encountered in back-traversing "cleaned" Groebner equations
    pivot_syms = [] # unknowns to solve the system for
    for eq in reversed(G): # loop back through the equations
        eq_syms = get_symbols(eq) # unknowns in current Groebner equation
        candidate_pivot_sym = min(eq_syms,key=sym_idx) # find the unknown of equation that is earliest in the ordering
        if all(sym_idx(candidate_pivot_sym) < sym_idx(x) for x in seen_syms): # if that symbol is earlier than any previously seen symbol
            pivot_syms.append(candidate_pivot_sym) # then the equation is a pivot for that symbol
        seen_syms += eq_syms # update the list of seen symbols
    free_syms = [x for x in eqs_unknowns if x not in pivot_syms] # any unknown without pivot is a free symbol
    Gfinal = sy.groebner(G0,pivot_syms)
    sol = sy.solve(Gfinal,pivot_syms)
    # sol = sy.linsolve(Gfinal,pivot_syms) # alternative way, but are Gfinal equations always linear?
    return sol,eqs_unknowns,pivot_syms,free_syms

solution,unknowns_ordering,solving_unknowns,free_symbols = seek_sol(E,eqs)
print(f'\nOrdered unknowns:')
sy.pprint(unknowns_ordering)
print(f'Free unknowns ({len(free_symbols)}): ')
sy.pprint(free_symbols)
print(f'Solving unknowns ({len(solving_unknowns)}): ')
sy.pprint(solving_unknowns)
print('Solution: ')
sy.pprint(solution)

The code works fine for this specific problem, finding the solution in which 7 symbols (correspondent to 7 geometric non-redundant constraints) are expressed in terms of 9 free symbols.

I hope this algorithm will be able to solve more underdetermined polynomial system with redundant equations coming from similar geometric problems, but even so IMHO it is a great result, thanks to Oscar Benjamin for his directions.