QGIS

You can integrate QGIS with the RenewMap API for geospatial and mapping functions.

API Access

You will need an API key to get started.

The RenewMap API allows you to integrate up-to-date project data with data analysis platforms such as QGIS. By calling the API directly (rather than using a CSV export), you are guaranteed to always have the most up-to-date data in your GIS tools.

Setup

In QGIS, you can run a Python script to fetch RenewMap API data into your project.

  1. Select Plugins > Python Console and then select 📝 Show editor
  2. Create a new blank script using the ➕ icon.
  3. Paste the code below into the blank script, replacing YOUR_API_KEY with your actual API key.
  4. Run the script with the ▶️ icon.
  5. The latest RenewMap data will appear in your project:
    • A new Point layer called RenewMap with energy project locations and attributes.

Note: the layer is stored in memory and will be lost when you close the project. Re-run the script whenever you want to get the latest data.

If you want to persist the layer to your next session, right click the layer in the Layers pane and select Make permanent. However, this permanent layer will not update with new data.

Projects

import json
import sys
from typing import Dict, List, Optional

import numpy as np
import pandas as pd
import requests

from qgis.core import (
    Qgis,
    QgsFeature,
    QgsField,
    QgsGeometry,
    QgsPointXY,
    QgsProject,
    QgsVectorLayer,
)
from qgis.PyQt.QtCore import QVariant

API_KEY = "YOUR_API_KEY"  # Replace with your actual API key
BASE_URL = "https://api.renewmap.com.au/api/v1/projects"
DATA_KEY = "projects"
ADD_FIELDS = False  # Your account needs to have the fields API enabled
LIMIT = 10000  # Records per request, max 10000. Try 1000 if requests time out.
REQUEST_TIMEOUT = 120  # Seconds
NUMERIC_COLUMNS = ["project_size_mw", "storage_size_mwh"]  # Sent as text by the API

headers = {
    "accept": "application/json",
    "Authorization": f"Bearer {API_KEY}"
}


class APIConfig:
    """Configuration class for API parameters.

    Attributes:
        base_url: Base URL for the API endpoint
        limit: Number of records per request
        headers: HTTP headers for API requests
        data_key: Key in API response containing the data array
    """
    base_url: str = BASE_URL
    limit: int = LIMIT
    headers: Dict = headers
    add_fields: bool = ADD_FIELDS
    data_key: str = DATA_KEY
    timeout: int = REQUEST_TIMEOUT

    def get_url(self, offset=0):
        url = f"{self.base_url}?limit={self.limit}&offset={offset}"
        if self.add_fields:
            url = f"{url}&fields=all"
        return url


def print_environment():
    """Print versions, handy when comparing two machines."""
    print(
        f"QGIS {Qgis.QGIS_VERSION} | Python {sys.version.split()[0]} | "
        f"numpy {np.__version__} | pandas {pd.__version__}"
    )


def fetch_data(config: APIConfig) -> pd.DataFrame:
    """
    Fetch data from API using pagination.

    Args:
        config: APIConfig object containing API configuration

    Returns:
        DataFrame containing all fetched data, with fields flattened if ADD_FIELDS
    """
    projects_data = []
    offset = 0

    # Fetch data in chunks using pagination
    while True:
        url = config.get_url(offset)
        response = requests.get(url, headers=config.headers, timeout=config.timeout)
        response.raise_for_status()
        data_dict = response.json()
        batch = data_dict[config.data_key]

        if len(batch) == 0:
            break  # No more data available

        projects_data.extend(batch)
        print(f"Fetched {len(projects_data)} projects so far...")
        offset += config.limit  # Increment offset for next request

    # Flatten the fields data if applicable
    if config.add_fields:
        projects_data = [flatten_fields(p) for p in projects_data]
        print("Flattened fields")

    print(f"Fetched {len(projects_data)} projects")
    df = pd.DataFrame(projects_data)

    # Convert numeric text columns so QGIS stores them as numbers
    for column in NUMERIC_COLUMNS:
        if column in df.columns:
            df[column] = pd.to_numeric(df[column], errors='coerce')
    return df


