Skip to content

Getting Started

Installation

SolarFields isn't on PyPI yet; install it from source (requires Python 3.13 or 3.14 and uv):

git clone https://gitlab.com/spf-ost/solarfields.git
cd solarfields
uv sync

uv sync also installs the development tools (dependency group dev), among them pandapipes for the reference solver. uv sync --no-dev installs SolarFields alone; uv sync --no-dev --extra pandapipes adds pandapipes.

Dependencies

Package Purpose
numpy Numerical arrays
numba JIT compilation for physics kernels
scipy Linear algebra (LAPACK) for numba's np.linalg in the compiled solvers
matplotlib Visualization
pyyaml Configuration file parsing
pandapipes Optional (extra pandapipes): reference solver for fields (PandapipesSolver), used by the tests and the solver benchmark

FieldSolver doesn't need pandapipes. PandapipesSolver imports it on first use, so that import solarfields doesn't load it (about 1.4 s); without pandapipes, it raises an error that says how to install it.

Quick Start: Single String

Define your configuration in YAML:

# config.yaml, in a folder next to configs/ (paths of includes are relative to this file)
includes:
  - ../configs/collectors.yaml
  - ../configs/pipes.yaml

fluid: water

operating_conditions:
  G_b: 700.0 W/m²          # Beam irradiance [W/m²]
  G_d: 100.0 W/m²          # Diffuse irradiance [W/m²]
  T_amb: 25.0 °C         # Ambient temperature [°C]
  u: 2.0 m/s              # Wind speed [m/s]
  E_L: 350.0 W/m²          # Long-wave irradiance [W/m²]

inlet:
  T_inlet: 40.0 °C
  V_dot: 1800.0 L/h

outlet:
  p_outlet: 3.0 bar       # Absolute static pressure at the outlet [bar]: here the system pressure, at the expansion vessel
  field_elevation: 10.0 m  # Highest point of the field above the vessel [m]: here a roof

string:
  name: String-10-Harp
  collectors:
    - type: FP-SG-15m2   # From configs/collectors.yaml
      count: 10
  inter_pipes: short_connector  # From configs/pipes.yaml

Every dimensional value is given with its unit (Units in Config Files).

A point is always the decimal point

1.200 m is 1.2 m, not twelve hundred meters. Write numbers without separators between groups of digits: 1200 m, 12500 L/h. A comma, a space, an apostrophe or an underscore in a number is rejected with an error that shows how to write it; a point between groups of digits can't be told from a decimal point and is read as one.

Run the solver:

from solarfields import load_config, StringSolver

config = load_config('config.yaml')
solver = StringSolver(config['string'], config['fluid'], pipes_in_air=config['pipes_in_air'])
result = solver.solve(
    config['inlet']['T_inlet'],
    config['inlet']['V_dot'],
    config['operating_conditions'],
    **config['pressure'],  # p_inlet or p_outlet, and field_elevation, as quantities
)

print(f"T_out: {result.T_outlet:.1f}°C, ΔT: {result.dT:.1f} K")
print(f"Δp: {result.dp_total/100:.1f} mbar")
print(f"η: {result.eta_string*100:.1f}%")

The objects that load_config returns can't be changed after they are built (#68): a value changed on the string of a solver built before would have no effect. To vary a value of a component, make a new one with dataclasses.replace, which checks the value again, and a new solver (Changing a Value: Frozen Objects):

from dataclasses import replace

string = config['string']
collectors = [replace(c, thermal=replace(c.thermal, eta0_b=0.75)) for c in string.collectors]
solver = StringSolver(replace(string, collectors=collectors), config['fluid'], pipes_in_air=config['pipes_in_air'])

Quick Start: Field with Several Sub-fields

For complex field layouts with several sub-fields:

  • A sub-field groups parallel strings fed by common inlet/outlet headers
  • Topology (direct or reverse return) defines how headers connect to strings
  • Branch piping connects sub-fields in series or parallel configurations, and junctions where a main pipe splits or joins (junctions, Deeper Tree Branching)
  • In Python, FieldNetwork.from_subfields builds the same network from sub-fields and branch pipes (Sub-fields in Python)
