Permeabilidades Relativas en Python: Normalizar y Escalar Endpoints

De una curva de referencia a tres conjuntos aceite–agua

Yacimientos
Petrofísica
Python
Normaliza saturaciones y permeabilidades relativas, conserva la forma de una curva de referencia y reescálala con los endpoints de tres tipos de roca. Ejemplo aceite–agua paso a paso en Python.
Autor/a
Fecha de publicación

3 de octubre de 2026

De una curva de referencia a nuevos endpoints

Dos conjuntos de permeabilidad relativa pueden tener una forma semejante y, al mismo tiempo, diferentes saturaciones límite y valores máximos de kr. La normalización permite expresar la curva en coordenadas entre cero y uno. Después podemos reescalarla con otros endpoints, conservando su forma normalizada.

En este ejemplo aceite–agua seguiremos el procedimiento del capítulo 9 de Rock Properties and Reservoir Engineering: A Practical View, de Badawy y Ganat. El flujo será: leer una curva de referencia, normalizarla, definir nuevos endpoints y reconstruir las curvas.

Trabajaremos con los tres tipos de roca de la tabla 9.19 del libro. El dataset de referencia está calculado con una relación de Corey, no contiene mediciones de laboratorio: usa los endpoints del tipo 1 y los exponentes promedio Nw = 1.8 y No = 2.2 de la tabla 9.17. Así podemos concentrarnos en el escalamiento sin añadir un ajuste de parámetros.

Visita el canal @rigopetrodata

Tutoriales de R, Python y Excel para ingeniería petrolera

Ver canal

Los endpoints del ejemplo

En un sistema aceite–agua, sin gas, las saturaciones cumplen \(S_o=1-S_w\). Usaremos:

Parámetro Significado
Swl Saturación mínima de agua; corresponde a Swirr en el ejemplo del libro.
Swcr Saturación crítica de agua: el límite a partir del cual el agua comienza a ser móvil.
Sor Saturación residual de aceite después del desplazamiento por agua.
krw_max Permeabilidad relativa al agua en Sw = 1 − Sor.
kro_max Permeabilidad relativa al aceite en Sw = Swl.

Swl y Swcr no son conceptos intercambiables. En este ejemplo tomamos Swcr = Swl, como en el cálculo aceite–agua de la tabla 9.20. Esta igualdad permite usar una sola coordenada normalizada común para las dos curvas al reescalar.

Los valores del tipo 1, que usaremos como referencia, son Swl = Swcr = 0.190, Sor = 0.222, krw_max = 0.1783 y kro_max = 0.62. Todas las saturaciones y kr se expresan como fracción.

1. Cargar la curva de referencia

Descargar el dataset de referencia

El CSV contiene las columnas Sw, krw y kro, con 21 puntos entre Sw = 0.190 y Sw = 0.778. Guarda el archivo junto a tu notebook y ejecuta los fragmentos en orden. Necesitamos pandas, NumPy y Matplotlib; puedes instalarlos con pip install pandas numpy matplotlib.

Ver código
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

datos = pd.read_csv("curva_referencia.csv")
datos.head()
Sw krw kro
0 0.1900 0.000000 0.620000
1 0.2194 0.000812 0.553839
2 0.2488 0.002826 0.491728
3 0.2782 0.005863 0.433624
4 0.3076 0.009840 0.379481

Para construir este dataset se usaron \(k_{rw}=0.1783\,s^{1.8}\) y \(k_{ro}=0.62\,(1-s)^{2.2}\), donde \(s=(S_w-0.190)/(1-0.190-0.222)\), con valores de s entre 0 y 1. En los siguientes pasos trabajaremos directamente con los puntos del archivo.

2. Visualizar las curvas originales

Ver código
plt.figure(figsize=(8, 5))
plt.plot(datos["Sw"], datos["krw"], label="Agua")
plt.plot(datos["Sw"], datos["kro"], label="Aceite")
plt.xlabel("Saturación de agua, Sw")
plt.ylabel("Permeabilidad relativa, kr")
plt.legend()
plt.grid(alpha=0.3)
plt.show()
Figura 1: Curvas de referencia calculadas para el tipo de roca 1. El intervalo termina en Sw = 1 − Sor = 0.778.

Al comienzo, el agua es inmóvil y el aceite tiene su kr máxima. Al llegar a Sw = 1 − Sor, el aceite queda inmóvil y el agua alcanza krw_max dentro del intervalo considerado. Este valor de krw no tiene por qué ser uno.

3. Normalizar saturaciones y permeabilidades relativas

Normalizamos cada fase con su propio intervalo, como indica el paso 6 de la sección 9.9 del libro:

\[ S_w^*=\frac{S_w-S_{wcr}}{1-S_{or}-S_{wcr}} \qquad S_o^*=\frac{S_o-S_{or}}{1-S_{or}-S_{wl}} \]

También dividimos cada kr por su valor de extremo:

\[ k_{rw}^*=\frac{k_{rw}}{k_{rw,\max}} \qquad k_{ro}^*=\frac{k_{ro}}{k_{ro,\max}} \]

Los endpoints se conocen por la definición del ejemplo. Para datos de laboratorio no conviene asumir que el último punto medido ya alcanzó la saturación residual.

Ver código
Swl, Swcr, Sor = 0.190, 0.190, 0.222
krw_max, kro_max = 0.1783, 0.62

datos["So"] = 1 - datos["Sw"]
datos["Sw_n"] = (datos["Sw"] - Swcr) / (1 - Sor - Swcr)
datos["So_n"] = (datos["So"] - Sor) / (1 - Sor - Swl)
datos["krw_n"] = datos["krw"] / krw_max
datos["kro_n"] = datos["kro"] / kro_max
datos[["Sw_n", "So_n", "krw_n", "kro_n"]].head().round(4)
Sw_n So_n krw_n kro_n
0 0.00 1.00 0.0000 1.0000
1 0.05 0.95 0.0046 0.8933
2 0.10 0.90 0.0158 0.7931
3 0.15 0.85 0.0329 0.6994
4 0.20 0.80 0.0552 0.6121

Aquí So* = 1 − Sw* porque Swcr = Swl. Por eso podemos representar ambas kr normalizadas contra Sw* en un mismo gráfico: el agua aumenta y el aceite disminuye.

Ver código
plt.figure(figsize=(8, 5))
plt.plot(datos["Sw_n"], datos["krw_n"], label="Agua")
plt.plot(datos["Sw_n"], datos["kro_n"], label="Aceite")
plt.xlabel("Saturación normalizada de agua, Sw*")
plt.ylabel("Permeabilidad relativa normalizada, kr*")
plt.legend()
plt.grid(alpha=0.3)
plt.show()
Figura 2: Curvas normalizadas. Ambas kr cubren de 0 a 1; su forma permanece diferente. La coordenada horizontal común es válida aquí porque Swcr = Swl.

Normalizar cambia las escalas horizontal y vertical. No convierte las curvas en rectas ni obliga a que su cruce ocurra en Sw* = 0.5.

4. Definir los nuevos endpoints

Tomamos los endpoints aceite–agua de la tabla 9.19 y hacemos explícita la igualdad Swcr = Swl para los tres casos. Conservamos el tipo 1 para comprobar que podemos reconstruir la referencia.

Ver código
endpoints = pd.DataFrame({
    "Tipo": ["RT1", "RT2", "RT3"],
    "Swl": [0.190, 0.165, 0.135],
    "Swcr": [0.190, 0.165, 0.135],
    "Sor": [0.222, 0.219, 0.213],
    "krw_max": [0.1783, 0.1783, 0.1783],
    "kro_max": [0.62, 0.65, 0.68]
}).set_index("Tipo")
endpoints
Swl Swcr Sor krw_max kro_max
Tipo
RT1 0.190 0.190 0.222 0.1783 0.62
RT2 0.165 0.165 0.219 0.1783 0.65
RT3 0.135 0.135 0.213 0.1783 0.68

En estos datos krw_max es igual para los tres tipos. Cambian las saturaciones límite y kro_max. Los nombres RT1, RT2 y RT3 corresponden al ejemplo del libro; no son una clasificación obtenida a partir del dataset de núcleos de otros posts.

5. Reescalar la curva normalizada

Despejamos la saturación de agua usando los endpoints del tipo de roca de destino:

\[ S_{w,\mathrm{nuevo}}=S_{wcr,\mathrm{nuevo}}+ S_w^*\left(1-S_{or,\mathrm{nuevo}}-S_{wcr,\mathrm{nuevo}}\right) \]

Después recuperamos la escala de kr:

\[ k_{rw,\mathrm{nuevo}}=k_{rw}^*\,k_{rw,\max,\mathrm{nuevo}} \qquad k_{ro,\mathrm{nuevo}}=k_{ro}^*\,k_{ro,\max,\mathrm{nuevo}} \]

Este reescalamiento conjunto conserva la correspondencia entre las dos fases bajo la condición Swcr = Swl. Comprobamos esa condición y que el intervalo móvil sea positivo antes de calcular.

Ver código
assert np.allclose(endpoints["Swcr"], endpoints["Swl"])
assert (endpoints["Swcr"] + endpoints["Sor"] < 1).all()