def flatten_fields(project_json):
    """
    Unpacks and formats the fields API extension.

    Args:
        project_json: the API json response for a project.

    Returns:
        project_json: the flattened json response

    """
    try:
        if 'fields' not in project_json:
            return project_json
        fields = project_json.pop('fields')
        for field in fields:
            field_name = field['field_name']
            field_value = field['value']

            # Convert list values to CSV for QGIS
            if isinstance(field_value, list):
                field_value = ', '.join([str(i) for i in field_value])
            project_json.update({field_name: field_value})

        return project_json

    except Exception as e:
        print(f"⚠️ There was an error flattening fields for {project_json.get('project_name', 'unknown')}: {e}")
        return project_json


def column_kind(series: pd.Series) -> str:
    """Numeric columns become Double fields; everything else (incl. bool) becomes String."""
    if pd.api.types.is_bool_dtype(series):
        return 'str'
    return 'num' if pd.api.types.is_numeric_dtype(series) else 'str'


def to_native(value, kind: str):
    """Convert a pandas/numpy value into a plain Python value QGIS will accept.

    Lists/dicts are flattened to strings, numpy scalars are unwrapped, and
    NaN/None/NaT become None (NULL in QGIS).
    """
    if isinstance(value, (list, tuple, set)):
        value = ', '.join(str(i) for i in value)
    elif isinstance(value, dict):
        value = json.dumps(value)
    elif isinstance(value, np.generic):
        value = value.item()

    try:
        if pd.isna(value):
            return None
    except (TypeError, ValueError):
        pass

    return float(value) if kind == 'num' else str(value)


def create_qgis_fields(kinds: Dict[str, str]) -> List[QgsField]:
    """
    Create QGIS fields based on DataFrame columns.

    Args:
        kinds: Mapping of column name -> 'num' or 'str'

    Returns:
        List of QgsField objects
    """
    # Create longitude and latitude fields
    fields = [
        QgsField('Longitude', QVariant.Double),
        QgsField('Latitude', QVariant.Double)
    ]
    # Create fields for remaining columns
    for column in kinds:
        qtype = QVariant.Double if kinds[column] == 'num' else QVariant.String
        fields.append(QgsField(column, qtype))
    return fields


def create_feature(row: pd.Series, kinds: Dict[str, str]) -> Optional[QgsFeature]:
    """Create QGIS feature from DataFrame row.

    Args:
        row: Series containing single project data row
        kinds: Mapping of column name -> 'num' or 'str'

    Returns:
        QgsFeature object with geometry and attributes, or None if the row has no point
    """
    point = row['point']
    if point is None or len(point) != 2:
        return None  # No usable coordinates

    longitude, latitude = point
    feature = QgsFeature()
    # Set point geometry
    feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(longitude, latitude)))
    # Set attributes including coordinates and other fields
    feature.setAttributes(
        [longitude, latitude] + [to_native(row[column], kinds[column]) for column in kinds]
    )
    return feature


def create_vector_layer(df: pd.DataFrame) -> QgsVectorLayer:
    """Create QGIS vector layer from DataFrame.

    Args:
        df: DataFrame containing project data

    Returns:
        QgsVectorLayer object with all features
    """
    kinds = {c: column_kind(df[c]) for c in df.columns if c != 'point'}

    # Create memory layer
    layer = QgsVectorLayer("Point?crs=epsg:4326", "RenewMap", "memory")
    provider = layer.dataProvider()

    # Add fields to layer
    provider.addAttributes(create_qgis_fields(kinds))
    layer.updateFields()

    # Add features to layer
    features = []
    for _, row in df.iterrows():
        feature = create_feature(row, kinds)
        if feature is not None:
            features.append(feature)

    ok, _ = provider.addFeatures(features)
    layer.updateExtents()

    print(f"attempted: {len(features)} | layer now has {layer.featureCount()}")
    if not ok or layer.featureCount() != len(features):
        print("⚠️ Some features were rejected by the provider - check the Log Messages panel.")
    return layer


print_environment()
config = APIConfig()
df = fetch_data(config)
layer = create_vector_layer(df)
QgsProject.instance().addMapLayer(layer)

Adding the fields extension

ℹ️ The projects endpoint includes a parameter to return a larger selection of project attributes. To enable, Set ADD_FIELDS = True at the top of the script above.