# multi_subfield_config.yaml: includes, fluid, operating_conditions, inlet and strings as above
field:
  name: Two-Subfield-Field
  subfields:
    - name: Subfield-A
      topology: direct_return
      strings:
        - type: String-10-Harp
          count: 5
      piping:
        inlet_header_D: [0.080 m, 0.065 m, 0.050 m, 0.040 m]
        outlet_header_D: [0.080 m, 0.065 m, 0.050 m, 0.040 m]  # direct return: segment 0 is next to the outlet
        header_segment_L: 4.0 m
        string_connection_D: 0.035 m
        string_connection_L: 2.0 m
        header_material: steel
        connection_material: steel
  branch_pipes:
    - from: inlet
      to: Subfield-A.inlet
      pipe: {diameter: 0.10 m, length: 10.0 m, material: steel}
    - from: Subfield-A.outlet
      to: outlet
      pipe: {diameter: 0.10 m, length: 10.0 m, material: steel}

Checks of a Configuration

load_config reads every section of a configuration file strictly, so that nothing in it is silently without effect:

  • A key that no loader reads, e.g. a misspelt one, raises ConfigKeyUnknownError. The message names the section (e.g. collector 'FP-SG-15m2' thermal), the unknown keys and the known ones.
  • A collector, pipe or string that is referred to by name but isn't defined raises a ValueError that lists the defined names.
  • A value that describes the product or the plant, e.g. a collector's gross area or a pipe's diameter, length and material, has no default: a missing one raises ConfigKeyMissingError. The required values and the documented defaults are listed in Architecture.
  • A key or value that was renamed or replaced raises an error that names its replacement, e.g. sky: ISO9806Sky.HAZY for iso9806: hazy.
  • Every dimensional value is given with its unit, e.g. D_inner: 32 mm, V_dot: 18 m³/h or T_inlet: 40 °C, in any unit of the right dimension; a plain number, a quantity of another dimension, or text that can't be read raises ConfigUnitError, which names the file, the value's place and what to write (Architecture).
  • A key given twice in one section of a file raises ConfigKeyDuplicateError, which names the key, the file and both lines: a YAML reader would keep the second without a sign. Anchors and merge keys (<<: *base, followed by the keys that differ) are allowed; two << in one section count as a key given twice. Several sections are merged with one << and a list, <<: [*a, *b], in which the first one wins where they share a key.
  • Every value has a range (Architecture). Diameters, thicknesses and the soil cover have upper limits (1.5 m, 0.3 m, 10 m), which catch a value with a wrong unit, e.g. inlet_header_D: 78 m; the wind speed is at most 40 m/s, a given absolute pressure at most 25 bar.
  • Values that would be ignored or filled in are errors too: a list of string valves together with a uniform valve K for the same side of the strings, and a list of string valves or of inter-collector pipes with another number of entries than strings or gaps (null leaves a string without a valve, or a gap without a pipe).

Free-form are only the properties of the nodes of a graph network (e.g. a position for drawing), its metadata, and the settings sweep of the example scripts.

Examples

The examples/ folder contains worked-out examples with visualizations:

Example Description
single_harp/ Single harp collector characterization
single_meander/ Single meander collector (4 parallel serpentines)
string_harp_mixed/ 10-collector string with mixed single/double-glazed harps
string_meander/ Parallel meander string (40 serpentines)
string_meander_mixed/ Two groups of meander collectors in series
virtual_collector/ Inline virtual collector with custom piping
field_analysis_direct_return/ Multi-string direct-return field with headers
field_analysis_reverse_return/ Multi-string reverse-return field with headers
branching/ Field with several sub-fields and branching network
multi_subfield/ Field with several sub-fields
subfields_in_series/ Two sections in series: single-glazed, then double-glazed collectors (similar to the plant in Friesach, Austria)
benchmarks/ Benchmark problems: solver comparison and run times

Run any example:

uv run python examples/single_harp/analysis.py

Pre-defined Components

The configs/ folder contains reusable component libraries:

File Contents
configs/collectors.yaml Collector definitions (geometry, thermal coefficients)
configs/pipes.yaml Pipe definitions (connectors, field piping, fittings)

Reference by name in configurations:

collectors:
  - type: FP-SG-15m2      # Defined in configs/collectors.yaml
    count: 10
inter_pipes: short_connector  # Defined in configs/pipes.yaml

Virtual collectors (multiple meander collectors connected via headers) can be defined inline:

collectors:
  - type: virtual
    base_collector: FP-Meander-15m2  # Must be meander-4conn type
    parallel_count: 10
    internal_piping:
      diameter: 0.039 m
      length: 0.5 m

See Architecture for full specification.

Pipe Sizes

A pipe can name its size in a series of the pipe catalogue instead of giving its inner diameter and material: series: EN 1057 and size: 35×1.5 give a copper tube of 32 mm inner diameter. The series are copper tubes (EN 1057), stainless steel press-fitting tubes (EN 10312), threaded steel tubes of the medium series (EN 10255 medium) and the steel service pipes of pre-insulated buried pipes (EN 253); a size that isn't in the catalogue is given by its inner diameter.

