Getting Started¶
Installation¶
SolarFields isn't on PyPI yet; install it from source (requires Python 3.13 or 3.14 and uv):
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_subfieldsbuilds 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
ValueErrorthat 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.HAZYforiso9806: hazy. - Every dimensional value is given with its unit, e.g.
D_inner: 32 mm,V_dot: 18 m³/horT_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 raisesConfigUnitError, 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 (
nullleaves 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:
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.
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
waterand the thermal oils, which reject one - Unit: percent, with the basis in config files:
40 % by massfor MEG and MPG,40 % by volumefor 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:
- Add to
src/solarfields/core/fluid_registry.py - Run
uv run python scripts/generate_fluid_tables.py - 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
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.