N.B. This data is returned in json format and needs to be unpacked in the script. The logic to unpack and reshape the fields data can be found in flatten_fields().

Network

import json
import sys
from typing import Dict, List, Optional

import numpy as np
import pandas as pd
import requests
from shapely.geometry import shape

from qgis.core import (
    Qgis,
    QgsFeature,
    QgsField,
    QgsGeometry,
    QgsProject,
    QgsVectorLayer,
)
from qgis.PyQt.QtCore import QVariant

API_KEY = "YOUR_API_KEY"  # Replace with your actual API key
BASE_URL = "https://api.renewmap.com.au/api/v1/network"
DATA_KEY = "network"
LIMIT = 1000  # Records per request, max 1000
REQUEST_TIMEOUT = 120  # Seconds

headers = {
    "accept": "application/json",
    "Authorization": f"Bearer {API_KEY}"
}


class APIConfig:
    """Configuration class for API parameters.

    Attributes:
        base_url: Base URL for the API endpoint
        limit: Number of records per request
        headers: HTTP headers for API requests
        data_key: Key in API response containing the data array
    """
    base_url: str = BASE_URL
    limit: int = LIMIT
    headers: Dict = headers
    add_fields: bool = False  # Network API doesn't support fields
    data_key: str = DATA_KEY
    timeout: int = REQUEST_TIMEOUT

    def get_url(self, offset=0):
        url = f"{self.base_url}?limit={self.limit}&offset={offset}"
        if self.add_fields:
            url = f"{url}&fields=all"
        return url


def print_environment():
    """Print versions, handy when comparing two machines."""
    print(
        f"QGIS {Qgis.QGIS_VERSION} | Python {sys.version.split()[0]} | "
        f"numpy {np.__version__} | pandas {pd.__version__}"
    )


def fetch_data(config: APIConfig) -> pd.DataFrame:
    """Fetch data from API using pagination.

    Args:
        config: APIConfig object containing API configuration

    Returns:
        DataFrame containing all fetched data
    """
    data_list = []
    offset = 0

    # Fetch data in chunks using pagination
    while True:
        url = config.get_url(offset)
        response = requests.get(url, headers=config.headers, timeout=config.timeout)
        response.raise_for_status()
        data_dict = response.json()
        batch = data_dict[config.data_key]

        if len(batch) == 0:
            break  # No more data available

        data_list.extend(batch)
        print(f"Fetched {len(data_list)} items so far...")
        offset += config.limit  # Increment offset for next request

    print(f"Fetched {len(data_list)} items")
    return pd.DataFrame(data_list)


def column_kind(series: pd.Series) -> str:
    """Numeric columns become Double fields; everything else (incl. bool) becomes String."""
    if pd.api.types.is_bool_dtype(series):
        return 'str'
    return 'num' if pd.api.types.is_numeric_dtype(series) else 'str'


def to_native(value, kind: str):
    """Convert a pandas/numpy value into a plain Python value QGIS will accept.

    Lists/dicts are flattened to strings, numpy scalars are unwrapped, and
    NaN/None/NaT become None (NULL in QGIS).
    """
    if isinstance(value, (list, tuple, set)):
        value = ', '.join(str(i) for i in value)
    elif isinstance(value, dict):
        value = json.dumps(value)
    elif isinstance(value, np.generic):
        value = value.item()

    try:
        if pd.isna(value):
            return None
    except (TypeError, ValueError):
        pass

    return float(value) if kind == 'num' else str(value)


def create_qgis_fields(kinds: Dict[str, str]) -> List[QgsField]:
    """Create QGIS fields based on DataFrame columns.

    Args:
        kinds: Mapping of column name -> 'num' or 'str'

    Returns:
        List of QgsField objects
    """
    fields = []
    for column in kinds:
        qtype = QVariant.Double if kinds[column] == 'num' else QVariant.String
        fields.append(QgsField(column, qtype))
    return fields


