Architecture & Tool Design¶
Overview¶
This page describes the hierarchical structure chosen for collector fields and its components, and the result structure for the SolarFields tool. The design supports topologies ranging from a single collector or collector string to complex fields with several sub-fields with arbitrary tree branching.
Unit Conventions¶
For comprehensive documentation of all unit conventions used throughout solar_fields, see src/solarfields/core/units.py. This module documents standard units for flow rates, temperatures, pressures, geometry, irradiance, heat, and fluid properties.
Design principles:
- The main components are called Field, Sub-field, and String (see Terminology for the names used before).
- Every level has exactly 2 connections, inlet and outlet.
- All levels support pipe friction, minor losses (K values) and pipe heat losses
- A Collector component exists which can have 4 connections. Collectors are connected to form strings, which always have 2 connections.
- Results mirror the component hierarchy (result classes defined; see Result Hierarchy for the current state).
Features that have been considered but are not implemented are collected under Potential Future Extensions.
The packages, modules and main call paths of the code are mapped in the Code Map.
Summary¶
This design provides:
- Clear hierarchy: Field → Sub-field → String → Collector
- Uniform interface: Field, Sub-field, and String levels all have 2 connections (in/out)
- Topology patterns: at field level, sub-fields connected in parallel (as a tree) or in series, as in the plants in Taars, Denmark, and Friesach, Austria; at sub-field level, parallel strings in direct or reverse return; at string level, collectors in series. Other topologies can be defined with the generic graph format (nodes and edges).
- Hydraulics and heat losses: pipe friction, minor losses (bends, valves, etc.); collector heat gain and losses by the collector model of ISO 9806:2025, Formula (A.1) (Collector Thermal Model); heat losses of all pipes, within strings and in the field network, to the ambient air or to the ground
- Mirrored results: Result classes match the component structure: field, sub-fields, strings, collectors, pipes
- Solvers: Our string solver, combined with our own network solver for every field (
FieldSolver); a pandapipes-based solver (PandapipesSolver) as reference, which needs the optional dependency pandapipes - Operating conditions: one set of conditions for the whole field, a deliberate restriction (see Limitations)
- Extensibility: see Potential Future Extensions
What a Calculation Gives¶
A calculation gives the steady state of a field at one operating point; thermal capacities and transients aren't modeled.
Inputs: the field (layout, collectors, pipes, valves), the fluid and its concentration, the field inlet temperature and flow (absolute, or per m² of collector area), and one set of operating conditions for the whole field: beam and diffuse irradiance on the collector plane, ambient temperature, wind speed and long-wave irradiance (see Operating Conditions).
Results (see Result Hierarchy):
- the flow of every string, pipe and valve, and of every riser of every collector;
- the pressure and temperature of every node of the field network, and the inlet and outlet temperatures of every pipe, collector and string;
- the pressure drop of every element, with pipe friction and minor losses separately; for each sub-field, its pressure drop split into headers, T-junctions, connection pipes, string valves and strings, which add up to its total;
- the heat gain of every collector and string, the heat loss of every pipe, and the heat delivered at the field outlet;
- the velocity and Reynolds number of every pipe, and the uniformity of the flow among the strings: each string's flow against its share by collector area, and their coefficient of variation (Uniformity of the Flow).
The field is solved as a whole: the temperature rise along each string changes the viscosity and density of the fluid, which act back on the flow distribution. What isn't modeled is listed under Limitations.
Component Hierarchy¶
Field (tree branching into 1+ subfields)
└── Subfield (parallel strings via header pipes)
└── String (collectors or collector groups in series)
└── Collector (physical/harp or virtual/meander)
| Level | Contains | Topology | Connections |
|---|---|---|---|
| Collector | Risers/absorber (physical) or sub-collectors (virtual) | Internal | 2 (in/out) |
| String | 1+ collectors in series + pipes + valves | Series | 2 (in/out) |
| Sub-field | 1+ strings in parallel + headers + valves | Parallel | 2 (in/out) |
| Field | 1+ sub-fields + branching pipes | Tree | 2 (in/out) |
Level 1: Collector¶
The atomic heat-gaining element. Two variants exist:
Physical Collector¶
Real collectors are defined in configs/collectors.yaml. Typical harp-type flat plate collectors used in large solar thermal fields have two connections, inlet and outlet, and horizontal riser pipes.
Parameters: area A_G, n_risers, D_riser, L_riser, D_header, L_header_seg, connection, ISO 9806 thermal and optical coefficients (a1, a2, etc.).
connection sets where the inlet and outlet are (default U):
U: inlet and outlet at the same end of the headers. The path through the riser next to the connections is the shortest.Z: inlet and outlet at opposite ends of the headers (diagonal).
The hydraulic layouts are shown below:
┌─────────────────────────────────────────────────────────────────────┐
│ Harp Collector, U Connection │
│ │
│ inlet → ═╗ ╔═ → outlet │
│ ╟───────────────────────────────────────╢ │
│ ╟───────────────────────────────────────╢ │
│ ╟───────────────────────────────────────╢ │
│ inlet ╟───────────────────────────────────────╢ outlet │
│ header ╟───────────────────────────────────────╢ header │
│ ╟───────────────────────────────────────╢ │
│ ╟───────────────────────────────────────╢ │
│ ╟───────────────────────────────────────╢ │
│ ╙───────────────────────────────────────╜ │
│ riser pipes │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Harp Collector, Z Connection │
│ │
│ inlet → ═╗ │
│ ╟───────────────────────────────────────╖ │
│ ╟───────────────────────────────────────╢ │
│ ╟───────────────────────────────────────╢ │
│ inlet ╟───────────────────────────────────────╢ outlet │
│ header ╟───────────────────────────────────────╢ header │
│ ╟───────────────────────────────────────╢ │
│ ╟───────────────────────────────────────╢ │
│ ╟───────────────────────────────────────╢ │
│ ╙───────────────────────────────────────╢ │
│ ╚═ → outlet │
│ riser pipes │
│ │
└─────────────────────────────────────────────────────────────────────┘
Other connections (e.g. connectors on the sides of the collector instead of at the corners) and other flow schemes (e.g. serial bundles of parallel tubes) aren't modeled. The hydraulic designation code of the Solar Keymark Scheme Rules (Annex P5.4) describes the flow scheme and the connector positions of a collector: with its corner numbering, U uses two corners at the same end of the headers and Z two diagonal corners; collectors with connectors at all four corners can be connected either way.
Meander Collector¶
Two variants exist (collector_type in configs/collectors.yaml):
meander: a single serpentine absorber pipe, 2 connections (one flow path, no headers)meander-4conn: several serpentine absorber pipes in parallel between horizontal headers, 4 connections: top-left, top-right, bottom-left, bottom-right (shown below). Two of them are used;connectionsets which, as for harp collectors (the library collectors: inlet bottom-left, outlet top-right,Z)
┌───────────────────────────────────────────────────────────────────┐
│ Typical Meander Collector │
│ │
│ conn top header (inlet) conn │
│ → ○═══════════╤═══════════╤═══════════╤═══════════╤═════○ → │
│ ┌───────┘ ┌───────┘ ┌───────┘ ┌───────┘ │
│ │ │ │ │ │
│ └───────┐ └───────┐ └───────┐ └───────┐ │
│ │ │ │ │ │
│ ┌───────┘ ┌───────┘ ┌───────┘ ┌───────┘ │
│ │ │ │ │ serpentines │
│ └───────┐ └───────┐ └───────┐ └───────┐ │
│ → ○═══════════╧═══════════╧═══════════╧═══════════╧═════○ → │
│ conn bottom header (outlet) conn │
│ │
└───────────────────────────────────────────────────────────────────┘
Virtual Collector¶
A composite element formed by connecting 2+ meander collectors via both headers:
Connected via both top and bottom headers (2 pipes between)
External connections: inlet (bottom-left), outlet (top-right): a Z connection. A virtual collector takes the connection of its base collector.
┌───────────────────────────────────────────────────────────────────┐
│ Virtual Collector │
│ (several meander collectors connected) │
│ │
│ ○─────────────────○═══○─────────────────○══ → outlet │
│ │ Meander │ │ Meander │ │
│ │ collector │ │ collector │ │
│ │ #1 │ │ #2 │ │
│ inlet → ══○─────────────────○═══○─────────────────○ │
│ │
│ │
└───────────────────────────────────────────────────────────────────┘
Use case: When internal parallel structure needs explicit modeling (e.g., string_meander_mixed example)
Definition: Constructed inline in string definition, not in separate config file.
Virtual collectors can now be defined inline in string configurations:
strings:
- name: String-Meander-Mixed
collectors:
- type: virtual
base_collector: FP-Meander-15m2 # Must be meander-4conn type
parallel_count: 10 # Number of base collectors to connect
internal_piping:
diameter: 0.039 m # Connector diameter [m]: that of the base collector's header
length: 0.5 m # Length between collectors [m]
The connectors become additional header segments of the virtual collector, with pipe friction only: they have no heat losses and no fittings. internal_piping therefore takes diameter and length only. The riser solver takes one header diameter for the whole virtual collector, so the connectors' diameter is that of the base collector's header: a different diameter is rejected (#64); it defaults to it. Connectors of their own diameter need header diameters per segment.
Result: Single collector with:
- Total risers: base.n_risers × parallel_count (e.g., 4 × 10 = 40)
- Total area: base.A_G × parallel_count (e.g., 15 × 10 = 150 m²)
- Header segments: Base segments repeated with connector segments between
Validation:
- base_collector must exist and be meander-4conn type
- parallel_count must be a whole number, at least 2
- internal_piping with its length is required; its diameter, if given, equals the base collector's D_header
When to use: - Custom inter-collector piping not covered by pre-configured collectors - Experimental configurations - Non-standard collector counts
Pre-configured alternative: For common configurations, use pre-defined virtual collectors from configs/collectors.yaml (e.g., String-Meander-10x15m2).
Collector Interface (Both Types)¶
Connections: inlet, outlet
Inputs: m_dot, T_inlet, operating_conditions
Outputs: T_outlet, dp, Q, eta
Level 2: String¶
One or more collectors (physical or virtual) connected in series.
┌──────────────────────────────────────────────────────────────────────────────┐
│ STRING │
│ │
│ inter-collector pipes │
│ (can have different D, L, K) │
│ ┌─────────┐ ┌────┐ ┌──┴──┐ ┌────┐ ┌──┴──┐ ┌────┐ ┌─────────┐ │
│ │ inlet │ │ │ │ │ │ │ │ │ │ │ │ outlet │ │
│ → ──┤ pipe ├──┤ C1 ├──┤pipe ├──┤ C2 ├──┤pipe ├──┤ C3 ├──┤ pipe ├── → │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ └────┬────┘ └────┘ └─────┘ └────┘ └─────┘ └────┘ └────┬────┘ │
│ optional valves optional valves │
│ [shut-off] [shut-off] │
│ [balancing] [balancing] │
│ │
│ 2 connections: inlet (left), outlet (right) │
│ [C#] = Collector │
└──────────────────────────────────────────────────────────────────────────────┘
String Components¶
| Component | Description |
|---|---|
| Collectors | 1+ physical or virtual collectors in series |
| Inter-collector pipes | Connect consecutive collectors. One pipe type for all, or a list (one per pipe). Heat loss from the insulation, or a given U_loss [W/(m·K)] (see Pipe Heat Loss Model). Can have minor losses (K values). |
| Inlet connecting pipe | Connects string inlet to first collector. Heat loss as above, minor losses (e.g. shut-off valve). |
| Outlet connecting pipe | Connects last collector to string outlet. Heat loss as above, minor losses (e.g. shut-off and balancing valve). |
Valves are modeled as minor losses (K values) of the inlet or outlet pipe. Pipes are referenced by name from configs/pipes.yaml or defined inline: length, D_inner and material are required; roughness (replaces that of the material), U_loss, insulation_thickness, outer_diameter and minor_losses are optional (see Required Values and Defaults). A pipe without U_loss loses the heat of its insulation in the ambient air; U_loss: 0 W/(m·K) switches its heat loss off.
String Configuration Example¶
string:
name: String-Mixed-10x15m2
collectors:
- type: FP-SG-15m2 # Physical collector from configs/collectors.yaml
count: 5
- type: FP-DG-15m2
count: 5
inter_pipes: connector # Pipe from configs/pipes.yaml (or a list, one per gap; null: no pipe)
inlet_pipe:
name: string_inlet
length: 0.5 m
D_inner: 0.032 m
material: copper # roughness from the table of pipe materials
minor_losses:
- name: inlet_tee
K: 0.5
- name: shutoff_valve
K: 0.3
outlet_pipe:
name: string_outlet
length: 0.5 m
D_inner: 0.032 m
material: copper
minor_losses:
- name: shutoff_valve
K: 0.3
- name: balancing_valve
K: 5.0 # Explicit K; automatic calculation is a potential extension
(From examples/string_harp_mixed/config.yaml.)
String Interface¶
Connections: inlet, outlet
Inputs: m_dot, T_inlet, operating_conditions
Outputs: T_outlet, dp_total, Q_total, eta_string
Per-collector: T, dp, Q, eta, flow_cv
Per-pipe: dp, Q_loss, T_in, T_out
Level 3: Sub-field¶
One or more strings connected in parallel via header pipes, in a direct return or a reverse return (topology: direct_return or reverse_return). Other layouts, e.g. with the inlet or outlet between strings, can be built with the generic graph format (network: with nodes and edges, see examples/branching/); supporting them in the sub-field format is a potential extension.
Sub-field Topology Examples¶
Standard direct return (inlet and outlet at same end):
┌───────────────────────────────────────────────────────────────────────────┐
│ SUB-FIELD (direct return) │
│ │
│ inlet │
│ ↓ │
│ ●══════════●══════════●══════════●══════════● inlet header (cold) │
│ │ │ │ │ │ │
│ [S0] [S1] [S2] [S3] [S4] │
│ │ │ │ │ │ │
│ ●══════════●══════════●══════════●══════════● outlet header (hot) │
│ ↓ │
│ outlet │
│ │
│ ● = T-junction │
│ ═══ = header pipe segment │
│ [S#] = string │
└───────────────────────────────────────────────────────────────────────────┘
Standard reverse return (inlet and outlet at opposite ends):
┌───────────────────────────────────────────────────────────────────────────┐
│ SUB-FIELD (reverse return) │
│ │
│ inlet │
│ ↓ │
│ ●══════════●══════════●══════════●══════════● inlet header (cold) │
│ │ │ │ │ │ │
│ [S0] [S1] [S2] [S3] [S4] │
│ │ │ │ │ │ │
│ ●══════════●══════════●══════════●══════════● outlet header (hot) │
│ ↓ │
│ outlet │
│ ● = T-junction │
│ ═══ = header pipe segment │
│ [S#] = string │
└───────────────────────────────────────────────────────────────────────────┘
Sub-field Components¶
| Component | Description |
|---|---|
| Strings | 1+ strings in parallel |
| Headers | Inlet and outlet header, segmented, with a diameter per segment and a common segment length. Heat losses with header_U_loss and header_surroundings (see Pipe Heat Loss Model). |
| String connections | Pipes between headers and strings (string_connection_D, string_connection_L); heat losses with connection_U_loss and connection_surroundings |
| String inlet and outlet valves | K value per string (string_inlet_valves, string_outlet_valves: one entry per string, null for a string without a valve) or one for all strings (string_inlet_valve_K, string_outlet_valve_K); none if not given |
| T-junctions | One per string in each header: the through-loss (K 0.2) on each header segment, the branch loss (K 1.0, at the header diameter) between header and string |
| Field inlet and outlet losses | K values at the sub-field inlet and outlet (field_inlet_losses, field_outlet_losses), at the header diameter next to them |
Sub-field Configuration Example¶
Field with one sub-field (solved by our own field solver, examples/field_analysis_direct_return/):
field:
name: Field-U-10strings
strings:
- type: String-10-Coll
count: 10
piping:
topology: direct_return # or reverse_return
inlet_header_D: [0.078 m, 0.078 m, 0.078 m, 0.068 m, 0.068 m, 0.053 m, 0.053 m, 0.041 m, 0.035 m]
outlet_header_D: [0.078 m, 0.078 m, 0.078 m, 0.068 m, 0.068 m, 0.053 m, 0.053 m, 0.041 m, 0.035 m]
header_segment_L: 4.0 m # same for all segments
string_connection_D: 0.035 m
string_connection_L: 2.0 m
header_material: steel # roughness from the table of pipe materials
connection_material: steel
string_outlet_valves: # one K per string
- K: 2.0
# ...
A sub-field in a field with several sub-fields (examples/multi_subfield/) uses the same keys and a name; its topology is given at sub-field level instead of in piping:
field:
subfields:
- name: Subfield-A
topology: direct_return
strings:
- type: String-10-Coll
count: 5
piping:
inlet_header_D: [0.053 m, 0.053 m, 0.041 m, 0.035 m]
outlet_header_D: [0.053 m, 0.053 m, 0.041 m, 0.035 m]
header_segment_L: 4.0 m
string_connection_D: 0.035 m
string_connection_L: 2.0 m
header_material: steel
connection_material: steel
string_outlet_valve_K: 2.0
The format with per-segment lengths, header insulation, explicit inlet/outlet connections and sub-field valves that this page showed earlier is a potential extension.
Sub-field as Network¶
load_config returns the field as a network (FieldNetwork, key network) for every format: a field with strings and piping, several sub-fields with branch pipes, or the graph format; FieldNetwork.from_subfields builds the network of several sub-fields, in a config file and in Python (Sub-fields in Python). A sub-field is described by a CollectorField and turned into nodes and edges in one place, CollectorField.add_to_network: the first inlet T-junction is the sub-field inlet, the outlet is at the first string (direct return) or at the last string (reverse return); header segments carry the through-loss of their T-junction, and each string sits between two small valve edges with the branch losses, followed by its connection pipes and valves. The nodes of a sub-field carry its name (node property subfield), which the graph export uses to group them.
Sub-field Interface¶
Connections: inlet, outlet
Inputs: m_dot_total, T_inlet, operating_conditions
Outputs: T_outlet (mixed), dp_total, Q_total (delivered), Q_loss_pipes, eta_subfield
Per-string: m_dot, T_inlet, T_outlet, dp, Q
Headers: v_segments, dp_segments, Q_loss_segments
Flow distribution: cv, min_max_ratio
Level 4: Field¶
A field consists of one or more sub-fields. Branch pipes connect the sub-fields to the field inlet and outlet, either in parallel (as a tree) or one after the other (in series); where a main pipe splits or joins, they meet at a junction. The flow never comes back to a point it has passed: branch pipes that would close such a loop are rejected.
Field with One Sub-field¶
When a field contains exactly one sub-field, the field and sub-field are effectively identical:
┌────────────────────────────────────────────────────────────────────┐
│ FIELD (one sub-field) │
│ │
│ inlet outlet │
│ ↓ ↑ │
│ ├─────────────────┤ │
│ │ │ │
│ │ Sub-field A │ │
│ │ │ │
│ └─────────────────┘ │
│ │
│ No branching needed: the sub-field is the field │
└────────────────────────────────────────────────────────────────────┘
Field with Several Sub-fields (Tree Branching)¶
┌────────────────────────────────────────────────────────────────────┐
│ FIELD (several sub-fields, tree) │
│ │
│ inlet │
│ ↓ │
│ ●─────────────────────────● │
│ │ │ │
│ ┌──────┴──────┐ ┌──────┴──────┐ │
│ │ │ │ │ │
│ │ Sub-field A │ │ Sub-field B │ │
│ │ │ │ │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ ●─────────────────────────● │
│ ↓ │
│ outlet │
│ │
│ No valves at field level (only at sub-field and string level) │
│ ● = junction │
│ ─── = branch pipe │
└────────────────────────────────────────────────────────────────────┘
Deeper Tree Branching¶
┌────────────────────────────────────────────────────────────────────┐
│ FIELD (deeper tree branching) │
│ │
│ inlet │
│ ↓ │
│ ●─────────────────────────────────────● │
│ │ │ │
│ │ ●─────────────● │
│ │ │ │ │
│ ┌──────┴──────┐ ┌──────┴────┐ ┌─────┴─────┐ │
│ │ Sub-field A │ │Sub-field B│ │Sub-field C│ │
│ │ (5 strings) │ │(3 strings)│ │(2 strings)│ │
│ └──────┬──────┘ └──────┬────┘ └─────┬─────┘ │
│ │ │ │ │
│ │ ●─────────────● │
│ │ │ │
│ ●─────────────────────────────────────● │
│ ↓ │
│ outlet │
│ │
│ Sub-field A directly off the main pipe; B and C share a branch │
│ ● = junction │
│ ─── = branch pipe │
└────────────────────────────────────────────────────────────────────┘
The points where a main pipe splits or joins (● in the diagrams) are junctions: a config names them under junctions, and its branch pipes start and end there as at the field inlet and outlet. For the tree above:
field:
name: Deeper-Tree
subfields: # A, B and C, as in the sub-field example
- name: A
# ...
junctions: [J-1, J-BC, J-BC-return, J-1-return]
branch_pipes:
# Supply: the main pipe to J-1, then A off it, and the branch to J-BC, which B and C share
- {from: inlet, to: J-1, pipe: {diameter: 0.100 m, length: 40.0 m, material: steel, surroundings: ground}}
- {from: J-1, to: A.inlet, pipe: {diameter: 0.065 m, length: 5.0 m, material: steel, K: 1.0}}
- {from: J-1, to: J-BC, pipe: {diameter: 0.080 m, length: 30.0 m, material: steel, surroundings: ground, K: 0.2}}
- {from: J-BC, to: B.inlet, pipe: {diameter: 0.050 m, length: 5.0 m, material: steel, K: 1.0}}
- {from: J-BC, to: C.inlet, pipe: {diameter: 0.050 m, length: 5.0 m, material: steel, K: 1.0}}
# Return: the same way back
- {from: A.outlet, to: J-1-return, pipe: {diameter: 0.065 m, length: 5.0 m, material: steel, K: 1.0}}
- {from: B.outlet, to: J-BC-return, pipe: {diameter: 0.050 m, length: 5.0 m, material: steel, K: 1.0}}
- {from: C.outlet, to: J-BC-return, pipe: {diameter: 0.050 m, length: 5.0 m, material: steel, K: 1.0}}
- {from: J-BC-return, to: J-1-return, pipe: {diameter: 0.080 m, length: 30.0 m, material: steel, surroundings: ground, K: 0.2}}
- {from: J-1-return, to: outlet, pipe: {diameter: 0.100 m, length: 40.0 m, material: steel, surroundings: ground}}
The pipe between J-1 and J-BC carries the flow of B and C together: its pressure drop and heat loss count for both of them, and not for A, so it shifts the flow between A and the pair B and C. Without the junctions, each sub-field would need a pipe of its own from the inlet, and a shared pipe can't be described that way: two parallel pipes of the shared pipe's diameter and length, each with half the flow, have about a quarter of its pressure drop (it grows with the flow to the power 1.75 to 2) and twice its heat loss.
A junction, like every node of a network, has no loss of its own: the loss of a T-junction is given as K of the branch pipes next to it. In the example, these are the values the headers of a sub-field take, the branch loss (1.0) on the pipes that leave the main pipe and the through loss (0.2) on the main pipe after the junction, each at the velocity in its own pipe (Minor Losses); #44 is to replace them with a model of the flow split.
Sub-fields in Series¶
In some plants, the heat transfer fluid flows through two sections of the field one after the other: the first section preheats the fluid, and the second section heats it further, often with a collector type that is better suited to the higher temperature. Two examples of such plants:
- Taars, Denmark: flat-plate collectors, followed by parabolic trough collectors;
- Friesach, Austria: single-glazed flat-plate collectors, followed by double-glazed flat-plate collectors.
In the configuration, each section is a sub-field, and a branch pipe connects the outlet of the first sub-field to the inlet of the second. The flows leaving the strings of the first sub-field mix in its outlet header. The second sub-field therefore receives the mixed outlet temperature of the first sub-field, reduced by the heat loss of the branch pipe between them, and its strings receive this temperature, reduced by the heat losses of the inlet header up to their T-junction (see Pipe Heat Loss Model).
┌────────────────────────────────────────────────────────────────────┐
│ FIELD (sub-fields in series) │
│ │
│ inlet → ┌─────────────┐ mixed ┌─────────────┐ → outlet │
│ │ Sub-field A │ ──────→ │ Sub-field B │ │
│ │ (preheat) │ │ (boost) │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ Inlet of every string of B: mixed outlet of A, minus pipe losses │
└────────────────────────────────────────────────────────────────────┘
field:
name: Sections-in-Series-12strings
subfields:
- name: Single-Glazed # first section: strings of single-glazed collectors
# strings, piping, valves as in the sub-field example
- name: Double-Glazed # second section: strings of double-glazed collectors
# ...
branch_pipes: # field inlet → first section → second section → field outlet
- from: inlet
to: Single-Glazed.inlet
pipe: {diameter: 0.080 m, length: 20.0 m, material: steel}
- from: Single-Glazed.outlet
to: Double-Glazed.inlet
pipe: {diameter: 0.080 m, length: 30.0 m, material: steel}
- from: Double-Glazed.outlet
to: outlet
pipe: {diameter: 0.080 m, length: 20.0 m, material: steel}
The example examples/subfields_in_series/ models a field of this kind, similar to the plant in Friesach: six strings of single-glazed collectors, followed by six strings of double-glazed collectors.
Collectors connected in series within one string are a different case: the fluid doesn't mix between them, so they form a single, longer string (for example the single-glazed and double-glazed collectors in examples/string_harp_mixed/). The same holds for collector rows coupled in series: they never join or split in between, so together they form one longer string.
Field Components¶
| Component | Description |
|---|---|
| Sub-fields | 1+ sub-fields |
| Branch pipes | Connect the sub-fields to the field inlet and outlet, to junctions, and sub-fields in series to each other (branch_pipes: from, to, pipe with diameter, length, material, or series and size of the Pipe Catalogue in place of diameter and material, and optionally roughness, K, fittings, U_loss, surroundings, insulation_thickness and outer_diameter). |
| Junctions | Where branch pipes split or join, e.g. at the end of a main pipe that several sub-fields share (junctions: a list of names; optional). See Deeper Tree Branching. |
Note: Bends and valves between the sub-fields are minor losses of the branch pipes: a bare K and a list of fittings, each with a K and optionally a name and D_ref (see Minor Losses). The generic graph format (examples/branching/) describes any network, without sub-fields.
A branch pipe runs in the direction of the flow: it starts at the field inlet (inlet), the outlet of a sub-field (<sub-field>.outlet) or a junction, and ends at the inlet of a sub-field (<sub-field>.inlet), the field outlet (outlet) or a junction. One drawn against the flow, or to a point inside a sub-field, is rejected (#78), and so are branch pipes that close a loop, e.g. one from a sub-field's outlet back to its inlet, which would carry its flow the other way, as a bypass of the sub-field. Every one of these points needs a branch pipe that brings its flow and one that carries it on (the field inlet only the second, the field outlet only the first), and two branch pipes between the same two points are rejected: give one, or a junction between them. Where branch pipes form a ring, e.g. a pipe between two junctions that both get flow from the inlet, the pressures decide which way the flow of the pipe between them runs: its result (FieldResult.pipes) has a negative flow where it runs against its drawing. In an edge id, points become underscores (branch_<from>_<to>): names that would give two edges the same id, e.g. junctions J_x to y and J to x_y, are rejected, naming both branch pipes. A junction's name is its own: not inlet, outlet or the inlet or outlet of a sub-field. Each error names the branch pipe by its ends, the sub-field or the junction.
Field Configuration Example¶
From examples/multi_subfield/config.yaml (sub-fields as in the sub-field example):
field:
name: Multi-Subfield-8strings
subfields:
- name: Subfield-A
# ...
- name: Subfield-B
# ...
branch_pipes:
- from: inlet
to: Subfield-A.inlet
pipe: {diameter: 0.068 m, length: 15.0 m, material: steel}
- from: inlet
to: Subfield-B.inlet
pipe: {diameter: 0.053 m, length: 25.0 m, material: steel}
- from: Subfield-A.outlet
to: outlet
pipe: {diameter: 0.068 m, length: 15.0 m, material: steel}
- from: Subfield-B.outlet
to: outlet
pipe: {diameter: 0.053 m, length: 25.0 m, material: steel}
The generic graph format (network: with nodes of type source, sink, junction and edges of type pipe, valve or string) covers arbitrary trees; see examples/branching/config.yaml. Each edge takes the properties its type reads (EDGE_PROPERTIES in components/network.py), pipes and string connections a material in place of a roughness; another key is rejected, in a config file and in the Python API (NetworkEdge). An edge connects two different nodes: one from a node to itself is rejected, naming it (#71). A string edge points in the direction of its flow, from the node its flow comes from (the inlet of its first collector) to the node it flows to: a string that gets reverse flow stops the solve, naming it, its nodes and the way to draw it, in both solvers (#71).
Such a network has the losses it gives, and no others: unlike a field described under field:, it gets no T-junction losses of its own. A T-junction of a header is given as CollectorField.add_to_network builds it, as in the example: the through loss as K of each header segment (0.2), and the branch loss as a valve edge between the header and the string (K 1.0, at the header diameter).
Sub-fields in Python¶
FieldNetwork.from_subfields builds a network of several sub-fields in the Python API, the same network load_config builds from a config file (with this method): from the sub-fields (CollectorField objects, each with a name of its own), the branch pipes (BranchPipe objects) and the names of the junctions. A BranchPipe takes the keys of a branch pipe of a config file, from_node and to_node for from and to, the dimensional values as quantities, the roughness in place of the material, and MinorLoss objects as its fittings; a size of the pipe catalogue gives its inner and outer diameter and its roughness:
from solarfields import Q_, pipe_materials, pipe_size
from solarfields.components import BranchPipe, FieldNetwork, MinorLoss, PipeSurroundings
from solarfields.core.heat_loss import BuriedPipes
steel = pipe_materials()["steel"]["roughness"]
main = pipe_size("EN 253", "DN 80")
buried = {"diameter": main.D_inner, "outer_diameter": main.D_outer, "roughness": main.roughness,
"length": Q_(30, "m"), "surroundings": PipeSurroundings.GROUND}
branch = {"diameter": Q_(65, "mm"), "length": Q_(10, "m"), "roughness": steel}
branch_pipes = [
BranchPipe("inlet", "J-in", **buried),
BranchPipe("J-in", "Subfield-A.inlet", **branch, fittings=[MinorLoss("tee_branch", K=1.0)]),
BranchPipe("J-in", "Subfield-B.inlet", **branch, fittings=[MinorLoss("tee_branch", K=1.0)]),
BranchPipe("Subfield-A.outlet", "J-out", **branch),
BranchPipe("Subfield-B.outlet", "J-out", **branch),
BranchPipe("J-out", "outlet", **buried),
]
network = FieldNetwork.from_subfields("Field", [subfield_a, subfield_b], branch_pipes,
junctions=["J-in", "J-out"],
buried_pipes=BuriedPipes(T_ground=Q_(8, "degC")))
The pipe systems of the heat loss model (buried_pipes, pipes_in_air) are given once, to the network, as in a config file: they apply to all its pipes, and each sub-field takes them. A sub-field with pipe systems of its own, other than the default, is rejected, as they would have no effect. The checks are those of a config file, and so are the ids: a branch pipe's edge is branch_<from>_<to>, with points as underscores (BranchPipe.edge_id), e.g. branch_inlet_J-in, under which FieldResult.pipes gives its result.
The network keeps what it was built from (subfields, branch_pipes, junctions), so that a network, also one of a config file, can be built again with a changed part (Changing a Value). A build takes 2–3 ms for the examples' fields of 8 and 12 strings, about 3 % of a solve: a search that changes a valve or a pipe at every step can build the network each time.
Field Interface¶
Connections: inlet, outlet
Inputs: V_dot_total_Lh, T_inlet, operating_conditions
Outputs: T_outlet (mixed), dp_total, Q_total (delivered), Q_loss_pipes, eta_field
Per string: StringResult; per pipe and valve: PipeResult (dp, T_in, T_out, Q_loss)
Per sub-field: m_dot, T_inlet, T_outlet, dp, Q, pressure breakdown, headers
Uniformity of the flow: CV of the flow per m² of collector area, per sub-field and across all
strings
Solver Architecture¶
Every solve combines our detailed string solver with a network-level solver for the flow distribution among the strings. Two field-level solvers exist (src/solarfields/solvers/):
| Solver | Module | Configurations | Network level |
|---|---|---|---|
FieldSolver |
field_solver.py |
Every field: one sub-field (field: with strings and piping), several sub-fields (field: with subfields), graph networks (network:) |
Our own: node-pressure Newton |
PandapipesSolver |
pandapipes_solver.py |
The same networks, as a reference for tests and benchmarks (examples/benchmarks/field_comparison). Same inputs as FieldSolver; valid for turbulent pipe flow only (see Friction Factor). Needs the optional dependency pandapipes (extra pandapipes, part of the development tools) and imports it on first use |
pandapipes |
FieldSolver takes a CollectorField (solved as CollectorField.to_network()) or a FieldNetwork (load_config(...)["network"] for every config format; solve_field_from_yaml loads and solves in one step). It returns a FieldResult (see Result Hierarchy). Its network solver is described under "Network solver" (Flow Distribution Solver).
Both take a CollectorField or a FieldNetwork and the fluid as name or as {name, concentration}, and return a FieldResult with the pipes, valves and nodes in the same layout, so that their results can be compared element by element.
The diagram shows FieldSolver: the string solver gives each string's pressure drop as a function of its flow, the network solver the flows, pressures and temperatures of the whole network. PandapipesSolver has the same outer loop, with pandapipes as network solver and each string as a valve whose K value gives the string's pressure drop at its current flow.
┌───────────────────────────────────────────────────────────────────────────┐
│ │
│ FieldSolver: outer loop │
│ │
│ ┌──────────────────────┐ ┌───────────────────────────┐ │
│ │ String solver │ Δp(ṁ) per string │ Network solver │ │
│ │ │ ──────────────→ │ (node-pressure Newton) │ │
│ │ • Riser flows │ │ • Flows of all edges │ │
│ │ • ISO 9806 thermal │ │ • Pressures of all nodes │ │
│ │ model │ ṁ and T_in per │ • Temperatures along the │ │
│ │ • T-dependent fluid │ string │ flow, pipe heat losses │ │
│ │ properties │ ←────────────── │ │ │
│ └──────────────────────┘ └───────────────────────────┘ │
│ │
│ Converged when every string's Δp matches the network within tol_dp │
│ and no temperature changes by more than tol_T. │
│ │
└───────────────────────────────────────────────────────────────────────────┘
Solver Responsibilities¶
| Level | Solver | Responsibilities |
|---|---|---|
| String | Our own (dominant-branch) | Collector physics (ISO 9806 thermal model), riser flow distribution, temperature-dependent fluid properties, computes equivalent K-factor |
| Sub-field | Our own (FieldSolver); pandapipes (PandapipesSolver) as reference |
Parallel flow distribution across strings, header pressure drops; FieldSolver uses string characteristics Δp(ṁ), pandapipes string K-factors as "valve" elements |
| Field | Our own (FieldSolver); pandapipes (PandapipesSolver) as reference |
Branching network flow distribution across sub-fields, branch pipe pressure drops; the same network as the sub-fields |
The pipes of the network level (headers, connection pipes, branch pipes) can lose heat to the ambient air or to the ground (see Pipe Heat Loss Model).
Hydraulics Implementation¶
Flow Distribution Solver¶
The flow distribution among the risers of a harp collector is solved with the dominant-branch method (core/dominant_branch.py): it solves all flow corrections at once, with a Jacobian that accounts for the coupling of the paths through the shared headers. The flow distribution among strings is solved by the network solver (below). The Hardy-Cross method, which corrects each path on its own and converges more slowly to the same result, was removed (#22).
Risers: the riser solver uses the exact derivative of every loss term, for pipe friction with the local slope of the Churchill friction factor, d(ln λ)/d(ln Re), so that each step is a Newton step. An estimated slope (Δp ∝ ṁⁿ with n from 1 to 1.75 depending on Re) is off by up to a factor of 3 between laminar and turbulent flow (Re ≈ 2000–4000), where the friction pressure drop rises with up to ṁ³; there the riser flows didn't converge. With the exact slope they converge in at most 4 steps from equal flows (every library collector, water and MPG 40 %, 10–90 °C, 2–200 L/(h·m²)). Each solve starts from the collector's distribution of the previous solve and stops when the path pressure drops differ by at most 1 % of tol_dp, so that the string pressure drops are accurate well within the pressure balance of the field, and by at most 0.01 % of the collector's pressure drop, so that the riser flows lie within about 0.01 % of the balance at every tol_dp (#70). With the absolute tolerance alone, a collector whose pressure drop is a few pascals counted as balanced at equal flows, without a Newton step: at tol_dp = 1000 Pa a riser's flow was off by up to 25 % and the flow spread was reported as 0 (FP-SG-2.5m2, water at 60 °C without sun, 60 L/(h·m²)), at the default by up to 0.6 % (ET-3m2, 5 L/(h·m²)). Every element has its own density and viscosity (riser_flows): the inlet header those at the collector's inlet temperature, each riser those at its mean temperature, each element of the outlet header those at the mix of the risers whose flow it carries (element_temperatures). The string solver solves the riser flows in every pass of its temperature loop, at the riser temperatures of the pass before (Thermal Model per Riser); with riser_temperatures off, once per solve, at the collector's mean temperature. The elements of a collector's riser paths are built once, when the solver is created.
Riser paths: each riser path runs from the inlet connection through the inlet header, the riser and the outlet header to the outlet connection; the header flows follow from the riser flows and the connection (U or Z, see Physical Collector). Header segments and risers have pipe friction; the serpentines of meander-4conn collectors also have their bends (n_bends_per_riser · K_bend, at the riser velocity). Every T-junction loss uses the header cross-section and the flow of the combined branch: the flow arriving at the junction in the inlet header, the flow leaving it in the outlet header. A path that turns between header and riser has K_split_branch or K_merge_branch, a path that passes straight through has K_split_through or K_merge_through. At the closed end of a header, the flow turns between header and riser with K_bend. The tests check the riser flows and the collector pressure drop against a pipe-network solve of the collector (mass balance at every junction, equal pressure drop over every riser).
Network solver: FieldSolver (solvers/field_solver.py, numerics in core/network_newton.py) solves a FieldNetwork with one inlet and one outlet by the node-pressure Newton method (global gradient algorithm, Todini & Pilati 1988, the method behind EPANET). The unknowns are the mass flow of every edge and the pressure of every node; each step solves a linear system for the node pressures, then updates the edge flows, with a line search on the residuals. It works on any connected graph and needs no start that satisfies the mass balance. A string edge becomes inlet connection pipe (with the inlet valve K), string and outlet connection pipe (with the outlet valve K); pipes have Churchill friction plus their K values, with the fluid properties at the edge's temperature. The strings enter the network through a characteristic Δp = Δp_ref · (ṁ/ṁ_ref)ⁿ, fitted to string solves (start: equal flow and +2 %). An outer loop solves the network to 1 % of tol_dp, carries the temperatures along the flow (solving every string at its flow and inlet temperature on the way, so sub-fields in series get the mixed outlet temperature of the ones before), and stops when every string's pressure drop matches the network within tol_dp and no temperature changes by more than tol_T; otherwise it moves each characteristic to the new string solve (exponent by secant, clamped to [1, 2]). The largest remaining imbalance is reported as dp_imbalance_max. A string with reverse flow or no flow stops the solve with an error that names it, its nodes and the way to draw it; so do flow directions that form a cycle. On the field benchmarks and the network examples it converges in 2 outer iterations with 4–5 Newton steps in total. An edge without any resistance (e.g. a valve with K = 0) gets a linear resistance whose pressure drop stays below 0.1 % of the network tolerance at the field flow, as otherwise the linear system is singular. The dominant-branch method stays for the risers: inside a collector, the paths are fixed and the T-junction losses depend on the flows of other elements (the combined branch), which the path formulation handles directly.
Temperatures along the flow (propagate_temperatures in core/network_newton.py, used by both field solvers): the nodes are taken in the order of the flow. A pipe or valve passes its inlet temperature on, less its heat loss (Pipe Heat Loss Model); a string gives its outlet temperature for its inlet temperature. Where flows join, the temperature is the one at which their enthalpy flows balance (mix_temperature, #71):
Each integral is taken by Simpson's rule, exact for a \(c_p\) up to a cubic in \(T\), and the root by Newton's method from the mass-weighted mean, in 2 to 4 steps. As the heat capacity rises with the temperature, the mass-weighted mean, used before #71, lies below the mix: two strings at 55.4 and 112.2 °C joined at 77.39 °C instead of 77.72 °C, and the field outlet carried 0.9 % less heat than the strings delivered. The error grows with the square of the temperature spread: 0.04 K at 20 K, below 0.01 K in a balanced field. It reached the field outlet, the outlet headers and the inlet of a sub-field in series. A string with flow is solved in every pass, however little it carries, and so are the pipes and valves that feed it, up to a node with flow: they pass their temperature on. Any other pipe or valve with less than 10⁻⁶ of the field's mass flow (a thousand times the allowed mass imbalance of a node) counts as without flow and takes its upstream temperature, so that the noise of the network solve in an edge without flow can't form a cycle. Before #71, that threshold was 10⁻⁶ of the summed flows of all edges, which grows with the size of the network: a nearly closed string of a large field was never solved, and the solve failed with a KeyError.
Friction Factor¶
For calculation of the friction factor, currently the Churchill (1977) equation is used. It is continuously differentiable (suitable for optimization) and covers all flow regimes: laminar (Re < 2300), transitional (2300 < Re < 4000), and turbulent (Re > 4000).
pandapipes doesn't offer the Churchill equation. The pandapipes reference solver (PandapipesSolver) therefore uses the Colebrook–White equation, the pandapipes friction model closest to Churchill. The Colebrook–White equation holds for turbulent flow only (Re ≥ 4000). As a consequence, the reference doesn't check pipes with laminar or transitional flow. Such flow can occur at low flow rates, or in the last segments of a header with cold, more viscous fluid. For these pipes, the pressure drops of the reference differ from those of FieldSolver, and the reference logs a warning that names the affected pipes. The default friction model of pandapipes ("nikuradse") isn't used: it adds the laminar friction factor 64/Re to the friction factor of fully rough turbulent flow, which doesn't depend on the Reynolds number.
All solvers use the pipe roughness of the config. In a config file, every pipe names its material, and its roughness follows from the table of pipe materials; a given roughness replaces it. Only the collector's tubes have a default roughness (copper). In the Python API, a pipe without roughness takes the default of its role (core/constants.py), the same values as the table's steel and copper:
| Pipes | Material (config) | Roughness given directly | Default in the Python API |
|---|---|---|---|
| Headers, connection pipes (header to string), branch pipes | header_material, connection_material, material |
header_roughness, connection_roughness, roughness |
4.5e-5 m (steel) |
| String inlet, outlet and inter-collector pipes | material of the pipe |
roughness of the pipe |
1.5e-6 m (copper) |
| Collector internals (risers, collector headers) | – | roughness in the collector geometry (default copper) |
1.5e-6 m (copper) |
Minor Losses¶
Pressure losses from fittings modeled as K values (loss coefficients), Δp = K·ρ·v²/2, with the velocity v in the element the loss belongs to:
- T-junctions (branch and through): fixed K values (branch 1.0, through 0.2) at each string's split and merge, as K values of the header segments and as branch valves of every sub-field's network, in both solvers; a network in the generic graph format has those it gives
- Valves (balancing, isolation)
- Bends and elbows
- Reducers and expansions
A K value refers to the velocity in a certain diameter. Without D_ref, this is the diameter of the element the loss belongs to (table below). A K value given for another diameter, e.g. that of a valve's data sheet for its nominal size, names this diameter as D_ref [m]. The loss is then applied with K·(D/D_ref)⁴, where D is the element's diameter: the same pressure drop at the element's velocity (Crane TP-410, Eq. 2-9).
| Minor losses | Applied at the diameter D of |
|---|---|
minor_losses of a pipe (string inlet, outlet and inter-collector pipes) |
The pipe (D_inner) |
String valves in a list (string_inlet_valves, string_outlet_valves) |
The connection pipe (string_connection_D) |
field_inlet_losses |
The first inlet header segment |
field_outlet_losses |
The outlet header segment at the field outlet: the first with direct return, the last with reverse return |
fittings of a graph edge or a branch pipe |
The edge or branch pipe (diameter) |
With one string, which has no header segments, the field inlet and outlet losses are applied at the connection pipe's diameter. A K value without a list entry of its own (string_inlet_valve_K, string_outlet_valve_K, the inlet_valve_K and outlet_valve_K of a string edge, the bare K of an edge or branch pipe) has no D_ref: it refers to the element's own diameter.
Thermal Modeling¶
Collector Thermal Model¶
Each collector follows the power output model of ISO 9806:2025, Formula (A.1), as ISO 24194:2026 gives it in its Formula (1), per collector and without the performance factor (#73). Per m² of gross area, term by term:
where \(\vartheta_m\) is the mean of the collector's inlet and outlet temperatures, \(\vartheta_a\) the ambient air temperature (\(T_a\) in kelvin), \(G_b\) and \(G_d\) the beam and diffuse irradiance on the collector plane, \(u\) the wind speed (the speed of the surrounding air), \(E_L\) the long-wave irradiance on the collector plane (wavelengths above 3 µm) and \(\sigma\) the Stefan–Boltzmann constant. Note that the a₆ term weights the irradiance with the incidence angle modifiers, as the zero-loss term does.
Collector Parameters (names and units of ISO 24194:2026)
| Parameter | Description | Unit |
|---|---|---|
| \(A_G\) | Gross area of the collector as defined in ISO 9488 | m² |
| \(\eta_{0,b}\) | Peak collector efficiency based on the beam irradiance | - |
| \(K_b\) | Incidence angle modifier for the beam irradiance, at the operating point's incidence angles \(\theta_T\) (transversal) and \(\theta_L\) (longitudinal) | - |
| \(K_d\) | Incidence angle modifier for the diffuse irradiance | - |
| \(a_1\) | Heat loss coefficient | W/(m²·K) |
| \(a_2\) | Temperature dependence of the heat loss coefficient | W/(m²·K²) |
| \(a_3\) | Wind speed dependence of the heat loss coefficient | J/(m³·K) |
| \(a_4\) | Sky temperature dependence of the heat loss coefficient | - |
| \(a_5\) | Effective thermal capacity (accepted, not used: see below) | J/(m²·K) |
| \(a_6\) | Wind speed dependence of the zero-loss efficiency | s/m |
| \(a_8\) | Radiation loss dependence | W/(m²·K⁴) |
What the calculation leaves out, and why
- The a₅ term: the calculation is steady state, so \(d\vartheta_m/dt = 0\) and the term is zero. A collector may give a₅, e.g. copied from its test report: the value is kept, changes no result, and the collector warns that it isn't used (
InputNotUsedWarning). - a₇: the model has no a₇ term. A collector that gives one is rejected (
ConfigKeyUnknownError, with the reason). - The performance factor \(f_{perf}\) of ISO 24194:2026 is the supplier's margin for checking a field's guaranteed output (soiling, pipe heat losses, uncertainties). solarfields computes the pipe heat losses and the flow distribution itself and applies no such factor: the result is the model's output.
- The simplified Formula (2) of ISO 24194:2026, with hemispherical irradiance, \(\eta_{0,hem}\) and \(K_{hem}\), isn't an input: give the collector's \(\eta_{0,b}\), \(K_b\) and \(K_d\) of its test report. Without a₆, Formula (2) is Formula (1) with \(\eta_{0,b} = \eta_{0,hem}\) and \(K_b = K_d = K_{hem}\).
- The incidence angles aren't computed: \(K_b\) is given as its value at the operating point.
Check. The worked example of ISO 24194:2026, 6.8 (a flat plate collector field: \(\eta_{0,hem}\) = 0.80, \(K_{hem}\) = 0.99, 850 W/m², a₁ = 3.0 W/(m²·K), a₂ = 0.01 W/(m²·K²), \(\vartheta_m - \vartheta_a\) = 45 K) gives 517.95 W/m² in steady state; with its dynamic term, −a₅·dϑ_m/dt = −2.8 W/m², the standard's 515.15 W/m², and with 13 200 m² and \(f_{perf}\) = 0.88, its 5.98 MW. The tests also check each term alone and all together: the solver's outlet temperature balances the formula, written out independently of the code (tests/unit/test_collector_model.py).
Solution Methods
- For a₈ = 0 (most collectors): the equation is quadratic in the temperature rise T_out − T_in and solved analytically. Of its two roots, the solve takes the stable one, where the heat the fluid takes up rises faster with the outlet temperature than the collector's net gain. It takes it in a form without cancellation (#70): the usual (−b + √(b² − 4ac))/(2a) loses its digits when 4ac is small against b², e.g. for a tiny a₂ (10⁻¹⁴ W/(m²·K²) moved the outlet by 1.4 K); −2c/(b + √(b² − 4ac)) keeps them and gives the linear solution at a₂ = 0.
- For a₈ ≠ 0: Newton iteration from the solution without a₈, until the energy balance holds within 0.001 K times ṁ·c_p, in at most 20 steps; it must end at the stable root.
- No solution: far outside a collector's operating range, the equation has no root: with an inlet far below the ambient temperature, very little flow and a large a₂ or a₄, no outlet temperature balances the collector's heat gain with the heat the fluid takes up (in the ISO 9806 form, the a₂ loss grows with the square of the distance to the ambient temperature, also below it). The solve then raises
CollectorEquationError(#70), which names the collector, the riser, and the inputs of the equation (inlet temperature, mass flow, irradiance, ambient temperature, as attributes too); so does an a₈ iteration that doesn't converge to the stable root. A result there would be invalid.
Thermal Model per Riser¶
A collector with parallel risers (harp, meander-4conn, virtual collector) has one thermal model per riser strip (#58): its share of the gross area, A_G/n, its own flow, and the ISO 9806 equation with the efficiency at its own mean temperature (Eismann 2017, Eq. 2.105). In a meander-4conn or virtual collector, a riser strip is one serpentine. The collector's outlet is the mix of the riser outlets, and its heat gain the sum over its risers:
At equal flows, every strip has the outlet temperature of the whole collector: the model is then exactly the one for the whole collector. With uneven flows, a riser with less flow than the mean runs hotter and loses more heat, so the collector gains a little less (Eismann 2017, Eq. 2.106–2.107). The collector efficiency eta is its heat gain over its gross area and the irradiance in its plane, Q/(A_G·(G_b + G_d)); negative for a collector that loses heat.
- Coupling: the riser temperatures follow from the riser flows; the riser flows follow from the fluid properties at the riser temperatures (Flow Distribution Solver): a hotter riser has the less viscous fluid and draws more flow. Both are iterated in the string's temperature loop. Each pass takes the riser flows of the pass before, computes the riser temperatures with them, and then solves the riser flows at the temperatures of the pass before; the first pass of a solve that starts from the riser flows of the previous solve keeps them. The loop has converged when no collector or riser temperature changes by more than
tol_T. - Assumptions: the ISO 9806 coefficients belong to the whole collector at its test flow. Applied per riser strip, they assume equal absorber area per riser and uniform irradiance. The headers' own heat gain and loss is counted as the risers': the inlet header carries fluid at the inlet temperature.
- Size: in the examples, the hottest riser lies at most 0.1 K above its collector's outlet in the harp collectors and up to 1.5 K in a virtual collector of ten parallel meander collectors; the heat output differs by at most 0.02 % from the model for the whole collector. The viscosity feedback evens the flows out: without it, an estimate with equal heat per riser gave 2.2 K for the virtual collector. At low flows of a viscous fluid the flows spread more: over the library collectors (strings of 1 and 4, water and MPG 40 %, inlet 10–70 °C, 5–120 L/(h·m²), 850 + 150 W/m²), the hottest riser lies up to 15 K above its collector's outlet (FP-DG-2.5m2, MPG 40 % at 10 °C, 5 L/(h·m²)), and the heat output differs by up to 0.52 % from the model for the whole collector (FP-SG-2.5m2, the same conditions).
- Computing time: on the examples, the fields take 34–83 % longer, single strings two to five times as long, in milliseconds (100 collectors: 69 instead of 50 ms).
- Switch:
riser_temperaturesin the solver settings (Convergence Criteria), on by default. Off, every collector has one thermal model, and its riser flows are solved once per solve, at the collector's mean temperature: as fast as without risers, and the results have no riser temperatures; the temperature limits are then checked at the collectors' mixed outlets. Iterations of an optimization (e.g. valve settings) can run with it off and a final run with it on. Off, it hides how unevenly the risers run, and so stagnation: in a harp whose far risers nearly stagnate (2 collectors of 30 risers, 12 mm × 0.5 m, water at 40 °C, 1080 L/h, 950 W/m²), the heat output is 3.5 % too high with 20 mm headers, where the hottest riser reaches 79 °C at a mixed outlet of 45 °C; with 16 mm headers a riser reaches 151 °C, and the solve with it on stops at the boiling limit, while off it returns a result that looks valid. A result that counts needs the final run with it on.
Cross-check. Eismann's Example 2-5 (2017, p. 77): ten parallel meander collectors of 2.5 m², each a straight tube of 20 m and 7 mm, between headers of 2.2 m segments and 16 mm, one-sided connection; η₀ = 0.78, a₁ = 3.6 W/(m²K), a₂ = 0.01 W/(m²K²); 1000 W/m², 20 °C ambient, 40 °C inlet; Tyfocor LS, here ZLC at 45.5 % by volume, the fluid closest to its data sheet; solarfields' default T-junction loss coefficients. Eismann computed it with his own spreadsheet and gives the results as figures (2-32, 2-33): a comparison of two calculations, not a validation.
| Collector | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| Specific flow, solarfields [L/(h·m²)] | 38.7 | 36.8 | 34.8 | 32.5 | 29.9 | 27.9 | 26.8 | 26.1 | 25.8 | 25.7 |
| Specific flow, Eismann | 42 | 38 | 34 | 31 | 29 | 27 | 26 | 25 | 25 | 25 |
| Outlet temperature, solarfields [°C] | 56.4 | 57.2 | 58.1 | 59.3 | 60.9 | 62.3 | 63.1 | 63.7 | 63.9 | 64.0 |
| Outlet temperature, Eismann | 55 | 57 | 59 | 60 | 62 | 63 | 64 | 64 | 65 | 65 |
Mean deviation of the flow 14 % (Eismann 16 %); output loss from the uneven flow 0.13 % (Eismann: below 0.2 %); pressure drop 0.26 bar at Eismann's mean flow of 30.4 L/(h·m²), which he gets at 0.3 bar.
The fluids. Eismann fitted his own correlations for Tyfocor LS (2017, Eq. 6.25, 6.27, 6.29) to the manufacturer's data sheet of 2013. At 40–70 °C, the range of this example, they agree with TYFOROP's data sheet of 2020 within 0.1 % in density and 0.1 % in heat capacity, but their kinematic viscosity is 24–34 % lower at 60–70 °C (the printed density correlation has a typo: its quadratic coefficient is 16.984·10⁻⁴, not 10⁻³, as his Fig. 6-3 shows). ZLC at 45.5 % lies within 0.3 % (density), 0.5 % (heat capacity) and 7 % (viscosity) of the data sheet there. The fluid explains little of the remaining difference: with Eismann's correlations instead of ZLC, solarfields gives a mean flow deviation of 11 %, even further from his 16 %. The difference lies in the hydraulic model, most likely in the loss coefficients of the header junctions: solarfields' default constants (#44) give a lower pressure drop than his 0.3 bar.
Pipe Heat Loss Model¶
All pipes can lose heat to their surroundings: the pipes within strings (inter-collector pipes, string inlet and outlet pipes) and the pipes of the field network (header segments, connection pipes between headers and strings, branch pipes). Each pipe has a heat loss coefficient per unit length \(U_{loss}\) [W/(m·K)].
Temperature along a pipe. For a constant heat loss coefficient, the exact solution of the energy balance along the pipe gives the outlet temperature
and the heat loss \(\dot{Q}_{loss} = |\dot{m}| \cdot c_p \cdot (T_{in} - T_{out})\), with the specific heat capacity \(c_p\) at the inlet temperature (core/heat_loss.py, one implementation for all pipes). The fluid approaches the surroundings temperature \(T_s\) but never passes it, also at small flows and in long pipes. For the usual case of small losses (\(U_{loss} L \ll |\dot{m}| c_p\)), the heat loss equals \(U_{loss} \cdot L \cdot (T_{in} - T_s)\) to first order.
Where the coefficient comes from. The same rule holds for every pipe, within strings and in the field network: a pipe with a given coefficient keeps it (U_loss for the pipes within strings, branch pipes and graph edges, header_U_loss and connection_U_loss for sub-fields), a constant along the pipe and independent of the temperatures and the wind; U_loss: 0 switches its heat losses off. Otherwise, its coefficient follows from its insulation, with the model for its surroundings described below, at the pipe's inlet temperature in every pass of the solver (#74). The pipes within strings are in the ambient air: they run on the collector rows, and a pipe from a string down to buried headers is the field's connection pipe (connection_surroundings). Each pipe of the field network is either in the ambient air (surroundings: air, the default) or buried (surroundings: ground). Typically, the large pipes between the plant room and the collector field are buried, and in ground-mounted fields often also the headers between the collector rows; in roof-mounted fields, headers and connection pipes run in the air. Both models add the thermal resistances per unit length in series, following ISO 12241: first the insulation and the casing,
with the outside diameter of the carrier pipe \(d_a\) and the inner and outer diameter of the casing \(D_i = d_a + 2 t_{ins}\) and \(D_c = D_i + 2 t_{casing}\); then the transfer to the surroundings. The coefficient is \(U_{loss} = 1 / (R_{ins} + R_{casing} + R_{surroundings})\).
The outside diameter of the carrier pipe defaults to the inner diameter plus twice a wall thickness of 3 mm, close to standard steel pipes (e.g. DN 50: 60.3 × 2.9 mm). A pipe can give its own insulation_thickness and outer_diameter (branch pipes, graph edges; a pipe of the Pipe Catalogue takes the outer diameter of its size), a sub-field its header_insulation_thickness and connection_insulation_thickness. Without a given thickness, the insulation \(t_{ins}\) depends on the surroundings:
- Buried pipes have the insulation of pre-insulated bonded pipes (EN 253) in the insulation series
buried_pipes.insulation_series: 1 (the default), 2 or 3, the higher, the thicker the insulation. It is the space between the steel pipe and the inside of its jacket, taken from the jackets of the catalogue's seriesEN 253(isoplus). At the outer diameter of one of its steel pipes (DN 20 to DN 600), it is exactly that pipe's insulation, e.g. (200 − 2 × 3.2 − 114.3) / 2 = 39.65 mm for DN 100 in series 1 (jacket 200 × 3.2 mm); between two, the inner diameter of the jacket is interpolated linearly in the outer diameter of the carrier pipe. A pipe thinner than DN 20 sits in the jacket of DN 20; above DN 600, the insulation is as thick as that of DN 600. A copper or stainless steel pipe gets the jacket of a steel pipe of its outer diameter. The casing wall stayscasing_thickness: the jacket's own wall would change the coefficient by less than 0.2 %. - Pipes in the air have the minimum insulation thickness for heating pipes of the German Building Energy Act (GEG, annex 8): 20 mm up to an inner diameter of 22 mm, 30 mm up to 35 mm, equal to the inner diameter up to 100 mm, and 100 mm above.
In both models, the insulation accounts for more than 90 % of the thermal resistance of a well-insulated pipe. The heat loss coefficient is therefore most sensitive to the insulation thickness and conductivity and to the pipe diameter. Soil, burial depth, wind and surface emissivity change it by a few percent only, which is why most configurations can keep the defaults.
Buried pipes¶
Buried pipes are pre-insulated pipes (PUR foam insulation, PE casing) in homogeneous soil. The soil adds the resistance of a single pipe below a plane surface (ISO 12241, section 7.2; EN 13941, annex D):
with the depth \(h\) of the pipe axis. The surroundings temperature is the undisturbed ground temperature, constant over the year. The coefficient doesn't depend on the operating conditions.
Parameter (buried_pipes:) |
Default | Typical range | Choice of the default |
|---|---|---|---|
T_ground |
10 °C | about 8–12 °C (annual mean) | Central Europe at 0.8–1.0 m depth; constant, no seasonal variation |
soil_conductivity |
1.2 W/(m·K) | dry 0.4–1.0, typical 1.0–1.5, moist 1.5–2.0, saturated up to about 2.4 | Typical soil; the value of manufacturer heat loss calculations |
soil_cover |
0.8 m | 0.8–1.0 m | Usual soil cover above the casing |
insulation_conductivity |
0.026 W/(m·K) | 0.024–0.030 W/(m·K) | PUR foam of pre-insulated pipes |
casing_thickness |
3 mm | 3–6 mm | Thin PE casing; its resistance is small |
casing_conductivity |
0.43 W/(m·K) | PE | |
pipe_wall_thickness |
3 mm | Close to standard steel pipes DN 32–100 | |
insulation_series |
1 | 1, 2 or 3 | The thinnest standard insulation of pre-insulated pipes (EN 253), so the highest losses: series 2 loses 12–29 % less, series 3 20–43 % less |
With the other defaults, the model gives 0.170, 0.156 and 0.150 W/(m·K) for DN 32, DN 50 and DN 80 steel pipes (outside diameters 42.4, 60.3 and 88.9 mm) with a given insulation of 30, 50 and 80 mm. Soil conductivities of 0.8–2.0 W/(m·K) and soil covers of 0.8–1.0 m change these values by less than 5 %.
With the default insulation, the model gives the coefficients below. Beside them are those isoplus publishes for its pipes of insulation series 1 (catalogue, section 4.2.1): the heat loss per metre of trench of a supply and a return pipe at 80 and 40 °C in soil at 8 °C, divided by (80 − 8) K + (40 − 8) K.
| Pipe | Steel pipe [mm] | Jacket, series 1 [mm] | Insulation | \(U_{loss}\), this model | \(U_{loss}\), isoplus | Difference | Series 2, this model | Series 3, this model |
|---|---|---|---|---|---|---|---|---|
| DN 20 | 26.9 × 2.6 | 90 × 3.0 | 28.5 mm | 0.134 | 0.125 | +7 % | 0.114 | 0.105 |
| DN 32 | 42.4 × 3.2 | 110 × 3.0 | 30.8 mm | 0.168 | 0.144 | +16 % | 0.148 | 0.134 |
| DN 50 | 60.3 × 3.2 | 125 × 3.0 | 29.3 mm | 0.216 | 0.182 | +19 % | 0.188 | 0.162 |
| DN 80 | 88.9 × 3.2 | 160 × 3.0 | 32.5 mm | 0.264 | 0.219 | +20 % | 0.221 | 0.194 |
| DN 100 | 114.3 × 3.6 | 200 × 3.2 | 39.65 mm | 0.276 | 0.235 | +18 % | 0.231 | 0.201 |
| DN 150 | 168.3 × 4.0 | 250 × 3.6 | 37.2 mm | 0.383 | 0.314 | +22 % | 0.303 | 0.249 |
| DN 200 | 219.1 × 4.5 | 315 × 4.1 | 43.9 mm | 0.417 | 0.338 | +23 % | 0.320 | 0.260 |
| DN 300 | 323.9 × 5.6 | 450 × 5.2 | 57.9 mm | 0.461 | 0.397 | +16 % | 0.357 | 0.286 |
| DN 500 | 508.0 × 6.3 | 710 × 7.0 | 94.0 mm | 0.459 | 0.399 | +15 % | 0.344 | 0.276 |
All coefficients in W/(m·K). Over all 18 sizes, the model gives 7 to 23 % more than isoplus. isoplus calculates a pair of pipes, where each warms the soil around the other, and its continuously produced pipes with PUR foam of 0.024 W/(m·K); with that conductivity, the model gives between 1 % less and 15 % more. So the model errs on the side of more heat loss.
Assumptions: steady state; each pipe on its own, so the thermal interaction between a supply and a return pipe in a common trench is neglected, which slightly overestimates their combined losses; homogeneous soil at a constant temperature; no ageing of the insulation; the thermal resistance of the carrier pipe wall is neglected.
Pipes in the air¶
Pipes in the air are insulated pipes (PUR foam insulation, aluminium jacket). Heat leaves the jacket by convection and thermal radiation acting in parallel:
- Natural convection after Churchill and Chu (1975) for a horizontal cylinder, \(Nu = \left[0.60 + 0.387\,Ra^{1/6} / \left(1 + (0.559/Pr)^{9/16}\right)^{8/27}\right]^2\), always present.
- Forced convection after Churchill and Bernstein (1977) for a cylinder in crossflow, at an effective crossflow velocity of 0.8 times the wind speed
uof the operating conditions; without wind, natural convection only. The two are combined as \(h_{conv} = (h_{nat}^3 + h_{forced}^3)^{1/3}\), an engineering approximation for mixed convection. - Radiation, linearised: \(h_{rad} = \varepsilon \sigma (T_s + T_{sur})(T_s^2 + T_{sur}^2)\), with the surroundings at the ambient air temperature.
Air properties are those of dry air at atmospheric pressure, at the film temperature (viscosity and thermal conductivity by Sutherland's law). Because convection and radiation depend on the surface temperature \(T_s\) of the jacket, it is found iteratively: the heat flow through insulation and casing equals the heat flow from the jacket to the air. The difference of the two falls monotonically between the air and the fluid temperature, so regula falsi (Illinois variant) keeps the root between them and finds it in about 7 evaluations, until the two flows agree within \(10^{-12}\) of the heat flow; bisection takes over if it doesn't within 50 steps. The coefficient therefore depends on the fluid temperature, the ambient temperature and the wind speed; along a pipe, it is evaluated at the inlet temperature of the pipe.
Parameter (pipes_in_air:) |
Default | Typical range | Choice of the default |
|---|---|---|---|
insulation_conductivity |
0.026 W/(m·K) | PUR 0.024–0.030; mineral wool about 0.035–0.045 | PUR foam, consistent with the buried pipes |
casing_thickness |
0.5 mm | 0.4–1.0 mm | Aluminium jacket; its resistance is negligible |
casing_conductivity |
205 W/(m·K) | Aluminium | |
emissivity |
0.2 | bright aluminium 0.03–0.08, clean sheet 0.10–0.15, weathered 0.15–0.30, painted 0.5–0.9 | Unpainted aluminium in a typical surface condition |
wind_factor |
0.8 | Average crossflow share for an unknown orientation of the pipe to the wind | |
pipe_wall_thickness |
3 mm | As for buried pipes |
With the defaults, a fluid temperature of 60 °C and an air temperature of 20 °C, the model gives for the same DN 32, DN 50 and DN 80 pipes:
| Pipe | No wind | Wind 2 m/s | Wind 5 m/s | Emissivity 0.05 / 0.8 (no wind) |
|---|---|---|---|---|
| DN 32 | 0.163 | 0.179 | 0.181 | 0.160 / 0.172 |
| DN 50 | 0.153 | 0.163 | 0.164 | 0.152 / 0.159 |
| DN 80 | 0.150 | 0.156 | 0.157 | 0.149 / 0.154 |
All values in W/(m·K). With mineral wool (0.04 W/(m·K)) instead of PUR, the coefficients rise to 0.22–0.24 W/(m·K).
Assumptions and limitations: steady state; each pipe on its own, without shielding by neighbouring pipes or structures; the orientation of the pipe is unknown, hence the horizontal-cylinder correlation and the effective crossflow share of the wind; the surroundings radiate at the air temperature, so the additional radiation to a clear night sky is not included; solar radiation on the pipe is not included, although direct sunlight can reduce the losses or even turn them into a net heat gain; thermal bridges at supports, flanges and valves and the ageing of the insulation are not included. These are engineering estimates, not manufacturer-certified values.
Configuration¶
pipes: # pipes within strings, in the air
- name: standard_connector
length: 0.30 m
D_inner: 0.022 m
material: copper # roughness from the table of pipe materials
U_loss: 0.1 W/(m·K) # given: taken as it is; 0 = no heat loss
minor_losses: []
- name: string_inlet
length: 2.0 m
D_inner: 0.032 m
material: copper
insulation_thickness: 0.03 m # no U_loss: from the insulation (default by diameter) and pipes_in_air
buried_pipes: # optional; the defaults above for keys not given
T_ground: 10.0 °C
soil_conductivity: 1.2 W/(m·K)
insulation_series: 1 # EN 253 insulation of the buried pipes without insulation_thickness
pipes_in_air: # optional; the defaults above for keys not given
emissivity: 0.2
field:
subfields:
- name: Subfield-A
piping: # with the required piping values
header_surroundings: ground # buried headers
connection_surroundings: air # connection pipes in the air (default)
branch_pipes:
- from: inlet
to: Subfield-A.inlet
pipe: {diameter: 0.08 m, length: 120.0 m, material: steel, surroundings: ground}
In the generic graph format, pipe edges take U_loss, surroundings, insulation_thickness and outer_diameter, and string edges connection_U_loss, connection_surroundings and connection_insulation_thickness for their connection pipes.
A pipe within a string takes insulation_thickness and outer_diameter too (a size of the Pipe Catalogue gives its outer diameter). The field solvers give the network's pipes_in_air to the pipes within its strings. A string solved on its own takes it as an argument, like the fluid: StringSolver(string, fluid, pipes_in_air=config['pipes_in_air']) with the config's section, or PipesInAir() for the defaults. It is required for a string with a pipe without U_loss, so that a config's pipes_in_air can't be left out without a sign.
Inputs without effect are rejected (#74) with InputWithoutEffectError, which names the input, its value and the reason (attributes owner, field, value, reason):
- Insulation next to
U_loss. The insulation is used only for a pipe without a given coefficient. Rejected: a pipe that givesU_losstogether withinsulation_thicknessorouter_diameter(a pipe within a string, a branch pipe, a pipe edge), a string edge withconnection_U_lossandconnection_insulation_thickness, and a field's piping withheader_U_lossandheader_insulation_thickness, or withconnection_U_lossandconnection_insulation_thickness. A pipe given by a size of the pipe catalogue andU_lossdoesn't take the size's outer diameter. - Values of the pipe systems that no pipe takes.
buried_pipes.T_groundcounts if a buried pipe loses heat (a length above 0, and noU_lossof 0). The values of a pipe system's insulation model (conductivities, casing, soil, emissivity, wind factor) count if one of its pipes gives noU_loss; of them,pipe_wall_thicknessonly if such a pipe has noouter_diameter(connection pipes never have one), andinsulation_seriesonly if such a buried pipe has noinsulation_thickness. The pipes are those of the network (pipe edges, branch pipes, headers, connection pipes) and those within its strings, which are in the air.- A config file is rejected for every value given in
buried_pipesorpipes_in_airthat none of its pipes takes, also when it is the default value. With a field or network, its pipes are the network's (with the templates its strings use) and those ofstring; without one, those ofstringand of every template instrings, which a script may solve on its own. - In Python, a dataclass can't tell a value given from its default, so a value other than the default is rejected: by
FieldNetwork.from_subfields,FieldSolverandPandapipesSolverfor the network's pipe systems (FieldNetwork.validatelists them), and byStringSolverfor itspipes_in_airand the pipes of its string. A string solved within a field takes the network's values: a value that its own pipes don't take, other pipes of the network take. - A value whose effect depends on another value or on the operating point is accepted, e.g. the wind factor at an operating point without wind (
u= 0), or a casing's conductivity at a casing thickness of 0.
- A config file is rejected for every value given in
Results. Each pipe result (PipeResult) contains the inlet and outlet temperature and the heat loss. FieldResult and SubfieldResult report the heat losses of the network pipes (Q_loss_pipes); their Q_total is the heat delivered at the outlet, i.e. the heat gain of the strings minus these losses, as in StringResult. The efficiencies refer to this delivered heat.
Checks. The temperature along a pipe is checked against the exact solution, the two insulation models against worked examples for DN 32–80 (including the effect of wind and emissivity), the buried pipes with their default insulation against the coefficients isoplus publishes (table above), and the field by its energy balance (heat delivered = \(\dot{m} \cdot \Delta h\) between inlet and outlet, with the enthalpy from \(c_p\), #71). The pandapipes reference solver uses the same temperature propagation, including the pipe heat losses and the mixing where flows join, so it doesn't check them independently.
Sources. ISO 12241:2022, Thermal insulation for building equipment and industrial installations — Calculation rules. EN 13941, District heating pipes — Design and installation of pre-insulated bonded pipe systems, annex D. S. W. Churchill and H. H. S. Chu, Correlating equations for laminar and turbulent free convection from a horizontal cylinder, Int. J. Heat Mass Transfer 18 (1975) 1049–1053. S. W. Churchill and M. Bernstein, A correlating equation for forced convection from gases and liquids to a circular cylinder in crossflow, J. Heat Transfer 99 (1977) 300–306. Emissivities: ASHRAE Handbook — Fundamentals, chapter 26. Pipe system data and the jackets of the insulation series: isoplus, Product catalogue (11/2024), section 4.2, "Heat loss – steel pipes – single" (PDF).
Pressures¶
The pressure drop of every element (pipe, valve, collector, string) is a loss of total pressure; the network's node pressures are total pressures relative to the field inlet. The absolute pressures follow from one absolute static pressure, given at the inlet (p_inlet) or at the outlet (p_outlet), exactly one of the two, and from the height of the field above the point where it's given, field_elevation (#56, #26):
- Field: the absolute total pressure at the inlet is
p_inletplus the dynamic pressure ρw²/2 in the edge that carries the most flow out of the inlet; at the outlet, it'sp_outletplus the dynamic pressure in the edge that carries the most flow into it. Every node's absolute total pressure follows from its pressure relative to that point. The static pressure at each end of a pipe or valve is the total pressure of the node there minus the dynamic pressure in the pipe (core.network_newton.absolute_pressures). A string gets the static pressure at the end of its inlet connection pipe as itsp_inlet. - String: from its inlet, the static inlet pressure plus the dynamic pressure of the first element (inlet pipe, or collector); each element's pressure drop is subtracted in turn. A collector's static pressures are those in its connections: in its headers, or in its riser for a single-serpentine meander. Given
p_outlet, the inlet pressure is the outlet pressure plus the string's static pressure drop, which the solve iterates with the other pressures (below), and all its pressures are shifted to the given outlet pressure. - Results:
p_inandp_outof everyPipeResultandCollectorResult,p_inletandp_outletof everyStringResultand of theFieldResult, all absolute static pressures in Pa. - Fluid properties: water's depend on the pressure (Fluid Properties), and every fluid's are capped at its boiling temperature at the local pressure during the iterations (Temperature Limits). A string takes them at the mean pressure of each collector. Its solve iterates the pressures in rounds (#68): the temperature loop at the collectors' pressures, then the pressure drops at its temperatures, which give the pressures of the next round; the solve ends when the pressures agree within
tol_dpwith those of its round. The first round takes the given pressure for every collector, from a givenp_outletalso as the inlet pressure; a later round starts from the temperatures of the round before and often ends after one pass. A fluid whose formulas don't take the pressure (every fluid but water) needs no further round when every temperature of the round, and the ambient temperature, is more than 1 K +tol_Tbelow the boiling temperature at the lowest pressure of this round and the next: its properties are then the same at either pressures, so another round couldn't change the result (BOILING_MARGINinsolvers/string_solver.py). The passes of all rounds count againststring_max_iter; pressures that don't agree when it is reached raiseNotConvergedError(in a field, when the string's last solve of the field's last iteration didn't converge). The field's pipes take the properties at the network's last solution, starting from the given pressure.PandapipesSolvergives pandapipes properties at the given pressure, since pandapipes takes them as functions of temperature only. - What a solver keeps between solves: nothing that enters a result, so that a solver used for many solves gives the results of a new solver for each (#68). Before, a string took its collectors' pressures, and from a given
p_outletits inlet pressure, from its previous solve; a solve after one that stopped at the boiling limit gave a pressure drop 4 % too high with water and 162 % too high with MPG 40 %, as the properties were capped at the boiling temperature at the wrong pressures, and a first solve fromp_outlet1636 kPa instead of 1003 kPa. Only the riser flows of the previous solve stay, as the start of the next riser solve: each solve iterates them to its own tolerance. The components can't change after a solver is built from them (Changing a Value: Frozen Objects). - Height of the field: every pressure of the field is taken at its highest point,
field_elevation[m] above the point of the given pressure: lower than the given pressure by the hydrostatic pressure ρgΔz, about 0.1 bar per metre. That's where the hydrostatic pressure in the field is lowest; every place below it is at a higher pressure in reality, so the boiling check errs on the safe side. The density of the column between the field and that point is taken on the safe side too (column_temperature, #71): for a field above the point, at the colder of the inlet and the outlet temperature, whose denser fluid lowers the field's pressure the most; for a field below it, at the hotter, whose lighter fluid raises it the least. The connecting pipe holds the inlet's or the outlet's fluid, by the side of the given pressure. The outlet temperature comes from the solve: each pass of a field solve, and each pressure round of a string solve, takes the one of the pass or round before, the first the inlet temperature. For water at 20 and 95 °C, the densities differ by 4 %, 0.07 bar at 20 m; with the inlet's density, used before #71 for both signs, the boiling temperature in a field 20–30 m below came out 0.5–1.5 K too high. A negative value is a field below that point. Withoutfield_elevation, the given pressure is the one at the height of the field (0 m). The flows don't change: the same height everywhere changes no pressure difference. Heights within the field aren't modelled (Potential Future Extensions). - A field below the point of the given pressure: its outlet's fluid flows up to that point's height, to the plant where the expansion vessel fixes the system pressure. There its static pressure is the outlet's less ρgΔz of a column of the outlet's fluid, lower than anywhere in the field; with a pressure given at the outlet and a field that heats the fluid, it is the given pressure itself, and otherwise it lies below the pressure there in reality, as the field's pressures take the lighter fluid. The boiling check takes the outlet there as well (
places_above_field, #71); the error names it as the outlet "at the height of the given pressure". Before #71, a string 20 m below a given 3 bar returned its outlet at 142 °C: valid at the field's 4.8 bar, where water boils at 150.5 °C, while water boils at 133.5 °C at 3 bar. Where the outlet pipe doesn't rise to that height, the check is stricter than needed, by ρgΔz. - Which pressure to give: 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_outletand the height of the field's highest point above the vessel asfield_elevation. The field outlet is then at the given pressure less ρgΔz plus the pressure drop on the way from the field outlet to the vessel, which the model leaves out: the boiling check errs on the safe side. A pressure given asp_inlet, e.g. read after the pump, isn't safe the same way: the pressure drop on the way up to the field lowers the field's pressures, unless the connecting pipes are part of the network.
Example: a field 10 m above a vessel at 3 bar, with water at 60 °C, is at 2.04 bar. Water boils there at 120.8 °C instead of 133.5 °C. - Boiling: the local pressure sets the boiling temperature, the upper limit of every calculation (Temperature Limits).
Temperature Limits¶
A result is valid only while the fluid is liquid (#26). The calculation continues as long as the fluid neither boils nor freezes anywhere in the field, and stops with TemperatureOutOfRangeError where it would. Such a result 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 limits are in core/fluid_limits.py. Within them, a result can lie beyond the fluid's data, where its property formulas extrapolate: it then lists these places, or, with allow_fluid_extrapolation off, stops there (Beyond the Data, #69).
Lower limit: the freezing temperature at the concentration, also where it lies below the fluid's data (#69): APG at 60 % freezes at −51 °C, and its data end at −35 °C (Beyond the Data). The freezing temperatures come from CoolProp and are stored every 0.5 % of concentration with the fluid formulas (Fluid Properties); between, they are interpolated linearly, within 0.005 K of CoolProp's (#69; interpolated between the formulas' concentrations, 5 % apart, they were up to 0.31 K too low, on the unsafe side). CoolProp has no data for glycol mixtures below their freezing temperature. Thermal oils have no freezing temperature in CoolProp: for them, the lower end of their data is the limit (kind "lower data limit").
Upper limit: the boiling temperature at the local absolute pressure (Pressures). Above a fluid's data, e.g. glycol mixtures above 100 °C, the property formulas extrapolate up to it, at some loss of accuracy, and the result lists the places there (Beyond the Data). A pressurised glycol field above 100 °C is liquid; to stop at the data limit instead, switch allow_fluid_extrapolation off.
| Fluids | Boiling temperature at the absolute pressure p |
|---|---|
| Water | Saturation temperature, IAPWS-IF97, equation 31 (within 0.01 K of IAPWS-95) |
| Water-based fluids: the glycol mixtures MEG, MPG, AEG, APG; the glycol products Antifrogen L and N (AL, AN), Glykosol N (GKN), Pekasol L (PKL), Zitrec FC, LC, M and MC (ZFC, ZLC, ZM, ZMC); the salt brines Pekasol 2000 (PK2) and Zitrec AC (ZAC) | Water's saturation temperature, a safe lower bound: glycol and dissolved salts lower the vapour pressure, so these fluids boil above water (#59) |
| Thermal oils (DowJ, DowQ, T66, T72, TD12, TVP1) | Their vapour pressure curves from CoolProp, ln p_sat = A + B/(T + C), fitted where p_sat ≥ 0.5 bar (within 2 %). Below, a curve gives boiling temperatures too high (T72: by up to 20 K at 1 kPa), so a lower pressure is beyond the limits (#69). Above an oil's data, the curve extrapolates, like the property formulas: T72's data end at 380 °C, where it boils at 5.7 bar; at 16 bar, the curve gives 450 °C |
Manufacturers rarely publish the vapour pressure of their fluids. TYFOROP's data sheet of Tyfocor LS, a propylene glycol fluid with 55–58 % water (TYFOCOR LS, Technical Information, edition 2020, p. 4), gives it from 30 to 200 °C. At its vapour pressures, water boils 0.7 to 4.6 K below the product: water's saturation curve lies on the safe side, by a few kelvin. Raoult's law for the glycol in the product, with water as the only volatile component, would give boiling temperatures 2 to 4 K above the data sheet, on the unsafe side: the product is more volatile than an ideal mixture of propylene glycol and water.
| Tyfocor LS, data sheet | 100 °C | 110 °C | 120 °C | 130 °C | 140 °C | 150 °C | 160 °C | 180 °C | 200 °C |
|---|---|---|---|---|---|---|---|---|---|
| Vapour pressure (absolute) | 0.90 bar | 1.40 bar | 1.80 bar | 2.50 bar | 3.20 bar | 4.20 bar | 5.60 bar | 9.20 bar | 14.9 bar |
| Water's boiling temperature at this pressure | 96.7 °C | 109.3 °C | 116.9 °C | 127.4 °C | 135.7 °C | 145.4 °C | 156.2 °C | 176.3 °C | 198.0 °C |
Pressure: an absolute static pressure below 611 Pa, the triple point of water, leaves no liquid state. A given pressure that low is rejected at once (ValueError); most likely, bar was given where Pa is due. So is a field elevation that takes the pressure at the field's highest point below it. Given at the inlet, a pressure drop larger than the inlet pressure makes the pressure negative downstream: an error of its own, the inlet pressure is too low for the pressure drop. With a thermal oil, a pressure below the range of its vapour pressure curve, about 0.5 bar, is beyond the limits too (kind "vapour pressure curve"): its boiling temperature isn't known there.
Values that aren't numbers: a temperature or pressure that is NaN or infinite is beyond the limits (kind "not a number"), in the check of a result and in get_fluid_props (#69).
Where it's checked: on the converged result, at the inlet and outlet of every collector and pipe, each at its own pressure, and at the hottest and the coldest riser outlet of every collector with parallel risers, at the collector's outlet pressure; for a field below the point of the given pressure, also at its outlet at that height (Pressures). The coldest riser lies below the collector's mixed outlet where the collector loses heat: at night, at −40 °C, a riser of a 2.5 m² collector with 42 % MPG froze 0.2 K below its outlet, which didn't (#69). It's checked in StringSolver.solve and solve_mass_flow, FieldSolver.solve and PandapipesSolver.solve. The error names the place furthest beyond a limit, its temperature, the local pressure, the limit and the usual causes, and counts the other places beyond a limit. The direct-return example field at 8,000 L/h instead of 18,000 L/h, with 1.5 bar at the outlet:
String 'string_9', collector 10 (FP-SG-15m2), riser 7 outlet: 117.2 °C reaches the boiling temperature
of APG at 40 % at 1.51 bar, 111.6 °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.
95 more places exceed a limit.
During the iterations, the fluid properties beyond a limit are taken at the limit: an intermediate state can lie beyond the limits that the converged result doesn't reach, and mustn't end a valid calculation. The string solver's first pass assumes no temperature rise: each collector takes the properties at its own inlet temperature, which the pass itself gives.
Direct calls: get_fluid_props raises below the lower limit, and, given a pressure, at or above the boiling temperature there. Beyond the fluid's data, it extrapolates without a sign; only the solvers list the places beyond the data.
Limits of the check: it sees the temperatures the model computes: the inlet and outlet of every collector and pipe, and the hottest and the coldest riser of every collector with parallel risers (Thermal Model per Riser). With riser_temperatures off, it sees the collectors' mixed outlets only; the hottest riser lies up to 0.1 K above them in the harp examples, up to 1.5 K in a virtual collector of ten parallel meander collectors, and up to 15 K in the library collectors at low flows, and a riser that nearly stagnates goes unnoticed (Thermal Model per Riser). Heights within the field aren't modelled: every place is taken at the field's highest point, and for a field below the point of the given pressure its outlet at that height as well (Pressures). The inlet of such a field isn't checked at that height: it would boil there only if it entered the field hotter than the boiling temperature at the plant, a very rare case that isn't treated.
Beyond the Data¶
The fluid property formulas are fitted to CoolProp's data over a range of temperatures, the data limits of the fluid at its concentration (Fluid Properties). Between them and the limits of a calculation, the freezing and the boiling temperature (Temperature Limits), the formulas extrapolate: the results hold, but their fluid properties are less certain (#69). The data limits are in core/fluid_loader.py (data_limits); between two stored concentrations, they are the narrower range of the two, as the properties there follow from both.
| Fluids | Lower data limit, at the stored concentrations | Upper data limit |
|---|---|---|
| Water | 0.01 °C, its triple point; it freezes at 0 °C | 200 °C |
| MEG, MPG, GKN, PK2 | At most 1 K above the freezing temperature | 100 °C |
| AEG, APG | At most 1 K above the freezing temperature; −34 °C where they freeze below their data (AEG from 50 %, APG from 55 %) | 100 °C |
| AL, AN | As AEG; −39 °C (AL at 60 %, AN from 55 %) | 80 °C |
| PKL | As AEG; −48 °C at 60 % | 100 °C |
| ZFC | As AEG; −39 °C from 52.5 % | 100 °C |
| ZAC | 0 °C, though it freezes at −0.6 to −4.6 °C | 100 °C; 99 °C at 6 % |
| ZLC | As AEG; −49 °C from 60 % | 100 °C; from 55 %, 97 down to 85 °C |
| ZM | As AEG; −49 °C from 60 % | 120 °C; from 20 to 90 %, 108 to 119 °C |
| ZMC | As AEG; −49 °C from 60 % | 110 °C; from 45 %, 107 down to 98 °C |
| DowJ, DowQ, TD12 | −79, −34, −84 °C | 345, 360, 230 °C |
| T66, T72, TVP1 | 0, −10, 12 °C | 380, 380, 397 °C |
At a stored concentration, the fitted range starts at the first whole degree of CoolProp's data above the freezing temperature, up to 1 K above it. Between two stored concentrations, the data limits are the narrower range of the two, and the lower data limit lies further above the freezing temperature where that falls fast with the concentration: up to 9 K for MPG (at 59.5 %, between 55 and 60 %), 8 K for PKL, 7.5 K for MEG, 7 K for GKN, 6 K for PK2. Water at 0 °C lies 0.01 K below its data. Where CoolProp's viscosity rises again near the upper end of the data (ZAC, ZLC, ZM, ZMC), the fitted range ends below it. A thermal oil has no freezing temperature: below the lower end of its data in CoolProp, it is beyond the limits (kind "lower data limit", which no setting allows; Temperature Limits). For DowJ, DowQ and TD12, that end lies 1 K below the lower data limit, the fitted range: a place in between is listed as beyond the data.
The setting allow_fluid_extrapolation in the solver settings (Convergence Criteria):
- On, the default: the result lists every place beyond the data in
beyond_fluid_data:StringResult.beyond_fluid_data,FieldResult.beyond_fluid_datafor the whole field, and in a field result each string's own. Each entry, aBeyondDataPlace, gives the place (location), its temperature, the data limit it lies beyond (limit), the side ("above"or"below") and how far (distance, in K);results.furthestgives the place furthest beyond. The places are those of the limit check (Temperature Limits), in the order of the flow; in a field, its pipes and valves first, then the strings. The plots show it too: the summaries give the number of places, the furthest place and its data limit, and the temperature plots draw the data limit as a line where a result goes beyond it. - Off: a solve raises
TemperatureOutOfRangeErrorat the place furthest beyond the data, with kind"upper data limit"or"lower data limit"andsetting = "allow_fluid_extrapolation". A place beyond a limit of the calculation, boiling or freezing, is named first. With the hot string of 40 % MPG of the tests (eight collectors, 85 °C at 300 L/h):
String 'S', collector 8 (FP-SG-15m2), riser 8 outlet: 124.1 °C is above the highest temperature of the
data of MPG at 40 %, 100.0 °C: above it, the fluid's properties are extrapolated, and
allow_fluid_extrapolation is off. Allow the extrapolation (allow_fluid_extrapolation: true in the solver
settings; the result then lists the places beyond the data), or keep the temperatures within the data:
a higher volume flow or fewer collectors in series lower them. 26 more places exceed a limit.
The setting doesn't change the iterations, only the check of the converged result: every number of a result is the same either way.
How uncertain the properties are beyond the data (against CoolProp, IAPWS-95 for water, and a data sheet):
- Viscosity above the data, the least certain property. CoolProp's MEG and MPG at 0 % are water, with data up to 100 °C: extrapolated, their viscosity lies below water's by 8 and 5 % at 120 °C, 20 and 14 % at 150 °C, 32 and 23 % at 179 °C. From 100 to 120 °C, the kinematic viscosity of 42 to 45 % MPG falls by 25 %, that of Tyfocor LS, a propylene glycol fluid with 55–58 % water, by 34 % on its data sheet (TYFOROP, TYFOCOR LS, Technical Information, edition 2020); its density and heat capacity change as on the data sheet, within 0.2 percentage points.
- Heat capacity above the data: a liquid's rises with temperature, but the formulas of ZM, ZMC, PK2 and MEG at 60 % fall by 1 to 5 % at 150 to 180 °C.
- Thermal oils above their data: the boiling temperature, too, follows from a vapour pressure curve extrapolated above the data. T72's data end at 380 °C, where it boils at 5.7 bar; at 16 bar, the curve gives 450 °C. A result of a thermal oil above its data is uncertain in its properties and in its distance to boiling.
- Below the data: most fluids' data start at most 1 K above the freezing temperature, so the formulas extrapolate by less. The mixtures that freeze below their data have no data there to compare with: APG at 60 % freezes at −51 °C, 17 K below its data, and its viscosity, extrapolated, rises 6-fold over these 17 K. Down to −60 °C, the lowest ambient and ground temperature, the formulas keep the trends of a liquid: the viscosity and the density rise as the temperature falls, the heat capacity falls. Far below, they don't: ZM at 100 % freezes at −125 °C, 75 K below its data, and its extrapolated viscosity falls again below −112 °C.
Manufacturers rarely publish property data of their glycol fluids above 100 °C; such data would show how far the extrapolation holds.
Fluid Properties¶
Fluid properties (density ρ, dynamic viscosity μ, heat capacity c_p) come from formulas fitted to CoolProp's data (#56): its incompressible models for the glycol mixtures, brines and thermal oils, and its real-fluid model (IAPWS-95) for water. scripts/generate_fluid_tables.py fits them, and they ship in src/solarfields/data/fluid_coefficients/, so CoolProp isn't needed at run time. The formulas are loaded on first use (lazy loading) and cached.
Architecture¶
User request → fluid_loader.py (cascading cache) → fluid_properties.py (JIT evaluator)
↓
Memory cache → Shipped coefficients → Concentration interpolation
Cascading Strategy: 1. Memory cache — Fastest, the formulas are cached in RAM after first use 2. Shipped coefficients — Pre-computed YAML files (one per fluid) 3. Concentration interpolation — between two stored concentrations, density and heat capacity linearly, the viscosity as ln μ (#69)
All supported fluids ship with pre-computed coefficients for immediate use.
Supported Fluids¶
The tool supports 21 CoolProp incompressible fluids registered in src/solarfields/core/fluid_registry.py:
Important: Concentration Basis Varies by Fluid
The
concentrationparameter has different meanings depending on the fluid. MEG/MPG use mass fraction, while all other fluids use volume fraction. This matters because a figure on the wrong basis is off by up to about 3 percentage points: 40 % by volume is 40–41 % by mass for propylene glycol and 42–43 % for ethylene glycol (Concentration by Mass and by Volume).
| Fluid | Description | Concentration Basis | Range | Fitted range |
|---|---|---|---|---|
| Water | Pure water | — | — | 0–200°C |
| DowJ | Dowtherm J | — | — | -79–345°C |
| DowQ | Dowtherm Q | — | — | -34–360°C |
| T66 | Therminol 66 | — | — | 0–380°C |
| T72 | Therminol 72 | — | — | -10–380°C |
| TD12 | Therminol D12 | — | — | -84–230°C |
| TVP1 | Therminol VP-1 | — | — | 12–397°C |
| MEG | Ethylene Glycol (aqueous) | Mass % | 0–60% | -23–100°C |
| MPG | Propylene Glycol (aqueous) | Mass % | 0–60% | -20–100°C |
| AEG | ASHRAE Ethylene Glycol | Volume % | 10–60% | -24–100°C |
| APG | ASHRAE Propylene Glycol | Volume % | 10–60% | -21–100°C |
| AL | Antifrogen L (Propylene Glycol) | Volume % | 10–60% | -22–80°C |
| AN | Antifrogen N (Ethylene Glycol) | Volume % | 10–60% | -25–80°C |
| GKN | Glykosol N (Ethylene Glycol) | Volume % | 10–60% | -26–100°C |
| PK2 | Pekasol 2000 (K acetate/formate) | Volume % | 30–100% | -13–100°C |
| PKL | Pekasol L (Propylene Glycol) | Volume % | 10–60% | -21–100°C |
| ZAC | Zitrec AC (Corrosion Inhibitor) | Volume % | 6–50% | 0–100°C |
| ZFC | Zitrec FC (Propylene Glycol) | Volume % | 30–60% | -22–100°C |
| ZLC | Zitrec LC (Propylene Glycol) | Volume % | 30–70% | -20–100°C |
| ZM | Zitrec M (Ethylene Glycol) | Volume % | 0–100% | -28–108°C |
| ZMC | Zitrec MC (Ethylene Glycol) | Volume % | 30–70% | -25–110°C |
Fitted range: the range in which the formulas hold within the targets below; for the mixtures at 40 %, from the freezing temperature at that concentration. ZAC has no 40 % entry (6–50 % in steps of 5 from 6 %).
Note: Only selected CoolProp fluids from the incompressible database are supported. See fluid_registry.py for the full list and exclusion reasons.
Common Confusion — MEG vs AEG, MPG vs APG:
Both MEG and AEG are ethylene glycol, but:
- MEG uses mass-based concentration (kg glycol / kg mixture)
- AEG uses volume-based concentration (see Concentration by Mass and by Volume)
Similarly for propylene glycol:
- MPG uses mass-based concentration
- APG uses volume-based concentration
Example showing the difference:
# These give DIFFERENT properties even though both are 40% propylene glycol:
get_fluid_props(50.0, 'MPG', concentration=40) # 40% by MASS
get_fluid_props(50.0, 'APG', concentration=40) # 40% by VOLUME (40-41 % by mass)
# To check a fluid's concentration basis programmatically:
from solarfields.core.fluid_loader import get_concentration_info
info = get_concentration_info('APG')
print(info) # {'basis': 'volume', 'min': 10, 'max': 60, ...}
Concentration parameter: For variable-concentration fluids, specify concentration in the appropriate basis for that fluid (see table above). Any concentration within the fluid's range can be given; between the stored ones, the properties are interpolated (Formulas: between stored concentrations).
Concentration by Mass and by Volume¶
A config file gives a concentration on the fluid's own basis (see the table above); a figure on the other basis is converted first. The conversion needs the densities of the concentrate and of water, and the definition of the volume fraction that the figure's source uses. Two are in use:
- Volumes before mixing, the mixing recipe: \(\varphi = V_c / (V_c + V_w)\), with \(V_c\) and \(V_w\) the volumes of the concentrate and of the water before they are mixed. 40 L of propylene glycol and 60 L of water are 40 % by volume, although the mixture has a little less than 100 L.
- Volume of the mixture: \(\varphi = V_c / V_m\), with \(V_m\) the volume of the mixture. As glycol and water contract when they are mixed, this fraction is a little larger for the same mixture.
The fraction by mass is \(w = m_c / (m_c + m_w)\). With the densities \(\rho_c\) of the concentrate, \(\rho_w\) of water and \(\rho_m\) of the mixture, all at the same temperature (the one at which the source gives its volume fraction, usually 20 °C), and \(\varphi\) and \(w\) as fractions (40 % = 0.40):
Volumes before mixing: \(m_c = \rho_c V_c\) and \(m_w = \rho_w V_w\), so
Volume of the mixture: \(m_c = \rho_c V_c\) and \(m_c + m_w = \rho_m V_m\), so
Densities at 20 °C: water 998.2 kg/m³ (CoolProp), ethylene glycol 1113.5 kg/m³ and propylene glycol 1036.1 kg/m³ (ATSDR, Toxicological Profiles for Ethylene Glycol and for Propylene Glycol, Table 4-2). The mixture's density is the source's; the examples take CoolProp's (APG and AEG at the volume fraction).
| 40 % by volume, at 20 °C | Volumes before mixing | Volume of the mixture |
|---|---|---|
| Propylene glycol | \(w = \frac{0.40 \cdot 1036.1}{0.40 \cdot 1036.1 + 0.60 \cdot 998.2} = 0.409\): 40.9 % by mass | \(w = 0.40 \cdot \frac{1036.1}{1036.2} = 0.400\): 40.0 % by mass |
| Ethylene glycol | \(w = \frac{0.40 \cdot 1113.5}{0.40 \cdot 1113.5 + 0.60 \cdot 998.2} = 0.426\): 42.6 % by mass | \(w = 0.40 \cdot \frac{1113.5}{1059.7} = 0.420\): 42.0 % by mass |
The other way, 40 % by mass is 39.1 % by volume for propylene glycol and 37.4 % for ethylene glycol (volumes before mixing). A figure on the wrong basis is off by up to about 3 percentage points (ethylene glycol); the two definitions differ by about 1 percentage point at 40–50 % (1.3 for propylene glycol at 50 %).
Which definition applies is the source's: where the figure's source has a table of both, it takes precedence over the formulas. CoolProp's documentation doesn't say which definition its volume-based fluids use (ASHRAE 2001 for AEG and APG, the manufacturers' data sheets for the trade products). For propylene glycol, its data fit the volumes before mixing: APG at a volume fraction and MPG at the fraction by mass converted with that definition have freezing temperatures within 0.2 K of each other and viscosities at 20 °C within 4 % (20–50 % by volume); converted with the volume of the mixture, their freezing temperatures differ by up to 2 K. For ethylene glycol, AEG and MEG come from sources that differ by up to 3 K in the freezing temperature at the converted concentration, more than the two definitions do, so their data don't tell.
For a trade product (Antifrogen, Glykosol, Pekasol, Zitrec), the percentage counts the product as its manufacturer's data sheet does, from which CoolProp's data come; the product contains inhibitors and may contain water, so \(\rho_c\) is the product's density, and the data sheet's own table, where it has one, takes precedence.
Formulas¶
- Density and heat capacity: polynomials in T [°C] of the lowest degree that stays within 0.2 % (density) and 0.3 % (heat capacity) of CoolProp's data.
- Viscosity: a Vogel equation, ln μ = A + B/(T + C), where it stays within 1 % (the thermal oils). Otherwise a polynomial in T for ln μ inside the data, continued above the upper data limit by a Vogel equation fitted to the top 30 K of the data and joined there. Where no single polynomial of degree up to 5 holds, ln μ is a polynomial in pieces, from the top of the data down: each piece reaches as far down as one polynomial holds, and takes the value of the piece above it at their join, so the viscosity has no step (#69). Only T72 needs this: its viscosity rises from 63 mPa·s at 0 °C to 349 mPa·s at −10 °C, and its three pieces join at −5 and 55 °C.
- Fitted range: glycol mixtures and brines per concentration, from the higher of the lower end of CoolProp's data and the freezing temperature (stored as
T_freeze_C) up to the upper end of the data. The solvers take it as the fluid's data limits (Beyond the Data). Where CoolProp's viscosity rises again just below the upper limit (ZAC, ZLC, ZM and ZMC at some concentrations), the fitted range ends where it still falls. Above, the formulas' viscosity keeps falling and differs from CoolProp's by up to 26 % (ZLC 70 % at 100 °C), 11 % (ZMC) and 7 % (ZM): a liquid's viscosity falls with temperature, so CoolProp's rise isn't physical. The thermal oils over their whole data range. The fitted range is stored per concentration. - Water and pressure: water's formulas depend on the absolute pressure, f(T, p) = f₀(T) + (p − p_ref)·f₁(T) with p_ref = 1 bar, fitted over 0–200 °C and the liquid states up to 25 bar;
get_fluid_propsrequires the pressure for water. CoolProp's incompressible models don't depend on pressure (∂ρ/∂p = 0 by definition), so the other fluids' formulas don't either. For real water, the pressure changes density, heat capacity and viscosity by less than 0.4 % between 1 and 30 bar. - Beyond the fitted range, the formulas extrapolate up to the boiling and down to the freezing temperature (Temperature Limits), and the solvers list the places there (Beyond the Data); low degrees and the Vogel form keep that physical. Water, the one fluid with data beyond its old fit, shows the difference: fitted on 20–100 °C, a Vogel equation is off by +0.9 % at 200 °C, a cubic polynomial by −45 %.
- Vapour pressure of the thermal oils, for their boiling temperatures (Temperature Limits): ln p_sat = A + B/(T + C), fitted where p_sat is at least 0.5 bar, within 2 % of CoolProp.
- Between stored concentrations: the density and heat capacity are interpolated linearly, the viscosity as ln μ: it changes about exponentially with the concentration, and interpolating μ itself was up to 38 % off (ZLC 67.5 % at −48 °C, #69). Formulas are stored every 5 % and at both ends of a fluid's range; where the properties interpolated midway between two of them miss a fit target against CoolProp, the concentration midway is stored too, until they hold, down to steps of 1.25 % (22 concentrations, 12 of them for ZLC above 30 %). The freezing temperatures are stored every 0.5 %.
- Checked when loaded: a coefficient file with a missing value, a value that isn't a finite number, or a list of coefficients of the wrong length raises
CoefficientsInvalidError, which names the file, the fluid and concentration, and the item. The compiled property functions don't check array bounds: a Vogel equation of two values gave μ = 54.6 Pa·s without a sign.
Accuracy: largest deviation from CoolProp inside the fitted range, over all stored concentrations (each formula stores its own as max_error_pct), and midway between neighbouring stored concentrations, where interpolation is least accurate (from the freezing temperature at that concentration up to the narrower fitted range of the two).
| Fluids | Density | Heat capacity | Viscosity |
|---|---|---|---|
| Water (0–200 °C, 1–25 bar) | 0.13 % | 0.20 % | 0.62 % |
| Glycol mixtures and brines, at the stored concentrations | 0.20 % | 0.29 % | 0.93 % |
| Glycol mixtures and brines, midway between them | 0.19 % | 0.30 % | 0.91 % |
| Thermal oils | 0.09 % | 0.21 % | 0.85 % |
Implementation Details¶
JIT-compiled evaluation: The core evaluator get_fluid_props_generic() is Numba JIT-compiled for maximum performance. Polynomial evaluation uses Horner's method for numerical stability.
Performance: First call has ~1-2s JIT compilation overhead; subsequent calls evaluate the compiled formulas.
Case-insensitive: Fluid names are case-insensitive ('water', 'Water', 'WATER' all work identically).
Usage¶
In Python (the fluid property functions are solarfields' internal layer: plain numbers in °C, % and Pa):
from solarfields.core.fluid_loader import get_fluid_props
# Water: its properties depend on the absolute pressure [Pa]
rho, mu, cp = get_fluid_props(T_C=50.0, fluid='water', p=3e5)
# Variable-concentration fluid
rho, mu, cp = get_fluid_props(T_C=50.0, fluid='MPG', concentration=40) # 40% by MASS
rho, mu, cp = get_fluid_props(T_C=50.0, fluid='APG', concentration=40) # 40% by VOLUME
In YAML configurations:
# Simple format (pure fluids like water)
fluid: water
# Dict format (for fluids requiring concentration)
fluid:
name: MPG
concentration: 40 % by mass # MPG's basis
# Or for volume-based fluid:
fluid:
name: APG
concentration: 40 % by volume # APG's basis
Solver constructors:
from solarfields import Q_, StringSolver
from solarfields.core.heat_loss import PipesInAir
# Pure fluid
solver = StringSolver(string, fluid='water')
# Fluid with concentration, on the fluid's basis (#16)
solver = StringSolver(string, fluid='MPG', concentration=Q_(40, "%"), basis="mass")
# A string with pipes without U_loss: the pipe system of the pipes in the air (#74)
solver = StringSolver(string, fluid='water', pipes_in_air=PipesInAir())
Adding New Fluids¶
To add support for additional CoolProp fluids:
-
Register the fluid in
src/solarfields/core/fluid_registry.py: -
Generate tables and coefficients:
This queries CoolProp for the data limits and the data, fits the formulas and writes: src/solarfields/data/fluid_metadata.yaml(updated)src/solarfields/data/fluid_coefficients/YourNewFluid.yaml(created)
uv run python scripts/generate_fluid_tables.py YourNewFluid fits only that fluid and leaves the metadata file as it is.
- Commit the generated files — they ship with the package.
No code changes needed — the generic evaluator handles all fluids automatically. Only water's formulas depend on the pressure; a new pressure-dependent fluid needs a branch in generate_coefficient_file.
For non-CoolProp fluids: You can manually create coefficient YAML files following the same structure. See existing files in src/solarfields/data/fluid_coefficients/ for the format.
Validation¶
Polynomial fits are validated against CoolProp reference data during generation. The tool logs maximum errors per property. For production use, verify that: - Temperature range covers your operating conditions - Polynomial error is acceptable for your application (≤0.1% for water and glycol mixtures)
Convergence Criteria¶
FieldSolver converges when every string's pressure drop matches the network within tol_dp and no temperature changes by more than tol_T between outer iterations; the network itself is solved to 1 % of tol_dp (network_max_iter as safety limit). The largest remaining imbalance is reported as dp_imbalance_max.
The outer loop of PandapipesSolver (pandapipes) converges when the string flows no longer change:
| Criterion | Tolerance | Description |
|---|---|---|
| Flow | 1e-4 (fixed) | max(abs(m_dot_new - m_dot_old)) / m_dot_mean < 1e-4 |
In both solvers, each outer iteration solves every string at its current mass flow, so the reported string results belong to the reported string flows. The field mass flow follows from the inlet volume flow at the inlet temperature. Inside the string solver, the temperature loop stops when the collector and riser temperatures change by less than tol_T, and the riser flows when their path pressure drops differ by less than 1 % of tol_dp, at the temperatures of the pass that solves them.
Solver parameters (SolverParams, YAML section solver:):
| Parameter | Default | Meaning |
|---|---|---|
tol_dp |
10 Pa | Pressure balance; the riser paths of a collector balance to 1 % of it and to 0.01 % of the collector's pressure drop |
tol_T |
0.01 K | Temperature change between iterations |
field_max_iter |
30 | Iteration limit of the field outer loop |
network_max_iter |
50 | Safety limit of the network iteration (FieldSolver) |
string_max_iter |
30 | Iteration limit of the string temperature loop: the passes of all rounds of a solve |
string_relax |
0.5 | Under-relaxation of the collector and riser temperatures in the string loop |
riser_max_iter |
50 | Safety limit of the riser flow distribution |
riser_temperatures |
on | Thermal model per riser (Thermal Model per Riser); off: one thermal model per collector, faster |
allow_fluid_extrapolation |
on | Fluid properties beyond the fluid's data, extrapolated; the result lists the places there (Beyond the Data); off: a solve stops at the data limits with TemperatureOutOfRangeError |
The keys used before (max_outer_iter, relax_outer, hc_max_iter, hc_tol, hc_relax) raise an error that says what replaces them. The relaxation (0.5) and the flow tolerance (1e-4) of the outer loop of PandapipesSolver are fixed; FieldSolver replaces it (#38).
Non-Convergence Handling¶
A solve that doesn't converge raises NotConvergedError (#64): its result isn't a solution and is never returned. The error names the loop that didn't converge, the iteration limit it reached and, where known, its remaining change against the tolerance, and says which setting to raise; the incomplete result is attached to it (error.result) for diagnosis.
- A string converges when no collector or riser temperature changes by more than
tol_Tbetween two passes, its collectors' pressures agree withintol_dpwith those its fluid properties were taken at, or another round couldn't change the result (Pressures), and every collector's riser flows balance within the riser tolerance (1 % oftol_dpand 0.01 % of the collector's pressure drop, whichever is smaller, #70) in the last pass. The loop compares a pass with the one before, sostring_max_itermust be at least 2; a smaller value is rejected.string_max_itercounts the passes of all rounds of a solve: six harp collectors with water took 6 passes in two rounds (3 withriser_temperaturesoff), with a glycol one round, so a limit below about 10 can raiseNotConvergedErrorwhere the default of 30 converges. - A field converges when the network solve converges, every string of the last pass converges, the strings' pressure drops meet the network's within
tol_dp, and no node temperature changes by more thantol_T; the pandapipes reference takes the change of the string flows instead of the pressure balance. During the iterations, the strings are solved at intermediate flows and may not have converged yet; only the last pass counts. - Order of the checks: the error names the first loop that didn't converge: the network solve, a string (its temperatures or a collector's riser flows), the pressure balance, the temperatures. Convergence is checked before the temperature limits (Temperature Limits).
- The collector equation: where it has no solution, or its iteration with a₈ doesn't converge, a solve raises
CollectorEquationErrorat once, in the pass where it happens (Collector Thermal Model).
No result with converged = False reaches the user: the flag of a returned result is always true. The tests check this, with the mass, energy and pressure balances, on every solve they make (tests/invariants.py).
Limitations¶
Physical Phenomena not modeled:
- Transient / dynamic behavior: Steady-state only, no thermal mass / heat capacities
- Heights within the field: every pressure is taken at the field's highest point,
field_elevationabove the given pressure (Pressures); no heights per element (by design, see below) - Riser strips: one ISO 9806 model per riser strip, with equal absorber area and irradiance per riser; the headers' own heat gain and loss is counted as the risers' (Thermal Model per Riser)
- Two-phase flow: No boiling, steam, or cavitation. A calculation stops where the fluid would boil at the local pressure or freeze (Temperature Limits).
- Fluid properties: Only water's depend on the pressure; those of the glycol mixtures, brines and thermal oils on temperature and concentration only (Fluid Properties)
- Reverse flow: Assumed unidirectional flow
- Stagnation: No stagnation temperature calculation
- Partial shading: Uniform irradiance across field
- Operating conditions: One set for the whole field, no conditions per sub-field or string (by design, see below; #28)
- Incidence angle: Constant incidence angle modifiers K_b and K_d per collector, independent of the angle of incidence (see Potential Future Extensions)
- T-junctions in the field headers: Fixed loss coefficients, independent of the flow split (#44)
- Thermosiphon: Not relevant for large pumped fields
- Drainback systems
Both the single set of operating conditions and the field without heights per element are deliberate restrictions. They keep the tool simple and easy to use, while still covering most solar fields. Heights per element might be added later (Potential Future Extensions).
System Boundary¶
Included:
- Collectors, strings, sub-fields, field branching
- Headers, inter-pipes, inlet/outlet pipes
- Balancing valves (as K-factors)
Excluded:
- Pump curves (fixed flow rate input)
- Expansion vessels
- Heat exchangers
- Storage tanks
- Secondary/tertiary loops
- Control logic
Operating Conditions¶
One set of operating conditions applies to all strings of the field. Conditions per sub-field or per string are a potential extension.
Operating Conditions Parameters¶
| Parameter | Unit | Description |
|---|---|---|
G_b |
W/m² | Beam (direct) irradiance on collector plane |
G_d |
W/m² | Diffuse irradiance on collector plane |
T_amb |
°C | Ambient air temperature |
u |
m/s | Wind speed: the speed of the surrounding air, used by the a₃ and a₆ terms of the collector model |
E_L |
W/m² | Long-wave irradiance on the collector plane (wavelengths above 3 µm), used by the a₄ term of the collector model |
Every value is required, in a config file and in the Python API (OperatingConditions has no defaults), unless a sky gives it (below). The net long-wave irradiance E_L − σT_amb⁴ follows from E_L and T_amb (OperatingConditions.E_L_net); it isn't an input. It matters only for collectors with a₄ ≠ 0; no collector in configs/collectors.yaml has one.
ISO 9806 Standard Test Conditions¶
Three pre-defined conditions from ISO 9806 are available. Each defines the net long-wave irradiance E_L − σT_amb⁴; the long-wave irradiance E_L follows from it at the ambient temperature (σT_amb⁴ = 418.8 W/m² at 20 °C):
| Condition | G_b [W/m²] | G_d [W/m²] | T_amb [°C] | u [m/s] | E_L − σT_amb⁴ [W/m²] | E_L [W/m²] | Description |
|---|---|---|---|---|---|---|---|
| Blue sky | 850 | 150 | 20 | 1.3 | −100 | 318.8 | Clear sky, high beam irradiance |
| Hazy sky | 440 | 260 | 20 | 1.3 | −50 | 368.8 | Hazy conditions, moderate beam |
| Grey sky | 0 | 400 | 20 | 1.3 | 0 | 418.8 | Overcast, diffuse only |
Values given with a sky replace the sky's values. At another ambient temperature, the sky keeps its net long-wave irradiance: E_L follows the given T_amb, unless E_L is given too.
# Using pre-defined conditions
operating_conditions:
sky: ISO9806Sky.BLUE # or: ISO9806Sky.HAZY, ISO9806Sky.GREY
T_amb: 25.0 °C # optional: values that replace the sky's
# Or explicit values
operating_conditions:
G_b: 700.0 W/m²
G_d: 150.0 W/m²
T_amb: 25.0 °C
u: 2.0 m/s
E_L: 350.0 W/m²
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.
A concentration is given with its basis
The same figure by mass and by volume differs by a few percent, so a concentration names its basis: 40 % by mass or 40 % by volume. The basis has to be the fluid's own: by mass for MEG and MPG, by volume for the other mixtures (Supported Fluids). A figure on the other basis is rejected; convert it first (Concentration by Mass and by Volume).
Every dimensional value in a config file, and in the libraries it includes, is given with its unit, as engineers write it: D_inner: 32 mm, V_dot: 18 m³/h, T_inlet: 40 °C, p_inlet: 3 bar, concentration: 40 % by volume. A list gives each element with its unit, e.g. inlet_header_D: [78 mm, 68 mm]. When the file is read, pint converts each value to the unit in which solarfields takes it (core/quantities.py, #16); any unit of the same dimension may be given, e.g. km/h for a wind speed or K for a temperature. A value in another unit than the one meant would otherwise be read wrong without a sign whenever it stays within its range, e.g. a wind speed of 15 km/h as 15 m/s.
A value without its unit is rejected with ConfigUnitError, which names the file, the value's place in it, the value, and what to write; so are a quantity of another dimension (e.g. a mass flow for a volume flow), text that isn't a number with a unit, and a temperature difference in °C, which would be read as a temperature. Values without a dimension stay plain numbers: loss coefficients, counts, the peak efficiency, the incidence angle modifiers, a4, the emissivity and the wind factor, the relaxation factor and the iteration limits. The free sections (metadata, the properties of graph nodes) aren't converted, unless they share a mapping with an input through a YAML alias.
A value is a number with a decimal point, without a decimal comma and without separators between groups of digits (0.5 m, 12500 L/h), followed by its unit. Solarfields reads the number itself and gives pint only the unit, so that a comma or a space can't change the number. A decimal comma, grouped digits, a unit without a number and an exponent written m2 are rejected with an error that shows how to write them. Exponents are written m² or m^2, a temperature °C or degC (C alone is coulomb); a concentration is given in % (or ‰, ppm) with its basis, by mass or by volume (other marks, e.g. % (V/V), are rejected); a temperature difference in K (or mK, delta_degC). A unit with a factor without a dimension, e.g. %·m or pi*m, is rejected. Files are read as UTF-8; units without special characters work as well, e.g. m^2 for m² and degC for °C. Inside, solarfields computes with plain numbers, in the units of the table: the compiled code needs them.
| Unit | Keys | |
|---|---|---|
| °C | T_inlet, T_amb, T_ground |
a temperature |
| L/h | V_dot |
|
| L/(h·m²) | V_dot_sp |
|
| bar | p_inlet, p_outlet |
absolute |
| m | field_elevation, D_riser, L_riser, D_header, L_header_seg, roughness, length, D_inner, D_ref, diameter, outer_diameter, insulation_thickness, connection_D, connection_L, connection_roughness, connection_insulation_thickness, inlet_header_D, outlet_header_D, string_connection_D, string_connection_L, header_segment_L, header_roughness, header_insulation_thickness, soil_cover, casing_thickness, pipe_wall_thickness |
|
| W/m² | G_b, G_d, E_L |
|
| m/s | u |
|
| Pa | tol_dp |
|
| K | tol_T |
a temperature difference |
| % | concentration |
of the glycol or salt, with its basis: % by mass or % by volume, the fluid's own |
| m² | A_G |
|
| W/(m²·K) | a1 |
|
| W/(m²·K²) | a2 |
|
| J/(m³·K) | a3 |
|
| J/(m²·K) | a5 |
accepted, not used (steady state) |
| s/m | a6 |
|
| W/(m²·K⁴) | a8 |
|
| W/(m·K) | U_loss, connection_U_loss, header_U_loss, soil_conductivity, insulation_conductivity, casing_conductivity |
Units in the Python API¶
The Python API takes every dimensional value as a pint quantity, made with Q_, which solarfields exports (#16):
from solarfields import Q_, OperatingConditions, Pipe, StringSolver
from solarfields.core.pipe_materials import pipe_materials
pipe = Pipe(length=Q_(0.5, "m"), D_inner=Q_(32, "mm"), roughness=pipe_materials()["copper"]["roughness"])
conditions = OperatingConditions(G_b=Q_(700, "W/m^2"), G_d=Q_(100, "W/m^2"), T_amb=Q_(25, "degC"),
u=Q_(2, "m/s"), E_L=Q_(350, "W/m^2"))
solver = StringSolver(string, "APG", concentration=Q_(40, "%"), basis="volume")
result = solver.solve(Q_(40, "degC"), Q_(1.8, "m^3/h"), conditions, p_outlet=Q_(3, "bar"))
A plain number is an error
A plain number for a dimensional value, e.g. D_inner=0.032, raises InputUnitError, which names the class or function, the input, the value, and what to give: the unit it was meant in can't be known. Before, it was read in the unit the code assumed, e.g. a pressure in bar as Pa or a flow in m³/h as L/h, without a sign.
- Any unit of the same dimension is converted:
Q_(32, "mm"),Q_(18, "m^3/h"),Q_(77, "degF"). A quantity of another dimension, a unit with a factor without a dimension (%·m) and a temperature given as a difference are rejected, with the checks and messages of config files. - A temperature is written
Q_(40, "degC"): pint doesn't multiply an offset unit, so40 * ureg.degCis an error in pint. A temperature difference,tol_T, is given inK,mKordelta_degC. - Pressures are absolute, in any unit of pressure:
Q_(3, "bar"),Q_(300, "kPa"). - A concentration is a quantity in
%(or‰,ppm) with its basis,basis="mass"orbasis="volume", which has to be the fluid's own (Concentration by Mass and by Volume); or a dict,{"name": "APG", "concentration": Q_(40, "%"), "basis": "volume"}, asload_configreturns it. - A list takes a list of quantities or one quantity of an array, e.g.
inlet_header_D=Q_([80, 65], "mm"). - A quantity made with pint directly, or by another package that uses pint, is taken as well: solarfields uses pint's application registry.
- The classes' defaults need no unit.
Inside, the objects hold plain numbers in the units of the table above (lengths in m, temperatures in °C, volume flows in L/h): pipe.D_inner is 0.032. Such an attribute, and a roughness of pipe_materials, can be given to a class as it is, and dataclasses.replace takes the fields it doesn't change; a number computed from it, e.g. pipe.D_inner * 2, is rejected like any plain number. The results are plain numbers in the units their fields give (#57 will hand them out as quantities). The classes' calculation methods take quantities too (MinorLoss.K_at, Pipe.outlet_temperature, NetworkBuilder.build), and design_header_diameters gives its diameters in m, marked, so that FieldPiping takes them as they are. The functions of solarfields.core (the fluid properties: get_fluid_props, freezing_temperature, boiling_temperature and the others; friction, heat transfer and heat loss) are the internal layer that the solvers call in their iterations: they take plain numbers (temperatures in °C, concentrations in % on the fluid's basis, pressures in Pa, lengths in m).
load_config returns the inlet values (config['inlet']), the given pressure and the field elevation (config['pressure']), the fluid (config['fluid'], with its concentration's basis) and the values of sweep as quantities, which solve takes as they are. A dict given to a loader in Python, e.g. to load_pipe, is read like a config file: its values are text with their unit, e.g. "32 mm", or quantities. Data that load_yaml has read is converted already and can be given to a loader as it is; a value of it set afterwards as a plain number is rejected.
Required Values and Defaults¶
A configuration file gives every value that describes the product or the plant: a missing one raises ConfigKeyMissingError, which names the section and the missing keys. There is no made-up default for such a value, because a config without it would still run, and its results would look plausible. The other values have documented defaults, chosen so that most users can keep them.
Required Values¶
| Section | Required values |
|---|---|
| Config file | fluid; operating_conditions: a sky or all of G_b, G_d, T_amb, u, E_L; inlet: T_inlet, and V_dot or V_dot_sp; the absolute static pressure, e.g. 3 bar, at the inlet (inlet: p_inlet) or at the outlet (outlet: p_outlet), exactly one of the two; in the same section, optionally, field_elevation |
| Collector, thermal | A_G, eta0_b, K_b, K_d, a1, a2 |
| Collector, geometry | Harp and meander-4conn: collector_type, connection, n_risers, D_riser, L_riser, D_header, L_header_seg; meander-4conn also n_bends_per_riser. Single-serpentine meander (no headers, one flow path): collector_type, D_riser, L_riser |
| Virtual collector | base_collector, parallel_count, the connector length (internal_piping) |
| Pipe (library, string inlet, outlet and inter-collector pipes) | length, D_inner, material; or length, series, size (Pipe Catalogue) |
| Minor loss, fitting, string valve in a list | K |
| Piping of a field or sub-field | topology (for a sub-field at sub-field level), inlet_header_D, outlet_header_D, header_segment_L, string_connection_D, string_connection_L, header_material, connection_material |
| Branch pipe | from, to, and its pipe: diameter, length, material; or length, series, size. In the Python API (BranchPipe): from_node, to_node, diameter, length, roughness |
| Graph edge | Pipe: diameter, length, material, or length, series, size. Valve: diameter, K. String: string_template, connection_D, connection_L, connection_material |
Defaults¶
| Value | Default | Reason |
|---|---|---|
ISO 9806 coefficients a3, a4, a6, a8 |
0 | A coefficient that the collector's test report doesn't give is taken as 0, i.e. its effect is neglected. |
a5 |
none | Not used: the calculation is steady state, where the a₅ term is zero. A given value is kept, with a warning (InputNotUsedWarning). |
Loss coefficients inside a collector (hydraulics) |
K_bend 0.5; K_split_branch 1.2, K_split_through 0.2; K_merge_branch 1.3, K_merge_through 0.25 |
Typical values; manufacturers rarely publish them. They have no documented source yet: #44 is to replace the T-junction values by a model of the geometry, and #51 documents the fittings. |
| Roughness of the collector's tubes | 1.5 µm (drawn copper tubing) | Absorber tubes are drawn tubes; Crane TP-410, p. A-24. |
n_bends_per_riser of a harp |
0 | Straight risers. The field keeps the given value, None if none, and CollectorGeometry.bends_per_riser gives the number the model takes, so that replace to meander-4conn, which requires it, doesn't take the default as given. |
Diameter a K value refers to (D_ref) |
The diameter of the element the loss belongs to | See Minor Losses; a K value given for another diameter names it. |
| Pipe roughness | That of the material (Pipe Materials) | A given roughness, header_roughness or connection_roughness [m] replaces it, e.g. for a measured value. |
| Heat loss of the pipes within strings | In the air; coefficient from the insulation | As for the network's pipes in the air (#74): insulation by diameter (GEG, annex 8), outside diameter = inner diameter + 2 × pipes_in_air.pipe_wall_thickness; a given U_loss [W/(m·K)] replaces the model, 0 for no heat loss. |
| Surroundings and heat loss of the network pipes | In the air; coefficient from the insulation | See Pipe Heat Loss Model: the insulation of buried pipes is that of pre-insulated pipes (EN 253) of insulation series 1 (buried_pipes.insulation_series), of pipes in the air by diameter (GEG, annex 8); pipe systems in buried_pipes and pipes_in_air. |
| String valves | None | A valve is in the model only where the config gives one (string_inlet_valves, string_outlet_valve_K, …). |
| Connector diameter of a virtual collector | The base collector's header diameter | Collectors joined through their headers; a different diameter is rejected, as the model takes one header diameter for the whole virtual collector. |
| Solver parameters | See Convergence Criteria | Numerical settings, not plant data. |
| Counts and names | One; generated names | Not plant data. |
field_elevation |
0 m | Without it, the given pressure is the one at the height of the field. A pressure given elsewhere, e.g. the system pressure at the expansion vessel, needs the height of the field's highest point above that point: a height of 10 m lowers every pressure in the field by about 1 bar (Pressures). |
Ranges¶
Every physical input has a valid range (#64): a value outside it would give a wrong result without any sign. It is rejected where its object is built (core/input_checks.py), so config files and the Python API reject the same values, with an InputError that names the input and its range, e.g. CollectorThermal.eta0_b must be above 0 and at most 1, got 1.5 or OperatingConditions.G_b must be at least 0 and at most 1500 W/m², got -100 W/m² (Errors of Inputs). A value that isn't a finite number, e.g. text, NaN or infinity, is rejected too. The ranges are in SI units, the units the solvers compute in.
| Inputs | Range | Reason |
|---|---|---|
Beam, diffuse and long-wave irradiance G_b, G_d, E_L |
0 to 1500 W/m²; G_b + G_d at most 1500 W/m² |
Irradiance isn't negative; 1500 W/m² lies above any irradiance on Earth's surface and catches a value given with a wrong factor |
Wind speed u |
0 to 40 m/s | A speed; above 40 m/s, a value in km/h given as m/s |
Ambient and ground temperature T_amb, T_ground |
−60 to +60 °C | Every climate; a temperature given in kelvin, e.g. 298, falls outside |
Inlet temperature T_inlet |
a finite number | The fluid's limits check it further (Temperature Limits) |
Absolute pressure p_inlet, p_outlet; field_elevation |
611 Pa to 25 bar; a finite number | From the triple point of water, below which no fluid is liquid, to 25 bar, where water's property formulas end and well above the pressures of solar fields; a pressure in Pa given where bar is due, or a typo such as 50 for 5 bar, lies above it. The upper limit applies to the given pressure: the pressures inside the field can exceed it by the field's pressure drop and, for a field below the point of the given pressure, by its height, up to water's critical point, 220.64 bar, where the boiling check ends. |
Volume flow V_dot, specific volume flow V_dot_sp, mass flow |
above 0 | A field without flow has no steady state to compute |
Diameters: of risers, headers, pipes, connections and edges, the reference diameter D_ref of a minor loss |
above 0, at most 1.5 m | Dimensions of something that exists; 1.5 m lies above any pipe of a solar field or a district heating network and catches a diameter given in millimeters or centimeters |
Outside diameter of a carrier pipe (outer_diameter, d_a of the heat loss model, not the casing) |
above the inner diameter, at most 2 m | The inner diameter, at most 1.5 m, plus the pipe wall |
| Soil cover of buried pipes | above 0, at most 10 m | Catches a depth given in millimeters |
The gross area A_G, riser lengths, header segment lengths |
above 0 | Dimensions of something that exists |
| Pipe lengths, connection lengths | 0 m and more | A pipe of 0 m with a minor loss stands for a valve or a fitting alone |
| Number of risers; bends per riser | whole numbers, at least 1; at least 0 | Counts |
count of collectors or strings; parallel_count of a virtual collector |
whole numbers, at least 1; at least 2 | A count of 0 would drop the entry without a sign |
Roughness, also header_roughness, connection_roughness and that of edges |
0 and more, below half the diameter | Larger, the roughness would fill the tube; e.g. 0.045 given in m instead of mm |
Loss coefficients K (minor losses, fittings, valves, the collector's hydraulics) |
0 and more | A loss doesn't add pressure |
Heat loss coefficients U_loss |
0 and more | |
| Insulation, casing and wall thicknesses | 0 to 0.3 m | Catches a thickness given in millimeters |
| Thermal conductivities | above 0 | |
| Emissivity, the wind factor of pipes in the air | 0 to 1 | Shares |
Insulation series of buried pipes insulation_series |
1, 2 or 3 | The insulation series of pre-insulated pipes (EN 253) |
Peak efficiency eta0_b |
above 0, at most 1 | No collector converts more than the irradiance |
Incidence angle modifiers K_b, K_d |
0 to 2 | Evacuated tube collectors exceed 1 at some angles, about 1.4 at most; a percentage given by mistake falls outside |
Heat loss coefficients a1, a2, a3, and a6, a8 |
0 and more | ISO 9806 coefficients of a real collector: its losses rise with the temperature difference and the wind |
Effective thermal capacity a5 |
0 and more | A heat capacity; not used, as the calculation is steady state |
Long-wave coefficient a4 |
0 to 1 | A share of the long-wave exchange |
| Concentration of water and the thermal oils | none | They aren't mixtures; a concentration would be ignored |
| Concentration of a mixture | 0, or 1 % and more, within the range of the fluid's data, e.g. 10–60 % by volume for APG | Concentrations are in percent; a value between 0 and 1 is a fraction given by mistake, e.g. 0.4 for 40 %, which would be computed as almost pure water |
| Solver settings | tol_dp above 0, at most 1000 Pa; tol_T above 0, at most 1 K; string_relax above 0, at most 1; iteration limits whole numbers, string_max_iter at least 2, field_max_iter at least 1 |
A looser tolerance would let a loop stop on a real change: with tol_T 50 K, a field "converged" after one iteration; a string loop compares each pass with the one before |
Besides the ranges (#66): a key given twice in one section of a file is rejected (ConfigKeyDuplicateError, naming the key, the file and both lines), as a YAML reader would keep the second without a sign; a list of string valves or of inter-collector pipes has one entry per string or gap; field_elevation is a number like every other.
tests/integration/test_input_rejection.py tries an invalid value of every input and checks that the error is an InputError that names it.
Errors of Inputs¶
Every input mistake raises an InputError (#75), from a config file and from the Python API, so that a GUI catches them all with one except InputError and marks the input concerned. An InputError is a ValueError; a value of the wrong type raises an InputTypeError, which is a TypeError as well. The errors that name a particular kind of mistake are subclasses and keep their names: ConfigUnitError and InputUnitError (a value without its unit, Units in Config Files), ConfigKeyUnknownError, ConfigKeyMissingError, ConfigKeyDuplicateError, ConfigKeyRenamedError and ConfigKeyReplacedError (keys of a config file), PipeCatalogueError, InputWithoutEffectError, HeaderSizeError (design_header_diameters has no size of those given for a header segment), and the fluid's FluidNotFoundError, ConcentrationMissingError and ConcentrationNotAvailableError, which are FluidConfigurationErrors too.
The message says what is wrong, why, and what to change, e.g. FieldPiping.inlet_header_D is 78 m, above 1.5 m: lengths are given in meters, check the unit (78 mm is 0.078 m, 78 cm is 0.78 m). The attributes give the same as data:
| Attribute | Content | Example |
|---|---|---|
owner |
What the input belongs to: the class of the Python API, or the section of a config file | "FieldPiping", "Pipe 'connector'", "field 'F' piping" |
field |
The input; an entry of a list with its index; None for a section as a whole, e.g. its unknown keys |
"inlet_header_D", "inlet_header_D[1]" |
value |
The value as given, a number in unit |
78.0 |
reason |
Why the value isn't taken, and what to change; the message is the input's name followed by it | "is 78 m, above 1.5 m: lengths are given in meters, …" |
unit |
The unit of value, low and high: that in which the objects store the input (INPUT_UNITS); None for a number without a unit |
"m", "W/m²", "% by volume" |
low, high |
The range; None where it has no limit on that side |
0.0, 1.5 |
above, below |
Whether low and high themselves lie outside the range |
True, False |
choices |
The values to choose from, as a config file writes them; None where the input isn't a choice |
("air", "ground") |
path, source, line |
The input's place in a config file, the file, and the line counted from 1; None in the Python API. Given by ConfigUnitError (path and file) and ConfigKeyDuplicateError (file and line) |
"pipes[0].D_inner", "config.yaml", 12 |
The choices are those of a config file, also for an input of the Python API, which takes either: connection_surroundings="buried" gives FieldPiping.connection_surroundings must be 'air' or 'ground', got 'buried', and an unknown topology lists 'direct_return' and 'reverse_return', not the names of the Python enumeration. An unknown fluid is rejected where it is given, by load_config and by the solvers' constructors, and its error lists the fluids. A concentration outside a fluid's data gives their range and the concentration's basis, e.g. fluid.concentration is 5 %, outside the data of APG, 10–60 % by volume; any concentration within it is taken (Formulas). A dimensionless input given as a quantity, e.g. eta0_b=Q_(80, "%"), is rejected with the plain number to give, 0.8. In a config file, a number with an exponent but without a decimal point, e.g. 5e-1, is text for the YAML reader (YAML 1.1); the error says to write 5.0e-1.
The errors keep their attributes when they are pickled, e.g. when a solve in another process raises one.
Python API¶
The classes of the Python API follow the same rule: an object can't be built without the values that describe the product or the plant. A missing value raises TypeError, which names it; a required property of a network edge given as None raises an InputTypeError too. The solvers' solve and solve_mass_flow require the absolute static pressure at the inlet or the outlet (p_inlet or p_outlet), and every dimensional value is a quantity, e.g. Q_(3, "bar") (Units in the Python API).
| Class | Required values |
|---|---|
CollectorThermal |
A_G, eta0_b, K_b, K_d, a1, a2 |
CollectorGeometry |
As in a config file, by collector type (CollectorGeometry.required_values). The values that don't apply to a single-serpentine meander (connection, D_header, L_header_seg) stay None, and its results report a header velocity and a header Reynolds number of 0. |
Collector |
geometry, thermal |
Pipe |
length, D_inner, roughness |
MinorLoss |
K |
FieldPiping |
topology, inlet_header_D, outlet_header_D, header_segment_L, string_connection_D, string_connection_L, header_roughness, connection_roughness |
CollectorField, uniform_field |
piping |
OperatingConditions |
G_b, G_d, T_amb, u, E_L |
NetworkEdge |
Pipe: diameter, length, roughness. Valve: diameter, K. String: string_template, connection_D, connection_L, connection_roughness. fittings is a list; each entry: K. An edge takes only the properties its type reads (EDGE_PROPERTIES in components/network.py), and a fitting only name, K and D_ref: another key is rejected, as a misspelled one would have no effect. |
A CollectorField carries the pipe systems of the heat loss model (buried_pipes, pipes_in_air; the defaults of Pipe Heat Loss Model if not given) and gives them to its network (to_network). A FieldNetwork has its own, which apply to all its pipes; every sub-field in its subfields must have the same, as other ones would have no effect, and a solver rejects the network otherwise. A value other than the default that no pipe of the network takes is rejected too (see the configuration of the heat loss model). load_config gives the config's sections to the field, its sub-fields and the network.
Where a config file gives a material, the Python API takes the roughness, so that an object holds one value for it: as a quantity, or the table's value, which pipe_materials returns (Pipe Materials). The other defaults above apply as in a config file. configs/collectors.yaml and configs/pipes.yaml hold complete example collectors and pipes, which load_collectors and load_pipes read:
from solarfields import Q_
from solarfields.components import Pipe
from solarfields.core.pipe_materials import pipe_materials
from solarfields.io import load_collectors
collector = load_collectors("configs/collectors.yaml")["FP-SG-2.5m2"]
inlet_pipe = Pipe(length=Q_(0.5, "m"), D_inner=Q_(32, "mm"), roughness=pipe_materials()["copper"]["roughness"])
Changing a Value: Frozen Objects¶
The objects that describe a plant (CollectorGeometry, CollectorThermal, CollectorHydraulics, Collector, Pipe, MinorLoss, CollectorString, FieldPiping, CollectorField, BranchPipe, NetworkNode, NetworkEdge, FieldNetwork), the OperatingConditions and the SolverParams are frozen (#68): their values are checked once, when an object is built, and setting one afterwards raises dataclasses.FrozenInstanceError. Lists are stored as tuples, and dicts (the properties of nodes and edges, the fittings, a network's components and metadata) as read-only mappings, so that their contents can't be changed either; a list given to an object is copied. The objects can be pickled and copied, e.g. to solve in another process.
Before, a value set on an object after a solver was built from it had no effect, or half of one, without a sign: a solver takes some values when it is built (the collectors' coefficients and geometry, the compiled network) and others at each solve, and the result reported the new value. A lower η0 set on the collector of a StringSolver built before kept the old heat output; smaller header diameters set on the field of a FieldSolver built before kept the old pressure drop, while the result reported the new diameters. A value set after construction wasn't checked either: conditions.T_amb = 298.15, meant in kelvin, gave an efficiency above η0.
A changed value needs a new object, made with dataclasses.replace, which checks it as at construction (a plain number, a value out of range, a list of the wrong length are rejected), and a new solver. To solve a string again with η0 = 0.75 for each of its collectors:
from dataclasses import replace
collectors = [replace(c, thermal=replace(c.thermal, eta0_b=0.75)) for c in string.collectors]
string_075 = replace(string, collectors=collectors)
result = StringSolver(string_075, "water").solve(Q_(40, "degC"), Q_(600, "L/h"), conditions, p_inlet=Q_(3, "bar"))
A field takes new strings in the same way, e.g. replace(field, strings=[string_075] * field.n_strings) for a field of identical strings, and a new FieldSolver solves it. With another number of collectors or strings, give the lists whose length depends on it as well (inter_pipes of a string, None for none; the string valves and a list of header diameters of a field): a list of the old length is rejected.
A network is built from its parts: it holds their nodes, edges and string templates, which the solvers read, and keeps the parts as a record (subfields, and for a network of several sub-fields branch_pipes and junctions). A part changed alone, e.g. replace(network, subfields=[...]), would have no effect, so the solvers reject a network whose parts differ from their nodes, edges or templates in it. Build the network again from the changed parts: give a field to the solver (or CollectorField.to_network()), build a network of several sub-fields with FieldNetwork.from_subfields (Sub-fields in Python), or load the changed config. Stronger valves in the strings of one sub-field of a network, e.g. one of a config file:
from dataclasses import replace
a = network.get_subfield("Subfield-A")
changed = replace(a, string_outlet_valves=[MinorLoss("valve", K=20.0)] * a.n_strings)
subfields = [changed if z.name == changed.name else z for z in network.subfields]
network = FieldNetwork.from_subfields(network.name, subfields, network.branch_pipes, junctions=network.junctions,
buried_pipes=network.buried_pipes, pipes_in_air=network.pipes_in_air)
A branch pipe is changed the same way, e.g. replace(pipe, diameter=Q_(80, "mm")) in the list of branch pipes. The values that solve takes as arguments (inlet temperature, flow, pressure, operating conditions, solver settings) need no new object: a sweep over them gives them to each solve.
Pipe Materials¶
A pipe names its material (material; header_material and connection_material in the piping; connection_material of a string edge), and its roughness follows from the table src/solarfields/data/pipe_materials.yaml. The values are for new pipes: deposits, corrosion and age make walls rougher. For a range, the table takes the upper end, i.e. the higher pressure drop. The values for welded stainless steel and plastics come from a secondary source that cites White (2011); the original table hasn't been checked yet. For drawn or pressed stainless steel, a manufacturer gives the same value. A pipe of the Pipe Catalogue takes the material of its series.
| Material | Pipe | Roughness | Source |
|---|---|---|---|
copper |
Drawn copper tube | 1.5 µm | Crane TP-410 (2013), p. A-24 (after Moody): drawn tubing, 0.000005 ft = 1.5 µm. |
steel |
Commercial (carbon) steel pipe | 45 µm | Crane TP-410 (2013), p. A-24 (after Moody): commercial steel, 0.00015 ft = 46 µm; rounded to 45 µm (0.045 mm), the value commonly tabulated in SI units. |
galvanized_steel |
Galvanized steel pipe | 150 µm | Crane TP-410 (2013), p. A-24 (after Moody): galvanized iron, 0.0005 ft = 152 µm; rounded to 150 µm. |
stainless_steel |
Drawn or pressed stainless steel tube (e.g. press-fitting systems) | 1.5 µm | White, Fluid Mechanics (2011), as compiled in a secondary source: drawn or pressed stainless steel, 1.5 µm. The original table hasn't been checked yet. Geberit, Geberit Mapress Application Technique (2022), Table 5: surface roughness 1.5 µm of its stainless steel system pipe 1.4401. |
stainless_steel_welded |
Welded stainless steel tube | 30 µm | White, Fluid Mechanics (2011), as compiled in a secondary source: welded stainless steel, 15–30 µm; upper end. The original table hasn't been checked yet. |
plastic |
Plastic pipe (PE-X, PE-RT, PP, PVC) | 7 µm | White, Fluid Mechanics (2011), as compiled in a secondary source: PVC, PE and PP, 1.5–7 µm; upper end. The original table hasn't been checked yet. |
Pipe Catalogue¶
Instead of its inner diameter and material, a pipe can name its size in a series of the pipe catalogue (src/solarfields/data/pipe_catalogue.yaml): series and size, e.g. series: EN 1057 and size: 35×1.5. The size gives the inner diameter (the outer diameter less twice the wall thickness) and the material, which gives the roughness (Pipe Materials); a branch pipe or a pipe edge of the graph format also takes its outer diameter from it, which the heat loss model uses, unless it gives U_loss. A size given together with the inner diameter, the material or the outer diameter is rejected, as are a series without a size and a size the series doesn't have; the message lists the sizes. A roughness may still be given, e.g. for an aged pipe. The piping of a field or sub-field (inlet_header_D and the others) and the connection pipes of string edges still give their diameters.
pipes:
- name: header_pipe
length: 12 m
series: EN 1057 # instead of D_inner: 32 mm and material: copper
size: 35×1.5 # also written 35x1.5; a decimal point, never a comma
branch_pipes:
- from: inlet
to: Subfield-A.inlet
pipe: {series: EN 253, size: DN 100, length: 80 m, surroundings: ground} # insulation: see below
In Python, pipe_size(series, size) returns the size (PipeSize), whose diameters in m a Pipe takes as they are: Pipe(length=Q_(12, "m"), D_inner=size.D_inner, roughness=size.roughness). pipe_series() lists the series with their material, standard, description and sources (PipeSeries), pipe_sizes(series) the sizes of one series; a series or size that isn't there raises PipeCatalogueError, which names the series, the size and the choices.
| Series | Pipes | Sizes | Material | Sources of the dimensions |
|---|---|---|---|---|
EN 1057 |
Seamless copper tubes for heating and sanitary installations, by outer diameter × wall thickness; where two wall thicknesses are common for one outer diameter (15 to 54 mm), both | 12×1 to 267×3 (23 sizes) | copper |
Wieland-Werke AG, "Haustechnik, Kälte, Klima" product catalogue (2026), SANCO installation tubes, pp. 7 and 12 (PDF); HME Copper Germany GmbH, SANCO product data sheet (PDF) |
EN 10312 |
Thin-walled stainless steel tubes of press-fitting systems (CrNiMo steel 1.4401), by outer diameter × wall thickness | 12×1 to 108×2 (11 sizes) | stainless_steel |
Geberit, "Geberit Mapress Application Technique" (2022), Table 7 (PDF) |
EN 10255 medium |
Non-alloy steel tubes for welding and threading, medium series (M), by nominal size | DN 8 to DN 150 (13 sizes) | steel |
thesteel.com, threaded steel tube S195T, EN 10255-M (page); The Engineering ToolBox, "EN 10255 – Non-alloy Steel Tubes for Welding and Threading – Dimensions" (page) |
EN 253 |
Steel service pipes of pre-insulated bonded pipes for buried networks (PUR foam, PE jacket), by nominal size, with the jacket of each insulation series 1, 2 and 3 | DN 20 to DN 600 (18 sizes) | steel |
isoplus, Product catalogue (11/2024), section 4.2, "Heat loss – steel pipes – single", series 1 to 3 (PDF) |
The standards themselves weren't at hand; the dimensions are those of the public manufacturer data above, and the sizes those that the sources list. Some tables of steel tubes to EN 10255 still give the wall thicknesses of the former DIN 2440 (e.g. 42.4 × 3.25 mm instead of 3.2 mm, 0.1 mm less in the inner diameter); the catalogue takes those of EN 10255. A size that isn't listed is given by its inner diameter and material. The jackets of the pre-insulated pipes give the insulation of buried pipes (Pipe Heat Loss Model).
The insulation of a buried pipe
A size of EN 253 gives the steel pipe. A buried pipe without insulation_thickness (or U_loss) has the insulation of the insulation series buried_pipes.insulation_series, series 1 unless set otherwise: the space between the steel pipe and the jacket, (jacket outer diameter − 2 × jacket wall − steel pipe outer diameter) / 2, e.g. DN 100 in series 1 (jacket 200 × 3.2 mm): (200 − 6.4 − 114.3) / 2 = 39.65 mm. A pipe of another series than the field's gives its insulation_thickness; pipe_size("EN 253", "DN 100").jackets lists the jackets.
Sizing the headers. design_header_diameters (in solarfields.core) gives the inner diameter of each header segment of a field of parallel strings of equal flow, for a design limit of the velocity, within the highest acceptable velocity:
from solarfields import Q_
from solarfields.core import design_header_diameters
inlet_header_D, outlet_header_D = design_header_diameters(
Q_(2.0, "kg/s"), 10, Q_(1030, "kg/m^3"), standard_sizes="EN 10255 medium",
v_design_max=Q_(1.8, "m/s"), v_max=Q_(2.0, "m/s"), topology="direct_return")
standard_sizesis required: a series of the catalogue, whose sizes' inner diameters it chooses from, or inner diameters, e.g.Q_([27.3, 36.0, 41.9], "mm"). A nominal size is no inner diameter: DN 32 to EN 10255 is 36.0 mm inside, and the pipe friction computed with 32 mm comes out about 75 % too high.- Each segment gets the smallest size whose velocity is at most the design limit
v_design_max, 1.8 m/s unless given. There is no lowest velocity: next to a header's dead end, where the flow of one string remains, no size could keep one. - Where no size keeps a segment within the design limit, the segment gets the largest size, as long as its velocity is at most the highest acceptable velocity
v_max, 2 m/s unless given; aHeaderVelocityWarninglists these segments (its entry in the lists returned, e.g.inlet_header_D[0], its flow, size and velocity). - Where even the largest size gives a segment more than
v_max,HeaderSizeErrornames the segment, its flow, the largest size and the velocity in it. - The inputs are checked: a flow, a density and
v_design_maxabove 0,v_maxwithin 1–2.5 m/s (an engineering estimate, adjustable within this range),v_design_maxat mostv_max, at least one string, sizes above 0 and at most 1.5 m.
Naming Conventions¶
Terminology¶
The package uses the terms of planning practice. Before #34, the code and these docs used other names, and the headers were named the other way round than in heating practice (the cold header that feeds the strings was the "supply" header):
| Now | Before |
|---|---|
String (string, strings:, CollectorString, StringSolver) |
Row (row, rows:, CollectorRow, RowSolver) |
Sub-field (subfield, subfields:) |
Zone (zone, zones:) |
Direct return (Topology.DIRECT_RETURN, topology: direct_return) |
U-layout (Topology.U, topology: U) |
Reverse return (Topology.REVERSE_RETURN, topology: reverse_return) |
Z-layout (Topology.Z, topology: Z) |
Inlet header, cold side (inlet_header_D) |
Supply header (supply_header_D) |
Outlet header, hot side (outlet_header_D) |
Return header (return_header_D) |
Branch pipes (branch_pipes:) |
Inter-zone pipes (inter_zone:) |
Configs with the old keys or values are rejected with an error that names the new one.
FieldSolver is the own solver for every field; PandapipesSolver is the pandapipes-based reference.
Component IDs¶
| Level | Pattern | Example |
|---|---|---|
| Sub-field | User-defined | Subfield-A, North, Main |
| String | {subfield}_{string_index} or user-defined |
Subfield-A_string_0, String-1 |
| Collector | {string}_coll_{index} |
Subfield-A_string_0_coll_0 |
| Branch pipe | branch_{from}_{to}, points as underscores |
branch_inlet_Subfield-A_inlet, branch_J-in_Subfield-B_inlet |
| Junction | User-defined | J-in, J-main |
| Pipe (graph format) | User-defined | pipe_in, main_inlet |
Fields with One Sub-field¶
When a field has exactly one subfield:
- Sub-field name defaults to field name
- String names omit sub-field prefix: string_0, string_1, ...
Benchmarks¶
The scripts in examples/benchmarks/ define the benchmark problems for comparing the solvers and tracking their run time (#22). Both use 40 % propylene glycol (APG) and the ISO 9806 blue-sky conditions.
field_comparison: a field of 10 strings with 10 flat-plate collectors each (FP-SG-15m2), as direct and as reverse return, 20,000 L/h at 60 °C inlet, without pipe heat losses (a benchmark of the network solution). Solved withFieldSolverand withPandapipesSolveron the same field; needs pandapipes (extrapandapipes, part of the development tools).string_comparison: a string of 10 harp collectors (FP-SG-15m2) and a virtual collector of 10 parallel meander collectors (FP-Meander-15m2), each at inlet temperatures of 10, 40 and 70 °C and at 15 and 20 L/(m²·h). These flows keep 40 % APG near the upper end of its data, 100 °C: at 70 °C inlet, up to 100.1 °C in the harp string and 100.9 °C in the hottest riser of the virtual collector, the places its results list as beyond the data (Beyond the Data). At 10 L/(m²·h) it would reach 112 °C, liquid at 3 bar but 12 K beyond the data. Solved withStringSolver.
Run times, median of 5 runs with a new solver each, after JIT compilation (October 2026; Windows 11, Intel Core Ultra (Meteor Lake, 14 logical CPUs), Python 3.13, numba 0.63, pandapipes 0.13). Between runs, the times vary by up to a factor of 2.
| Problem | Solver | Solve time | Outer iterations |
|---|---|---|---|
| Field, direct return | FieldSolver |
0.06 s | 2 |
| Field, direct return | PandapipesSolver |
0.4 s | 7 |
| Field, reverse return | FieldSolver |
0.06 s | 2 |
| Field, reverse return | PandapipesSolver |
0.4 s | 6 |
| Harp string, 6 operating points | StringSolver |
1.4–1.6 ms each | 4–7 |
| Meander collector, 6 operating points | StringSolver |
0.5–0.8 ms each | 2–7 |
FieldSolver solves each string twice at the start and once per outer iteration (40 string solves on both field benchmarks); the string solves take most of its run time.
Validation Status¶
Current state:
-
Own network solver compared with the pandapipes reference.
FieldSolverandPandapipesSolveruse the same string model, the same fluid properties and the same calculation of the temperatures along the flow, including the pipe heat losses and the mixing where flows join. The comparison therefore checks the hydraulic solution of the network: the flows and the pressures. The temperatures agree because the flows do; an error in the temperatures shows in both alike, as the mixing by mass did before #71. The temperatures are checked by energy balances instead (Temperatures along the flow). Both solvers get the same inputs: the same network including the T-junction losses, the density and viscosity of the fluid at the temperature of each pipe, and a friction factor for turbulent flow. The comparison covers all benchmarks and network examples (direct and reverse return, branching, several sub-fields, sub-fields in series; tests intests/integration/test_pandapipes_solver.py). The results agree closely:- flow of each string: within 0.04 %;
- total pressure drop: within 0.06 %;
- node temperatures: within 0.013 K;
- pressure drop of each pipe and valve: within 0.16 mbar; pressure of each node: within 0.4 mbar.
The remaining differences come from the two friction factor equations (Colebrook–White in the reference, Churchill in
FieldSolver) and from pandapipes evaluating the fluid properties of a pipe at the mean of its two end nodes. All pipes in these networks have turbulent flow (Re 4,500–114,000); laminar and transitional pipe flow isn't cross-checked (see Friction Factor). Before #37, the reference used the viscosity of water, the default friction model of pandapipes and one density for all pipes; its string flows then differed by up to 1.0 % and its total pressure drops by up to 2.4 %. -FieldSolvergives the solution of the network equations: on the direct-return benchmark flow CV 3.2 %, strings 1 and 10 at +4.8 % and −5.3 % of the mean flow, the solution of a direct Newton solve with the same inputs (#32). - Tool results are not yet validated against field measurement data.
Potential Future Extensions¶
The following have been considered as possible extensions. They aren't commitments: whether and how they're implemented depends on what the tool needs and what's feasible.
Heights within the Field¶
Heights per node or component: the static pressure by height within the field, instead of every place at the field's highest point (field_elevation), and the drainage behaviour of collectors and pipes for a qualitative stagnation assessment (#17).
Seasonal Ground Temperature and Twin Pipes¶
The ground temperature is constant. A seasonal temperature at the pipe depth (analytical model of the annual temperature wave in the soil, or climate data) and the thermal interaction between supply and return pipes in a common trench (thermal coupling resistance, EN 13941) would refine the losses of buried pipes.
Independent Check of the Pipe Heat Losses¶
The buried pipes are compared with the coefficients a manufacturer publishes (Buried pipes); the pipes in the air have no such comparison yet. The pandapipes reference solver uses the temperature propagation of FieldSolver, so it does not check the pipe heat losses independently. pandapipes can compute heat losses and temperatures itself (thermal mode, with the strings as heat sources), which would give an independent check.
Operating Conditions per Sub-field or per String¶
Different operating conditions per sub-field (e.g. different orientation) or per string (e.g. different tilt angle), uniform within a string:
General Sub-field Layouts¶
T-junctions for the sub-field inlet and outlet at any string or between strings (today: direct or reverse return only; other layouts via the graph format):
┌───────────────────────────────────────────────────────────────────────────┐
│ SUB-FIELD (general layout) │
│ │
│ inlet │
│ ↓ │
│ ●══════════●══════════●══════════●══════════● inlet header (cold) │
│ │ │ │ │ │ │
│ [S0] [S1] [S2] [S3] [S4] │
│ │ │ │ │ │ │
│ ●══════════●══════●═══●══════════●══════════● outlet header (hot) │
│ ↓ │
│ outlet │
│ │
│ T-junction for the outlet placed BETWEEN strings S1 and S2 │
│ ● = T-junction │
│ ═══ = header pipe segment │
│ [S#] = string │
└───────────────────────────────────────────────────────────────────────────┘
Extended Sub-field and Field Configuration¶
A more detailed configuration format: per-segment header lengths and insulation, explicit sub-field inlet and outlet connections with their own pipes and valves, configurable T-junction losses, and explicit branching junctions with minor losses:
subfields:
- name: Subfield-A
strings:
- type: String-10-Harp
count: 5
outlet_header:
segment_diameters: [0.053, 0.053, 0.041, 0.035] # Per-segment
segment_lengths: [4.0, 4.0, 4.0, 4.0] # Or uniform value
insulation:
thickness: 0.03 m
conductivity: 0.04 W/(m·K)
inlet_header:
segment_diameters: [0.053, 0.053, 0.041, 0.035]
segment_lengths: [4.0, 4.0, 4.0, 4.0]
tee_minor_loss_K: 1.0
inlet_connection:
position: 0 # T-junction index (0 = first string)
# Or: position_between: [0, 1] # Between string 0 and string 1
pipe:
diameter: 0.06 m
length: 3.0 m
shutoff_valve_K: 0.5 # Optional
outlet_connection:
position: 0 # Same end as inlet = direct return
# position: 4 # Opposite end = reverse return
# position_between: [1, 2] # Between strings = general layout
pipe:
diameter: 0.06 m
length: 3.0 m
shutoff_valve_K: 0.5 # Optional
balancing_valve_K: 2.0 # Optional
branching:
junctions:
- id: j_split
minor_loss_K: 0.5
pipes:
- id: branch_A_in
from: j_split
to: Subfield-A.inlet
diameter: 0.06 m
length: 15.0 m
insulation:
thickness: 0.04
conductivity: 0.04
Add proper T-Junction K values¶
The pressure loss coefficients (K values) of T-junctions are quite an intricate topic. More literature research work could be done to include more realistic T-junction K values. This could include portruding T-junctions.
Automatic Balancing Valve Calculation¶
Note: This is optional and depends on tool requirements.
Compute valve K values to equalize string outlet temperatures:
- Target: match highest T_outlet or specified target
- Requires valve catalog with tabulr K values: configs/valves.yaml
# configs/valves.yaml (future)
balancing_valves:
- name: TA-STAD-DN20
Kvs: 5.5 m³/h # at a pressure drop of 1 bar
diameter: 0.020 m
K_range: [1.5, 50]
Automatic Pipe Diameter Sizing¶
Note: This is optional and depends on tool requirements.
Given flow requirements and velocity limits: - Specify velocity constraints per pipe type - Algorithm selects from available pipe sizes: those of the Pipe Catalogue (#6)
IAM (Incidence Angle Modifier) Models¶
Note: This is optional and depends on tool requirements.
Current implementation: Scalar K_b and K_d values per collector (constant, angle-independent).
Potential addition: Full angle-dependent IAM with bidirectional interpolation (via SunPeek) for CPC and tracking collectors.
Compute G_b and G_d per string from solar geometry:
Inputs:
- Location (latitude, longitude)
- Date and time
- DNI (Direct Normal Irradiance) or other irradiance inputs like G_t (hemispheric irradiance in collector plance)
- Collector orientation (tilt, azimuth) per string
Outputs:
- G_b, G_d per string (accounting for solar position and collector orientation)
Note: Ambient conditions (including computed irradiances) are per-string granularity, not per-collector.
Add More Fluids¶
To add new CoolProp fluids, add them to SUPPORTED_COOLPROP_FLUIDS in fluid_registry.py and run the generation script. See the registry docstrings for current exclusions and rationale.
Result Hierarchy¶
The result classes mirror the component hierarchy (src/solarfields/results/). StringSolver returns a StringResult; FieldSolver and PandapipesSolver return a FieldResult for every field. In the result of PandapipesSolver, the K part of a pipe is K·ρv²/2 at the pipe's mean velocity, the rest of its pressure drop is friction; pandapipes doesn't separate them.
FieldResult
├── name, converged, outer_iterations, network_iterations, dp_imbalance_max
├── T_inlet, T_outlet, dT
├── dp_total, Q_total (delivered at the outlet), Q_loss_pipes, eta_field
├── m_dot_total, V_dot_total_Lh
├── flow_per_area_cv, flow_shares (across all strings; flow_ratios: flow / share)
│
├── strings: dict[str, StringResult] (by string edge id, every string of the field)
│ └── StringResult
│ ├── name, converged, iterations
│ ├── T_inlet, T_outlet, dT
│ ├── dp_total, Q_total, eta_string
│ ├── m_dot, V_dot
│ │
│ ├── collectors: list[CollectorResult]
│ │ └── CollectorResult
│ │ ├── name
│ │ ├── T_in, T_out, dp, Q, eta
│ │ ├── m_dot, flow_cv
│ │ ├── Re_riser_mean, Re_riser_min, Re_riser_max, Re_header
│ │ ├── v_riser_mean, v_header
│ │ ├── p_in, p_out (absolute static pressures)
│ │ ├── rho, mu (fluid properties)
│ │ ├── m_dot_risers (optional detailed)
│ │ ├── T_out_risers (riser outlet temperatures; with the thermal model per riser)
│ │ └── v_risers, Re_risers (velocity and Reynolds number of every riser)
│ │
│ ├── inter_pipes: list[PipeResult | None] (one per gap; None: no pipe)
│ ├── inlet_pipe: PipeResult | None
│ └── outlet_pipe: PipeResult | None
│
├── pipes: dict[str, PipeResult] (by edge id, every pipe and valve of the network)
├── p_inlet, p_outlet (absolute static pressures)
├── node_pressures, node_temperatures: dict[str, float]
│
└── subfields: dict[str, SubfieldResult] (none for a network in the graph format)
└── SubfieldResult
├── name, topology
├── T_inlet, T_outlet, dT
├── dp_total, Q_total (delivered at the outlet), Q_loss_pipes, eta_subfield
├── m_dot_total, V_dot_total_Lh
├── flow_per_area_cv, flow_shares (within the sub-field; flow_ratios: flow / share)
├── pressure breakdown: dp_inlet_header, dp_outlet_header, dp_tees, dp_connections,
│ dp_valves, dp_strings, dp_inlet_losses, dp_outlet_losses
│
├── strings: dict[str, StringResult] (the same objects as in FieldResult.strings)
│
└── headers: HeadersResult
├── inlet_header_segments, outlet_header_segments: list[PipeResult]
├── inlet_tees, outlet_tees: list[PipeResult]
└── inlet_header_D, outlet_header_D, segment_L
FieldResult.subfield is the sub-field of a field with exactly one. The sub-fields are found through their ports, which CollectorField.add_to_network returns and the network keeps in metadata["subfield_ports"] (prefix of the node and edge ids, inlet and outlet node).
Pressure losses: every loss is counted once, in one element, with friction and minor losses separate (PipeResult: dp = dp_friction + dp_minor):
| Element | dp_friction |
dp_minor |
|---|---|---|
Header segment (*_header_segments) |
Pipe friction | Through-loss of the T-junction it flows into (K = 0.2) |
T-junction branch (*_tees) |
0 | Branch loss of the T-junction (K = 1.0) |
Connection pipe (pipes["<string>.connection_in"], ..._out) |
Pipe friction | String inlet or outlet valve |
Field inlet and outlet losses (pipes["field_inlet"], ["field_outlet"]) |
0 | Field inlet or outlet minor losses |
Branch pipe between sub-fields (pipes["branch_..."]) |
Pipe friction | Its K and fittings (0 without) |
The only sums of pressure drops are the parts of the sub-field's pressure breakdown, taken along each string's path and weighted by the string flows. They don't overlap and add up to the sub-field's dp_total: inlet and outlet header and connection pipes with friction only, all T-junction losses (through and branch) as tees, string valves, field inlet and outlet losses, strings.
Uniformity of the Flow¶
How evenly the flow divides among the strings is judged per square meter of collector area. Each string has a share of the field's flow: the flow it would get if the flow divided in proportion to collector area wherever strings run in parallel, with the network's pipes and valves taken as lossless. Its flow ratio is its flow divided by its share,
1 where the string gets its share, 1.1 where it gets 10 % more flow per square meter than an even split by area would give it. flow_per_area_cv is the coefficient of variation of the ratios, \(CV = \sigma(r) / \bar r\) in %, for all strings of the field and for the strings of each sub-field.
- The shares. Each pipe and valve joins its two nodes into one. Between these, each string conducts in proportion to its collector area \(A_i\), and the field's mass flow passes this network from the inlet to the outlet, as a current passes a network of conductances: \(s_i = A_i (\varphi_a - \varphi_b)\), with the potentials \(\varphi\) of the string's two nodes such that at every node the shares that leave it add up to those that enter it (the outlet's potential is 0). Strings in parallel between the same two nodes thus share the flow by area. Each of two sections in series carries the whole flow, so the shares of a field with two sections in series add up to twice its flow. A string whose two ends pipes or valves join has a share of 0, as has one through which the split by area sends no flow (the cross string of a balanced bridge in the graph format); where the split by area would send the flow against a string's direction, its share is negative. For a share of 0 or below, the ratio and the CV are not a number (NaN).
- Why not the mass flows. For strings of one size, the CV of the ratios is that of the mass flows. For strings of different size, the mass flows mislead. Take nine strings of 10 collectors and one of 6. Unbalanced, the CV of the mass flows is 6.9 %, while the short string gets 102 % more flow per square meter than the others (1.90 times its share; CV of the flow per area 27.9 %); with the short string throttled by an outlet valve, the CV of the mass flows is 11.4 %, that of the flow per area 3.1 %. And for two sections in series, of 6 and 4 strings, the CV of the mass flows is 20.4 %, that of the flow per area 0.33 % (0.42 % and 0.14 % per section).
- In the results.
FieldResult.flow_sharesandSubfieldResult.flow_sharesgive the shares in kg/s by string edge id,flow_ratiosthe ratios, andflow_uniformity_metrics()the CV, the smallest and the largest ratio and their quotient (cv_percent,min_ratio,max_ratio,min_max_ratio). A sub-field's CV measures the balance within it; the sum of its strings' flows against the sum of their shares measures the balance between the sub-fields. - In the plots.
plot_network_summaryshows each string's flow against its share.plot_field_flow_distribution, for one sub-field, shows each string's flow per square meter against the sub-field's mean, with the CV within the sub-field, and gives the sub-field's flow against its share in its title. The bands at ±5 % and ±10 % are as before.
Result Dataclass Definitions¶
These are the actual implementations in src/solarfields/results/:
@dataclass
class CollectorResult:
"""Results for a single collector (physical or virtual)."""
name: str
T_in: float # [°C]
T_out: float # [°C]
dp: float # [Pa]
Q: float # [W]
eta: float # [-] Q / (A_G·(G_b + G_d)); negative for a collector that loses heat
m_dot: float # [kg/s]
flow_cv: float # [%] intra-collector
Re_riser_mean: float # [-]
Re_header: float # [-]; 0 for a collector without headers
v_riser_mean: float # [m/s]
v_header: float # [m/s]; 0 for a collector without headers
p_in: float # [Pa] absolute static, at the inlet connection
p_out: float # [Pa] absolute static, at the outlet connection
rho: float | None = None # [kg/m³] fluid density
mu: float | None = None # [Pa·s] fluid viscosity
Re_riser_min: float | None = None # [-]
Re_riser_max: float | None = None # [-]
m_dot_risers: np.ndarray | None = None # [kg/s] per-riser
T_out_risers: np.ndarray | None = None # [°C] per-riser outlet; None for one flow path or riser_temperatures off
v_risers: np.ndarray | None = None # [m/s] per-riser, with the fluid properties at the riser's temperature
Re_risers: np.ndarray | None = None # [-] per-riser, as v_risers; their mean, min and max are the values above
@dataclass
class PipeResult:
"""Results for any pipe segment."""
T_in: float # [°C]
T_out: float # [°C]
dp: float # [Pa] total
dp_friction: float # [Pa]
dp_minor: float # [Pa]
Q_loss: float # [W] heat loss (positive = heat leaving the fluid)
m_dot: float # [kg/s]
v: float # [m/s]
Re: float # [-]
p_in: float # [Pa] absolute static, at the inlet
p_out: float # [Pa] absolute static, at the outlet
@dataclass
class StringResult:
"""Results for a single string."""
name: str
T_inlet: float # [°C]
T_outlet: float # [°C]
dT: float # [K]
dp_total: float # [Pa]
Q_total: float # [W]
eta_string: float # [-]
m_dot: float # [kg/s]
V_dot: float # [L/h]
p_inlet: float # [Pa] absolute static, at the field's highest point
p_outlet: float # [Pa] absolute static, at the field's highest point
collectors: list[CollectorResult]
inter_pipes: list[PipeResult | None] # one per gap between collectors; None: the gap has no pipe
inlet_pipe: PipeResult | None
outlet_pipe: PipeResult | None
converged: bool
iterations: int
# Breakdown details
dp_collectors: float = 0.0 # [Pa]
dp_pipes: float = 0.0 # [Pa]
dp_inlet_losses: float = 0.0 # [Pa]
dp_outlet_losses: float = 0.0 # [Pa]
Q_loss_pipes: float = 0.0 # [W]
riser_iterations: int = 0 # Newton steps of the riser flow distribution
T_profile: np.ndarray | None = None # Temperature at each point [°C]
position: np.ndarray | None = None # Position along string [m]
beyond_fluid_data: list[BeyondDataPlace] = [] # Places beyond the fluid's data, in the order of the flow
@property
def n_collectors(self) -> int:
return len(self.collectors)
@dataclass
class HeadersResult:
"""Results for sub-field header pipes and their T-junctions."""
inlet_header_segments: list[PipeResult]
outlet_header_segments: list[PipeResult]
inlet_tees: list[PipeResult] # Branch of the inlet T-junction per string
outlet_tees: list[PipeResult] # Branch of the outlet T-junction per string
inlet_header_D: list[float] # [m] per segment
outlet_header_D: list[float] # [m] per segment
segment_L: float # [m]
@dataclass
class SubfieldResult:
"""Results for a sub-field."""
name: str
topology: str # "direct_return" or "reverse_return"
T_inlet: float # [°C]
T_outlet: float # [°C] mixed
dT: float # [K]
dp_total: float # [Pa]
Q_total: float # [W] delivered at the outlet: strings' heat gain − Q_loss_pipes
Q_loss_pipes: float # [W] headers and connection pipes
eta_subfield: float # [-]
m_dot_total: float # [kg/s]
V_dot_total_Lh: float # [L/h]
flow_per_area_cv: float # [%] CV of the strings' flow / share
flow_shares: dict[str, float] # [kg/s] each string's share of the field's flow by collector area
strings: dict[str, StringResult]
headers: HeadersResult
# Pressure breakdown along the string paths, flow-weighted; adds up to dp_total [Pa]
dp_inlet_header: float # friction
dp_outlet_header: float # friction
dp_tees: float # T-junctions, through and branch
dp_connections: float # friction
dp_valves: float # string valves
dp_strings: float
dp_inlet_losses: float
dp_outlet_losses: float
def pressure_breakdown(self) -> dict[str, float]: ...
@dataclass
class FieldResult:
"""Results for a complete field."""
name: str
T_inlet: float # [°C]
T_outlet: float # [°C] mixed
dT: float # [K]
dp_total: float # [Pa]
Q_total: float # [W] delivered at the outlet: strings' heat gain − Q_loss_pipes
Q_loss_pipes: float # [W] all network pipes
eta_field: float # [-]
m_dot_total: float # [kg/s]
V_dot_total_Lh: float # [L/h]
flow_per_area_cv: float # [%] CV of the strings' flow / share, all strings
flow_shares: dict[str, float] # [kg/s] each string's share of the flow by collector area
p_inlet: float # [Pa] absolute static, at the field's highest point
p_outlet: float # [Pa] absolute static, at the field's highest point
strings: dict[str, StringResult]
pipes: dict[str, PipeResult]
node_pressures: dict[str, float] # [Pa] total pressure, relative to the field inlet
node_temperatures: dict[str, float] # [°C]
subfields: dict[str, SubfieldResult]
outer_iterations: int
network_iterations: int
converged: bool
dp_imbalance_max: float # [Pa]
beyond_fluid_data: list[BeyondDataPlace] = [] # Places beyond the fluid's data: pipes and valves, then strings
@property
def subfield(self) -> SubfieldResult: ... # the only sub-field
@dataclass(frozen=True)
class BeyondDataPlace:
"""A place where the temperature lies beyond the fluid's data (#69)."""
location: str # e.g. "String 'S3', collector 10 (FP-SG-15m2) outlet"
temperature: float # [°C]
limit: float # [°C] the data limit it lies beyond
side: str # "above" or "below"
@property
def distance(self) -> float: ... # [K] how far beyond, positive