pipes:
  - name: header_pipe
    length: 12 m
    series: EN 1057
    size: 35×1.5
from solarfields import Q_, Pipe, pipe_size, pipe_sizes

size = pipe_size("EN 253", "DN 100")       # size.D_inner: 0.1071 m, size.D_outer: 0.1143 m
print([s.size for s in pipe_sizes("EN 1057")])
pipe = Pipe(length=Q_(80, "m"), D_inner=size.D_inner, roughness=size.roughness)

A size of EN 253 gives the steel pipe. A buried pipe without insulation_thickness has the insulation of pre-insulated pipes of EN 253 insulation series 1, or of the series set in buried_pipes: insulation_series (Architecture).

The dimensions come from public manufacturer data, as the standards weren't at hand: Wieland and HME (copper), Geberit Mapress (stainless steel), thesteel.com and The Engineering ToolBox (EN 10255), isoplus (EN 253). Architecture lists the sources with their links and the sizes of each series.

Fluid Properties

The tool supports 21 CoolProp incompressible fluids with automatic property evaluation. Fluids are specified by name (case-insensitive), and variable-concentration fluids require a concentration parameter.

Available Fluids

Fluid Description Concentration Basis Typical Use
water Pure water — General purpose, low-temperature systems
TVP1 Therminol VP-1 — CSP, parabolic trough (+12 to +255°C)
T66 Therminol 66 — High-temp industrial (0 to +355°C)
DowJ Dowtherm J — Wide-range applications (-80 to +180°C)
(+3 more thermal oils) See docs/architecture.md — High-temperature applications
MEG Ethylene Glycol (aqueous) Mass % Industrial systems, lab data in mass %
MPG Propylene Glycol (aqueous) Mass % Freeze protection, lab data in mass %
AEG ASHRAE Ethylene Glycol Volume % ASHRAE-based designs
APG ASHRAE Propylene Glycol Volume % ASHRAE-based designs
AL, AN Antifrogen L/N Volume % Commercial heat transfer fluids
ZM, ZMC, ZFC, ZLC Zitrec series Volume % Specialized solar thermal fluids
(+4 more glycols) See full list in docs/architecture.md Volume % Various industrial applications

Note: Thermal oils (TVP1, T66, T72, TD12, DowJ, DowQ) are now supported for CSP and parabolic trough applications.

Usage

In Python:

from solarfields import Q_, StringSolver

# Pure fluid (no concentration)
solver = StringSolver(string, fluid='water')

# Glycol mixture: the concentration as a quantity, with the fluid's basis
solver = StringSolver(string, fluid='MPG', concentration=Q_(40, "%"), basis="mass")
solver = StringSolver(string, fluid='APG', concentration=Q_(40, "%"), basis="volume")

The Python API takes every dimensional value as a quantity, e.g. Q_(32, "mm"), Q_(40, "degC"), Q_(3, "bar"); a plain number is an error (Units in the Python API).

In YAML config:

# Simple format (pure fluids)
fluid: water

# Dict format (fluids requiring concentration)
fluid:
  name: MPG
  concentration: 40 % by mass  # MPG's basis

Concentration Parameter

  • Required for: the mixtures (glycols and brines); not for water and the thermal oils, which reject one
  • Unit: percent, with the basis in config files: 40 % by mass for MEG and MPG, 40 % by volume for the other mixtures; a figure on the other basis is rejected; convert it first (Concentration by Mass and by Volume). In the Python API, a value between 0 and 1 is rejected, as it would be a fraction (0.4 would mean 0.4 %, not 40 %)
  • Available values: any concentration within the range of the fluid's data, e.g. 10–60 % by volume for APG; a concentration outside it is rejected with that range. Formulas are stored every 5 % (closer where needed); between them, density and heat capacity are interpolated linearly and the viscosity as ln μ, within the same accuracy as at the stored concentrations (Formulas)

Important: Concentration Basis

Fluids Basis Meaning
MEG, MPG Mass % kg glycol per 100 kg mixture
All others Volume % Volume of the concentrate per volume; two definitions are in use (Concentration by Mass and by Volume)

Warning: MEG/AEG (ethylene glycol) and MPG/APG (propylene glycol) use different bases! 40 % by volume is 40–41 % by mass for propylene glycol and 42–43 % for ethylene glycol. Choose the fluid matching your data source, or convert the figure (Concentration by Mass and by Volume).