def create_feature(row: pd.Series, kinds: Dict[str, str]) -> Optional[QgsFeature]:
    """Create QGIS feature from DataFrame row.

    Args:
        row: Series containing single network data row with geometry
        kinds: Mapping of column name -> 'num' or 'str'

    Returns:
        QgsFeature object with geometry and attributes, or None if the geometry is unusable
    """
    # Convert shapely geometry to QgsGeometry
    shapely_geom = shape(row['geometry'])
    qgs_geometry = QgsGeometry.fromWkt(shapely_geom.wkt)
    if qgs_geometry.isNull():
        return None

    feature = QgsFeature()
    feature.setGeometry(qgs_geometry)
    feature.setAttributes([to_native(row[column], kinds[column]) for column in kinds])
    return feature


def create_layer(gdf: pd.DataFrame, geom_type: str, layer_name: str) -> QgsVectorLayer:
    """Create a memory layer holding the rows of one geometry type.

    Args:
        gdf: DataFrame containing network data
        geom_type: Geometry type to keep, also used for the layer ("MultiLineString" / "MultiPolygon")
        layer_name: Name shown in QGIS

    Returns:
        QgsVectorLayer object with the matching features
    """
    kinds = {c: column_kind(gdf[c]) for c in gdf.columns if c != 'geometry'}

    layer = QgsVectorLayer(f"{geom_type}?crs=epsg:4326", layer_name, "memory")
    provider = layer.dataProvider()

    provider.addAttributes(create_qgis_fields(kinds))
    layer.updateFields()

    subset = gdf[gdf['geometry'].apply(lambda x: shape(x).geom_type == geom_type)]

    features = []
    for _, row in subset.iterrows():
        feature = create_feature(row, kinds)
        if feature is not None:
            features.append(feature)

    ok, _ = provider.addFeatures(features)
    layer.updateExtents()

    print(f"{layer_name} | attempted: {len(features)} | layer now has {layer.featureCount()}")
    if not ok or layer.featureCount() != len(features):
        print(f"⚠️ Some '{layer_name}' features were rejected - check the Log Messages panel.")
    return layer


def create_multilinestring_layer(gdf: pd.DataFrame) -> QgsVectorLayer:
    return create_layer(gdf, "MultiLineString", "Network")


def create_multipolygon_layer(gdf: pd.DataFrame) -> QgsVectorLayer:
    return create_layer(gdf, "MultiPolygon", "Network - Development")


print_environment()
config = APIConfig()
gdf = fetch_data(config)

multilinestring_layer = create_multilinestring_layer(gdf)
multipolygon_layer = create_multipolygon_layer(gdf)
QgsProject.instance().addMapLayer(multilinestring_layer)
QgsProject.instance().addMapLayer(multipolygon_layer)

Turbines

import json
import sys
from typing import Dict, List, Optional

import numpy as np
import pandas as pd
import requests

from qgis.core import (
    Qgis,
    QgsFeature,
    QgsField,
    QgsGeometry,
    QgsPointXY,
    QgsProject,
    QgsVectorLayer,
)
from qgis.PyQt.QtCore import QVariant

API_KEY = "YOUR_API_KEY"  # Replace with your actual API key
BASE_URL = "https://api.renewmap.com.au/api/v1/turbines"
DATA_KEY = "turbines"
LIMIT = 10000  # Records per request, max 10000. Try 1000 if requests time out.
REQUEST_TIMEOUT = 120  # Seconds

headers = {
    "accept": "application/json",
    "Authorization": f"Bearer {API_KEY}"
}


class APIConfig:
    """Configuration class for API parameters.

    Attributes:
        base_url: Base URL for the API endpoint
        limit: Number of records per request
        headers: HTTP headers for API requests
        data_key: Key in API response containing the data array
    """
    base_url: str = BASE_URL
    limit: int = LIMIT
    headers: Dict = headers
    add_fields: bool = False  # Turbine API doesn't support fields
    data_key: str = DATA_KEY
    timeout: int = REQUEST_TIMEOUT

    def get_url(self, offset=0):
        url = f"{self.base_url}?limit={self.limit}&offset={offset}"
        if self.add_fields:
            url = f"{url}&fields=all"
        return url


def print_environment():
    """Print versions, handy when comparing two machines."""
    print(
        f"QGIS {Qgis.QGIS_VERSION} | Python {sys.version.split()[0]} | "
        f"numpy {np.__version__} | pandas {pd.__version__}"
    )