curvas = []
for tipo, p in endpoints.iterrows():
    curva = pd.DataFrame({
        "Sw": p["Swcr"] + datos["Sw_n"] * (1 - p["Sor"] - p["Swcr"]),
        "krw": datos["krw_n"] * p["krw_max"],
        "kro": datos["kro_n"] * p["kro_max"]
    })
    curva["Tipo"] = tipo
    curvas.append(curva)
escaladas = pd.concat(curvas, ignore_index=True)
escaladas.head()
Sw krw kro Tipo
0 0.1900 0.000000 0.620000 RT1
1 0.2194 0.000812 0.553839 RT1
2 0.2488 0.002826 0.491728 RT1
3 0.2782 0.005863 0.433624 RT1
4 0.3076 0.009840 0.379481 RT1

No ajustamos nuevos exponentes ni interpolamos otra forma de curva. Estamos reutilizando los mismos valores normalizados y cambiando sus escalas.

Si Swcr fuera mayor que Swl, existiría un intervalo donde el agua sigue inmóvil mientras el aceite ya cambia su kr. En ese caso So* no sería 1 − Sw*: habría que tratar cada fase en su propio intervalo y llevar ambas a una malla común de Sw. No bastaría con cambiar Swcr en este bloque.

🛒
Propiedades De Los Fluidos Del Yacimiento

Disponible en Mercado Libre

Ver oferta →

6. Comparar los tres tipos de roca

Separamos agua y aceite en dos paneles para ver con claridad los cambios de saturación y de kr máxima.

Ver código
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
for tipo, curva in escaladas.groupby("Tipo"):
    axes[0].plot(curva["Sw"], curva["krw"], label=tipo)
    axes[1].plot(curva["Sw"], curva["kro"], label=tipo)
for ax, fase in zip(axes, ["Agua", "Aceite"]):
    ax.set_title(fase)
    ax.set_xlabel("Saturación de agua, Sw")
    ax.set_ylabel("Permeabilidad relativa, kr")
    ax.legend()
plt.tight_layout()
plt.show()
Figura 3: Curvas reescaladas con los endpoints de RT1, RT2 y RT3. El panel de agua conserva el mismo krw_max; el de aceite cambia también kro_max.

RT3 comienza en una Sw menor y alcanza aceite residual en una Sw mayor que RT1. Su intervalo de saturación es más amplio: 0.652 frente a 0.588. Además, su kro máxima aumenta de 0.62 a 0.68. En cambio, las curvas de agua comparten el extremo krw_max = 0.1783.

Los valores normalizados son iguales por construcción. Esto representa una hipótesis de forma común, no una demostración de que todas las rocas tengan el mismo comportamiento de flujo.

7. Revisar los extremos reconstruidos

Como cada curva está ordenada por Sw, podemos revisar sus primeras y últimas filas:

Ver código
resumen = escaladas.groupby("Tipo").agg(
    Sw_inicio=("Sw", "first"),
    Sw_final=("Sw", "last"),
    krw_inicio=("krw", "first"),
    krw_final=("krw", "last"),
    kro_inicio=("kro", "first"),
    kro_final=("kro", "last")
)
resumen.round(4)
Sw_inicio Sw_final krw_inicio krw_final kro_inicio kro_final
Tipo
RT1 0.190 0.778 0.0 0.1783 0.62 0.0
RT2 0.165 0.781 0.0 0.1783 0.65 0.0
RT3 0.135 0.787 0.0 0.1783 0.68 0.0

La saturación inicial debe ser Swl y la final, 1 − Sor. El agua comienza con krw = 0 y termina en krw_max; el aceite comienza en kro_max y termina con kro = 0. Al reescalar hacia los endpoints de RT1 recuperamos la curva de referencia, salvo redondeo numérico.

¿Cómo usar este procedimiento?

El escalamiento es útil para trasladar una forma de curva representativa a otros endpoints. Antes de aplicarlo a datos reales, hay que comprobar que las muestras tengan condiciones comparables de mojabilidad, trayectoria de saturación y referencia de permeabilidad. La selección de endpoints debe apoyarse en información SCAL y en la caracterización de la roca.

Aquí construimos únicamente el tramo Swl ≤ Sw ≤ 1 − Sor. El límite 1 − Sor no equivale al máximo Sw de una tabla completa para un simulador. Preparar una tabla SWOF requiere revisar sus extremos, el tramo adicional hacia Sw = 1 y la presión capilar, temas que quedan fuera de este ejemplo de kr.

Referencia

Badawy, A. M. y Ganat, T. A. O. (2022). Rock Properties and Reservoir Engineering: A Practical View. Springer. Capítulo 9: 9.8, Saturation Functions’ End Points, páginas impresas 144–147; 9.9, Preparing Saturation Functions (An Adopted Methodology), normalización en la página 164 y reconstrucción en las páginas 171–174. Tablas 9.17, 9.19 y 9.20.