Freezing temperatures of the glycols, as solarfields stores them from CoolProp's data; the calculation stops where the fluid would freeze (Temperature Limits):

Fluid 30 % 40 % 50 %
MEG (ethylene glycol, by mass) −14.6 °C −23.8 °C −36.0 °C
AEG (ethylene glycol, by volume) −15.7 °C −24.8 °C −37.1 °C
MPG (propylene glycol, by mass) −12.8 °C −20.6 °C −32.2 °C
APG (propylene glycol, by volume) −13.1 °C −21.5 °C −33.7 °C

At the same concentration, propylene glycol freezes 2–4 K higher than ethylene glycol. For a trade product, its data sheet gives the freezing temperatures.

Temperature Limits

A calculation continues as long as the fluid neither boils nor freezes anywhere in the field, and stops with a TemperatureOutOfRangeError where it would. Such a result would be invalid, and usually means a design problem: too many collectors in series or too low a volume flow, too low an inlet temperature or concentration, or too low a pressure. The error names the place, here for ten FP-SG-15m2 collectors in series with water at 900 L/h and 1.5 bar at the outlet:

String 'S3', collector 10 (FP-SG-15m2), riser 7 outlet: 115.6 °C reaches the boiling temperature of
water at 1.50 bar, 111.4 °C. The results would be invalid. This usually means too many collectors in
series or too low a volume flow; a higher system pressure raises the boiling temperature. 4 more places
exceed a limit.
  • Freezing: the lower limit is the freezing temperature at the concentration, e.g. −12.8 °C for 30 % MPG, −20.6 °C for 40 %, −32.2 °C for 50 %; for thermal oils, the lower end of their data.
  • Boiling: the upper limit is the boiling temperature at the local absolute pressure, which follows from the given pressure (below) and the pressure drops. Water boils at 133.5 °C at 3 bar and 158.8 °C at 6 bar. The water-based fluids, glycol mixtures and brines, are taken to boil at water's temperature: they boil a few kelvin higher, so the limit lies on the safe side. Thermal oils boil by their vapour pressure curves. A higher system pressure lets a hotter field run.
  • Beyond the fluid data, e.g. a glycol mixture above 100 °C, the property formulas extrapolate up to boiling, at some loss of accuracy.

How each fluid's boiling temperature follows, the glycol products included: Temperature Limits.

Pressure

A config gives one absolute static pressure, with its unit, e.g. 3 bar: at the inlet (inlet: p_inlet) or at the outlet (outlet: p_outlet), not both. In the same section, field_elevation is the height of the field's highest point above the point of that pressure [m], 0 if not given. The pressure sets the boiling temperatures along the field (above), and water's properties depend on it; the results give the absolute static pressure along every string and pipe. In the Python API, solve takes p_inlet or p_outlet in Pa and field_elevation in m; config['pressure'] holds them.

Every pressure of the field is taken at its highest point, ρgΔz below the given pressure: about 0.1 bar per metre. A field 10 m above a vessel at 3 bar, with water at 60 °C, is at 2.04 bar, where water boils at 120.8 °C instead of 133.5 °C. The density of the column is taken on the safe side: for a field above the vessel, that of the colder fluid, inlet or outlet; for a field below it, that of the hotter. A field below the vessel has higher pressures, but its hot outlet flows up to the plant at the vessel's height: the boiling check takes the outlet there too, at the given pressure.

The system pressure is fixed where the expansion vessel connects. If it connects at the pump suction, upstream of the pump that feeds the field, give the system pressure as p_outlet and the height of the field's highest point above the vessel as field_elevation. The pressure drop from the field outlet to the vessel comes on top and every place below the highest point is at a higher pressure, so the boiling check errs on the safe side. Heights within the field aren't modelled (Pressures).

Performance Notes

  • First call: ~1-2s JIT compilation (Numba cache)
  • Subsequent calls: ~100× faster than CoolProp direct queries
  • Accuracy: within 0.2 % (density), 0.3 % (heat capacity) and 1 % (viscosity) of CoolProp over each fluid's data range (Fluid Properties)
  • Case-insensitive: 'water', 'Water', 'WATER' all work

Adding New Fluids

To add a CoolProp fluid not in the registry:

  1. Add to src/solarfields/core/fluid_registry.py
  2. Run uv run python scripts/generate_fluid_tables.py
  3. Commit generated coefficient files

See docs/architecture.md for details.

Operating Conditions

Define environmental conditions, all of them required; the collector model and what each value does: Collector Thermal Model.