def fetch_data(config: APIConfig) -> pd.DataFrame:
    """Fetch data from API using pagination.

    Args:
        config: APIConfig object containing API configuration

    Returns:
        DataFrame containing all fetched data
    """
    data_list = []
    offset = 0

    # Fetch data in chunks using pagination
    while True:
        url = config.get_url(offset)
        response = requests.get(url, headers=config.headers, timeout=config.timeout)
        response.raise_for_status()
        data_dict = response.json()
        batch = data_dict[config.data_key]

        if len(batch) == 0:
            break  # No more data available

        data_list.extend(batch)
        print(f"Fetched {len(data_list)} items so far...")
        offset += config.limit  # Increment offset for next request

    print(f"Fetched {len(data_list)} items")
    return pd.DataFrame(data_list)


def column_kind(series: pd.Series) -> str:
    """Numeric columns become Double fields; everything else (incl. bool) becomes String."""
    if pd.api.types.is_bool_dtype(series):
        return 'str'
    return 'num' if pd.api.types.is_numeric_dtype(series) else 'str'


def to_native(value, kind: str):
    """Convert a pandas/numpy value into a plain Python value QGIS will accept.

    Lists/dicts are flattened to strings, numpy scalars are unwrapped, and
    NaN/None/NaT become None (NULL in QGIS).
    """
    if isinstance(value, (list, tuple, set)):
        value = ', '.join(str(i) for i in value)
    elif isinstance(value, dict):
        value = json.dumps(value)
    elif isinstance(value, np.generic):
        value = value.item()

    try:
        if pd.isna(value):
            return None
    except (TypeError, ValueError):
        pass

    return float(value) if kind == 'num' else str(value)


def create_qgis_fields(kinds: Dict[str, str]) -> List[QgsField]:
    """Create QGIS fields based on DataFrame columns.

    Args:
        kinds: Mapping of column name -> 'num' or 'str'

    Returns:
        List of QgsField objects
    """
    # Create longitude and latitude fields
    fields = [
        QgsField('Longitude', QVariant.Double),
        QgsField('Latitude', QVariant.Double)
    ]
    # Add other fields from the DataFrame
    for column in kinds:
        qtype = QVariant.Double if kinds[column] == 'num' else QVariant.String
        fields.append(QgsField(column, qtype))

    return fields


def create_feature(row: pd.Series, kinds: Dict[str, str]) -> Optional[QgsFeature]:
    """Create QGIS feature from DataFrame row.

    Args:
        row: Series containing single turbine data row
        kinds: Mapping of column name -> 'num' or 'str'

    Returns:
        QgsFeature object with geometry and attributes, or None if the row has no point
    """
    point = row['point']
    if point is None or len(point) != 2:
        return None  # No usable coordinates

    # Extract longitude and latitude from the 'point' column
    longitude, latitude = point

    feature = QgsFeature()
    feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(longitude, latitude)))
    feature.setAttributes(
        [longitude, latitude] + [to_native(row[column], kinds[column]) for column in kinds]
    )
    return feature


def create_vector_layer(df: pd.DataFrame) -> QgsVectorLayer:
    """Create a QgsVectorLayer from a DataFrame.

    Args:
        df: Input DataFrame with turbine data

    Returns:
        QgsVectorLayer object for QGIS map display
    """
    kinds = {c: column_kind(df[c]) for c in df.columns if c != 'point'}

    # Create qgis layer
    layer = QgsVectorLayer("Point?crs=epsg:4326", "Wind Turbine Database", "memory")
    provider = layer.dataProvider()

    # Add fields to layer
    provider.addAttributes(create_qgis_fields(kinds))
    layer.updateFields()

    # Add features to layer
    features = []
    for _, row in df.iterrows():
        feature = create_feature(row, kinds)
        if feature is not None:
            features.append(feature)

    ok, _ = provider.addFeatures(features)
    layer.updateExtents()

    print(f"attempted: {len(features)} | layer now has {layer.featureCount()}")
    if not ok or layer.featureCount() != len(features):
        print("⚠️ Some features were rejected by the provider - check the Log Messages panel.")
    return layer


print_environment()
config = APIConfig()
df = fetch_data(config)
layer = create_vector_layer(df)

# Add the layer to the map
QgsProject.instance().addMapLayer(layer)

Troubleshooting

❓ Check the Troubleshooting guide for some solutions to common problems.