operating_conditions:
  G_b: 700.0 W/m²          # Beam irradiance [W/m²]
  G_d: 100.0 W/m²          # Diffuse irradiance [W/m²]
  T_amb: 25.0 °C         # Ambient temperature [°C]
  u: 1.0 m/s              # Wind speed [m/s]
  E_L: 300.0 W/m²          # Long-wave irradiance [W/m²]

ISO 9806 Standard Sky Types

Sky Type G_b [W/m²] G_d [W/m²] T_amb [°C] u [m/s] E_L [W/m²]
BLUE 850 150 20 1.3 318.8
HAZY 440 260 20 1.3 368.8
GREY 0 400 20 1.3 418.8

Each sky defines its net long-wave irradiance E_L − σT_amb⁴ (−100, −50 and 0 W/m²); E_L follows from it at the ambient temperature. Values given with a sky replace the sky's values:

operating_conditions:
  sky: ISO9806Sky.BLUE
  T_amb: 25.0 °C         # The blue sky at 25 °C; E_L follows at 25 °C
from solarfields.core import ISO9806Sky

conditions = ISO9806Sky.BLUE.to_conditions()

Solver Parameters

Configure convergence behavior:

from solarfields import Q_
from solarfields.core import SolverParams

params = SolverParams(
    tol_dp=Q_(10, "Pa"),  # Pressure balance
    tol_T=Q_(10, "mK"),   # Temperature change between iterations, a temperature difference
    field_max_iter=30,   # Iteration limit of the field outer loop
    network_max_iter=50, # Safety limit of the network iteration (network core)
    string_max_iter=30,  # Iteration limit of the string temperature loop
    string_relax=0.5,    # Under-relaxation of the string temperature loop
    riser_max_iter=50,   # Safety limit of the riser flow distribution
    riser_temperatures=True,  # Thermal model per riser; False: one per collector, faster
    allow_fluid_extrapolation=True,  # Fluid properties beyond their data; False: stop at the data limits
)

In a config file, the same keys go in the solver: section, e.g. riser_temperatures: false. With the thermal model per riser, every collector with parallel risers reports the outlet temperature of each riser (T_out_risers), and the temperature limits are checked at its hottest riser; switched off, a calculation runs faster, e.g. for the iterations of an optimization, followed by a final run with it on (Thermal Model per Riser). Off, the limits are checked at the collectors' mixed outlets, up to 15 K below the hottest riser at low flows, and a riser that nearly stagnates goes unnoticed: a result that counts needs the run with it on.

Within the limits of a calculation, freezing and boiling, a result can lie beyond the fluid's data, e.g. a glycol mixture above 100 °C, where the property formulas extrapolate. The result then lists every such place in beyond_fluid_data, with its temperature and the data limit; with allow_fluid_extrapolation: false a solve stops there with TemperatureOutOfRangeError instead (Beyond the Data).

Visualization

The plotting module provides visualization tools organized by scope:

  • String-level plots — solarfields.plotting.strings
  • Field-level plots — solarfields.plotting.field
  • Composite views — solarfields.plotting.composite
  • Common utilities — solarfields.plotting.common

Every figure shows the values of the result: each collector's own riser and header diameters, and each riser's velocity and Reynolds number as the solver computed them, with the fluid properties at the riser's temperature (v_risers, Re_risers of a collector's result). The single-collector figure (plot_single_collector_hydraulics) shows each riser's outlet temperature against the collector's mixed outlet, which needs the thermal model per riser.

The velocity plots mark the highest velocity in each kind of pipe, from VelocityLimits: 1.5 m/s in a collector's risers, 1.8 m/s in its headers, 2 m/s in a field's headers and in the pipes between collectors. These are engineering estimates, not physical limits: each is adjustable within 1–2.5 m/s, e.g. VelocityLimits(riser_max=Q_(1.2, "m/s")), given to a velocity plot as velocity_limits. A velocity has no lower limit. In a collector's risers, the only pipes where some turbulence is wanted, a low velocity gives a low Reynolds number: the velocity figure flags the risers with "CHECK" where their lowest Reynolds number lies below riser_Re_min of VelocityLimits, 2300 unless given (below it, the flow is laminar), adjustable within 0–4000 (0: no flag). The Reynolds plots mark 2300 and 4000.

Where a string or field delivers no heat, the plots say "The collector field does not deliver any heat at these conditions." instead of showing heat outputs and efficiencies: such conditions lie outside the operation that solarfields designs for.

See the API Reference for details.