• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

WISDEM / WEIS / 16309547368

16 Jul 2025 03:21AM UTC coverage: 58.865% (-1.6%) from 60.46%
16309547368

push

github

web-flow
Merge pull request #409 from WISDEM/develop

WEIS v1.6

605 of 817 new or added lines in 20 files covered. (74.05%)

525 existing lines in 10 files now uncovered.

8094 of 13750 relevant lines covered (58.87%)

0.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

47.89
/weis/glue_code/gc_PoseOptimization.py
1
from wisdem.glue_code.gc_PoseOptimization import PoseOptimization
1✔
2
import numpy as np
1✔
3

4
class PoseOptimizationWEIS(PoseOptimization):
1✔
5

6
    def __init__(self, wt_init, modeling_options, analysis_options):
1✔
7
        
8
        self.level_flags = np.array([modeling_options[level]['flag'] for level in ['RAFT','OpenFAST_Linear','OpenFAST']])
1✔
9
        # if sum(self.level_flags) > 1:
10
            # raise Exception('Only one level in WEIS can be enabled at the same time')
11

12
        super(PoseOptimizationWEIS, self).__init__(wt_init, modeling_options, analysis_options)
1✔
13

14
        # Set solve component for some optimization constraints, and merit figures (RAFT or openfast)
15
        if modeling_options['OpenFAST']['flag']:
1✔
16
            self.floating_solve_component = 'aeroelastic'
1✔
17
        elif modeling_options['RAFT']['flag']:
1✔
18
            self.floating_solve_component = 'raft'
1✔
19
        else:
20
            self.floating_solve_component = 'floatingse'
1✔
21

22
        # aeroelastic won't compute floating period, execpt in special sims
23
        if modeling_options['RAFT']['flag']:
1✔
24
            self.floating_period_solve_component = 'raft'
1✔
25
        else:
26
            self.floating_period_solve_component = 'floatingse'
1✔
27
        
28
        if modeling_options['OpenFAST']['flag']:
1✔
29
            self.n_OF_runs = modeling_options['DLC_driver']['n_cases']
1✔
30
        elif modeling_options['OpenFAST_Linear']['flag']:
1✔
31
            self.n_OF_runs = modeling_options['OpenFAST_Linear']['linearization']['NLinTimes']
×
32
        else:
33
            self.n_OF_runs = 0
1✔
34
    
35
    def set_objective(self, wt_opt):
1✔
36
        # Set merit figure. Each objective has its own scaling.  Check first for user override
37
        if self.opt["merit_figure_user"]["name"] != "":
1✔
38
            coeff = -1.0 if self.opt["merit_figure_user"]["max_flag"] else 1.0
×
39
            wt_opt.model.add_objective(self.opt["merit_figure_user"]["name"],
×
40
                                       ref=coeff*np.abs(self.opt["merit_figure_user"]["ref"]))
41
            
42
        elif self.opt['merit_figure'] == 'blade_tip_deflection':
1✔
43
            wt_opt.model.add_objective('tcons_post.tip_deflection_ratio')
×
44
            
45
        elif self.opt['merit_figure'] == 'DEL_RootMyb':   # for DAC optimization on root-flap-bending moments
1✔
46
            wt_opt.model.add_objective('aeroelastic.DEL_RootMyb', ref = 1.e3)
×
47
            
48
        elif self.opt['merit_figure'] == 'DEL_TwrBsMyt':   # for pitch controller optimization
1✔
49
            wt_opt.model.add_objective('aeroelastic.DEL_TwrBsMyt', ref=1.e4)
1✔
50
            
51
        elif self.opt['merit_figure'] == 'rotor_overspeed':
1✔
52
            if not any(self.level_flags):
×
53
                raise Exception('Please turn on the call to OpenFAST or RAFT if you are trying to optimize rotor overspeed constraints.')
×
54
            wt_opt.model.add_objective(f'{self.floating_solve_component}.rotor_overspeed')
×
55
        
56
        elif self.opt['merit_figure'] == 'Std_PtfmPitch':
1✔
UNCOV
57
            wt_opt.model.add_objective('aeroelastic.Std_PtfmPitch')
×
58
        
59
        elif self.opt['merit_figure'] == 'Max_PtfmPitch':
1✔
60
            wt_opt.model.add_objective('aeroelastic.Max_PtfmPitch')
×
61

62
        elif self.opt['merit_figure'] == 'Cp':
1✔
63
            wt_opt.model.add_objective('aeroelastic.Cp_out', ref=-1.)
×
64
        
65
        elif self.opt['merit_figure'] == 'weis_lcoe' or self.opt['merit_figure'].lower() == 'lcoe':
1✔
66
            wt_opt.model.add_objective('financese_post.lcoe')
×
67
        
68
        elif self.opt['merit_figure'] == 'OL2CL_pitch':
1✔
69
            wt_opt.model.add_objective('aeroelastic.OL2CL_pitch')
×
70
        
71
        else:
72
            super(PoseOptimizationWEIS, self).set_objective(wt_opt)
1✔
73
                
74
        return wt_opt
1✔
75

76
    
77
    def set_design_variables(self, wt_opt, wt_init):
1✔
78
        super(PoseOptimizationWEIS, self).set_design_variables(wt_opt, wt_init)
1✔
79

80
        # -- Control --
81
        control_opt = self.opt['design_variables']['control']
1✔
82
        if control_opt['servo']['pitch_control']['omega']['flag']:
1✔
83
            wt_opt.model.add_design_var('tune_rosco_ivc.omega_pc', lower=control_opt['servo']['pitch_control']['omega']['min'], 
1✔
84
                                                            upper=control_opt['servo']['pitch_control']['omega']['max'])
85
        if control_opt['servo']['pitch_control']['zeta']['flag']:                            
1✔
86
            wt_opt.model.add_design_var('tune_rosco_ivc.zeta_pc', lower=control_opt['servo']['pitch_control']['zeta']['min'], 
1✔
87
                                                           upper=control_opt['servo']['pitch_control']['zeta']['max'])
88
        if control_opt['servo']['torque_control']['omega']['flag']:
1✔
89
            wt_opt.model.add_design_var('tune_rosco_ivc.omega_vs', lower=control_opt['servo']['torque_control']['omega']['min'], 
×
90
                                                            upper=control_opt['servo']['torque_control']['omega']['max'])
91
        if control_opt['servo']['torque_control']['zeta']['flag']:                                                    
1✔
92
            wt_opt.model.add_design_var('tune_rosco_ivc.zeta_vs', lower=control_opt['servo']['torque_control']['zeta']['min'], 
×
93
                                                           upper=control_opt['servo']['torque_control']['zeta']['max'])
94
        if control_opt['servo']['ipc_control']['Kp']['flag']:
1✔
95
            wt_opt.model.add_design_var('tune_rosco_ivc.IPC_Kp1p', lower=control_opt['servo']['ipc_control']['Kp']['min'],
×
96
                                                            upper=control_opt['servo']['ipc_control']['Kp']['max'],
97
                                                            ref=control_opt['servo']['ipc_control']['Kp']['ref'])
98
        if control_opt['servo']['ipc_control']['Ki']['flag']:
1✔
99
            wt_opt.model.add_design_var('tune_rosco_ivc.IPC_Ki1p', lower=control_opt['servo']['ipc_control']['Ki']['min'],
×
100
                                                            upper=control_opt['servo']['ipc_control']['Ki']['max'],
101
                                                            ref=control_opt['servo']['ipc_control']['Kp']['ref'])
102
        if control_opt['servo']['pitch_control']['stability_margin']['flag']:
1✔
103
            wt_opt.model.add_design_var('tune_rosco_ivc.stability_margin', lower=control_opt['servo']['pitch_control']['stability_margin']['min'],
×
104
                                                            upper=control_opt['servo']['pitch_control']['stability_margin']['max'])
105
        if control_opt['flaps']['te_flap_end']['flag']:
1✔
106
            wt_opt.model.add_design_var('dac_ivc.te_flap_end', lower=control_opt['flaps']['te_flap_end']['min'],
×
107
                                                            upper=control_opt['flaps']['te_flap_end']['max'])
108
        if control_opt['flaps']['te_flap_ext']['flag']:
1✔
109
            wt_opt.model.add_design_var('dac_ivc.te_flap_ext', lower=control_opt['flaps']['te_flap_ext']['min'],
×
110
                                                            upper=control_opt['flaps']['te_flap_ext']['max'])
111
        if 'flap_control' in control_opt['servo']:
1✔
112
            if control_opt['servo']['flap_control']['flp_kp_norm']['flag']:
1✔
113
                wt_opt.model.add_design_var('tune_rosco_ivc.flp_kp_norm', 
×
114
                                    lower=control_opt['servo']['flap_control']['flp_kp_norm']['min'], 
115
                                    upper=control_opt['servo']['flap_control']['flp_kp_norm']['max'])
116
            if control_opt['servo']['flap_control']['flp_tau']['flag']:
1✔
117
                wt_opt.model.add_design_var('tune_rosco_ivc.flp_tau', 
×
118
                                    lower=control_opt['servo']['flap_control']['flp_tau']['min'], 
119
                                    upper=control_opt['servo']['flap_control']['flp_tau']['max'])
120

121
        if control_opt['ps_percent']['flag']:
1✔
122
            wt_opt.model.add_design_var('tune_rosco_ivc.ps_percent', lower=control_opt['ps_percent']['lower_bound'],
×
123
                                                            upper=control_opt['ps_percent']['upper_bound'])
124

125
        if control_opt['servo']['pitch_control']['Kp_float']['flag']:
1✔
126
            wt_opt.model.add_design_var('tune_rosco_ivc.Kp_float', lower=control_opt['servo']['pitch_control']['Kp_float']['min'], 
1✔
127
                                                           upper=control_opt['servo']['pitch_control']['Kp_float']['max'])
128

129
        if control_opt['servo']['pitch_control']['ptfm_freq']['flag']:
1✔
130
            wt_opt.model.add_design_var('tune_rosco_ivc.ptfm_freq', lower=control_opt['servo']['pitch_control']['ptfm_freq']['min'], 
1✔
131
                                                           upper=control_opt['servo']['pitch_control']['ptfm_freq']['max'])
132

133
        if self.opt['design_variables']['TMDs']['flag']:
1✔
UNCOV
134
            TMD_opt = self.opt['design_variables']['TMDs']
×
135

136
            # We only support one TMD for now
UNCOV
137
            for i_group, tmd_group in enumerate(TMD_opt['groups']):
×
UNCOV
138
                if 'mass' in tmd_group:
×
UNCOV
139
                    wt_opt.model.add_design_var(
×
140
                        f'TMDs.TMD_IVCs.group_{i_group}_mass', 
141
                        lower=tmd_group['mass']['lower_bound'],
142
                        upper=tmd_group['mass']['upper_bound'],
143
                        )
UNCOV
144
                if 'stiffness' in tmd_group:
×
145
                    wt_opt.model.add_design_var(
×
146
                        f'TMDs.TMD_IVCs.group_{i_group}_stiffness', 
147
                        lower=tmd_group['stiffness']['lower_bound'],
148
                        upper=tmd_group['stiffness']['upper_bound']
149
                        )
150
                    if 'natural_frequency' in tmd_group:
×
151
                        raise Exception("natural_frequency and stiffness can not be design variables in the same group")
×
UNCOV
152
                if 'damping' in tmd_group:
×
153
                    wt_opt.model.add_design_var(
×
154
                        f'TMDs.TMD_IVCs.group_{i_group}_damping', 
155
                        lower=tmd_group['damping']['lower_bound'],
156
                        upper=tmd_group['damping']['upper_bound']
157
                        )
158
                    if 'damping_ratio' in tmd_group:
×
159
                        raise Exception("damping_ratio and damping can not be design variables in the same group")
×
UNCOV
160
                if 'natural_frequency' in tmd_group:
×
UNCOV
161
                    wt_opt.model.add_design_var(
×
162
                        f'TMDs.TMD_IVCs.group_{i_group}_natural_frequency', 
163
                        lower=tmd_group['natural_frequency']['lower_bound'],
164
                        upper=tmd_group['natural_frequency']['upper_bound']
165
                        )
UNCOV
166
                if 'damping_ratio' in tmd_group:
×
UNCOV
167
                    wt_opt.model.add_design_var(
×
168
                        f'TMDs.TMD_IVCs.group_{i_group}_damping_ratio', 
169
                        lower=tmd_group['damping_ratio']['lower_bound'],
170
                        upper=tmd_group['damping_ratio']['upper_bound']
171
                        )
172
        
173
        return wt_opt
1✔
174

175
    
176
    def set_constraints(self, wt_opt):
1✔
177
        super(PoseOptimizationWEIS, self).set_constraints(wt_opt)
1✔
178

179
        blade_opt = self.opt["design_variables"]["blade"]
1✔
180
        blade_constr = self.opt["constraints"]["blade"]
1✔
181
        if blade_constr['tip_deflection']['flag']:
1✔
182
            # Remove generic WISDEM one
183
            name = 'tcons.tip_deflection_ratio'
×
184
            if name in wt_opt.model._responses:
×
185
                wt_opt.model._responses.pop( name )
×
186
            if name in wt_opt.model._static_responses:
×
187
                wt_opt.model._static_responses.pop( name )
×
188
                
189
            if len(blade_opt["structure"]) > 0:
×
190
                wt_opt.model.add_constraint('tcons_post.tip_deflection_ratio', upper=1.0)
×
191
            else:
192
                print('WARNING: the tip deflection is set to be constrained, but spar caps thickness is not an active design variable. The constraint is not enforced.')
×
193

194
        if blade_constr["strains_spar_cap_ss"]["flag"]:
1✔
195
            # Remove generic WISDEM one
196
            name = 'rotorse.rs.constr.constr_max_strainU_spar'
×
197
            if name in wt_opt.model._responses:
×
198
                wt_opt.model._responses.pop( name )
×
199
            if name in wt_opt.model._static_responses:
×
200
                wt_opt.model._static_responses.pop( name )
×
201
            indices_strains_spar_cap_ss = range(
×
202
                blade_constr["strains_spar_cap_ss"]["index_start"], 
203
                blade_constr["strains_spar_cap_ss"]["index_end"]
204
            )
205
            wt_opt.model.add_constraint("rlds_post.constr.constr_max_strainU_spar", 
×
206
                                        indices = indices_strains_spar_cap_ss, 
207
                                        upper=1.0
208
            )
209

210
        if blade_constr["strains_spar_cap_ps"]["flag"]:
1✔
211
            # Remove generic WISDEM one
212
            name = 'rotorse.rs.constr.constr_max_strainL_spar'
×
213
            if name in wt_opt.model._responses:
×
214
                wt_opt.model._responses.pop( name )
×
215
            if name in wt_opt.model._static_responses:
×
216
                wt_opt.model._static_responses.pop( name )
×
217
            indices_strains_spar_cap_ps = range(
×
218
                blade_constr["strains_spar_cap_ps"]["index_start"], 
219
                blade_constr["strains_spar_cap_ps"]["index_end"]
220
            )
221
            wt_opt.model.add_constraint("rlds_post.constr.constr_max_strainL_spar", 
×
222
                                        indices = indices_strains_spar_cap_ps,
223
                                        upper=1.0
224
            )
225

226
        ### CONTROL CONSTRAINTS
227
        control_constraints = self.opt['constraints']['control']
1✔
228
        
229
        # Flap control
230
        if control_constraints['flap_control']['flag']:
1✔
231
            if self.modeling['OpenFAST']['flag'] != True:
×
232
                raise Exception('Please turn on the call to OpenFAST if you are trying to optimize trailing edge flaps.')
×
233
            wt_opt.model.add_constraint('sse_tune.tune_rosco.flptune_coeff1',
×
234
                lower = control_constraints['flap_control']['min'],
235
                upper = control_constraints['flap_control']['max'])
236
            wt_opt.model.add_constraint('sse_tune.tune_rosco.flptune_coeff2', 
×
237
                lower = control_constraints['flap_control']['min'],
238
                upper = control_constraints['flap_control']['max'])    
239
        
240
        # Rotor overspeed
241
        if control_constraints['rotor_overspeed']['flag']:
1✔
242
            if not any(self.level_flags):
1✔
243
                raise Exception('Please turn on the call to OpenFAST or RAFT if you are trying to optimize rotor overspeed constraints.')
×
244
            wt_opt.model.add_constraint(f'{self.floating_solve_component}.rotor_overspeed',
1✔
245
                lower = control_constraints['rotor_overspeed']['min'],
246
                upper = control_constraints['rotor_overspeed']['max'])
247
        
248
        # Add PI gains if overspeed is merit_figure or constraint
249
        if control_constraints['rotor_overspeed']['flag'] or self.opt['merit_figure'] == 'rotor_overspeed':
1✔
250
            wt_opt.model.add_constraint('sse_tune.tune_rosco.PC_Kp',
1✔
251
                upper = 0.0)
252
            wt_opt.model.add_constraint('sse_tune.tune_rosco.PC_Ki', 
1✔
253
                upper = 0.0)  
254
        
255
        # Nacelle Accelleration magnitude
256
        if control_constraints['nacelle_acceleration']['flag']:
1✔
257
            if not any(self.level_flags):
1✔
258
                raise Exception('Please turn on the call to OpenFAST or RAFT if you are trying to optimize with nacelle_acceleration constraint.')
×
259
            wt_opt.model.add_constraint(f'{self.floating_solve_component}.max_nac_accel',
1✔
260
                    upper = control_constraints['nacelle_acceleration']['max'])
261
        
262
        # Max platform pitch
263
        if control_constraints['Max_PtfmPitch']['flag']:
1✔
264
            if not any(self.level_flags):
1✔
265
                raise Exception('Please turn on the call to OpenFAST or RAFT if you are trying to optimize Max_PtfmPitch constraints.')
×
266
            wt_opt.model.add_constraint(f'{self.floating_solve_component}.Max_PtfmPitch',
1✔
267
                upper = control_constraints['Max_PtfmPitch']['max'])
268
        
269
        # Platform pitch motion
270
        if control_constraints['Std_PtfmPitch']['flag']:
1✔
271
            if not any(self.level_flags):
1✔
272
                raise Exception('Please turn on the call to OpenFAST or RAFT if you are trying to optimize Std_PtfmPitch constraints.')
×
273
            wt_opt.model.add_constraint(f'{self.floating_solve_component}.Std_PtfmPitch',
1✔
274
                upper = control_constraints['Std_PtfmPitch']['max'])
275
        if control_constraints['Max_TwrBsMyt']['flag']:
1✔
276
            if self.modeling['OpenFAST']['flag'] != True:
×
277
                raise Exception('Please turn on the call to OpenFAST if you are trying to optimize Max_TwrBsMyt constraints.')
×
278
            wt_opt.model.add_constraint('aeroelastic.max_TwrBsMyt_ratio', 
×
279
                upper = 1.0)
280
        if control_constraints['DEL_TwrBsMyt']['flag']:
1✔
281
            if self.modeling['OpenFAST']['flag'] != True:
×
282
                raise Exception('Please turn on the call to OpenFAST if you are trying to optimize Max_TwrBsMyt constraints.')
×
283
            wt_opt.model.add_constraint('aeroelastic.DEL_TwrBsMyt_ratio', 
×
284
                upper = 1.0)
285
            
286
        # Blade pitch travel
287
        if control_constraints['avg_pitch_travel']['flag']:
1✔
288
            if self.modeling['OpenFAST']['flag'] != True:
×
289
                raise Exception('Please turn on the call to OpenFAST if you are trying to optimize avg_pitch_travel constraints.')
×
290
            wt_opt.model.add_constraint('aeroelastic.avg_pitch_travel',
×
291
                upper = control_constraints['avg_pitch_travel']['max'])
292

293
        # Blade pitch duty cycle (number of direction changes)
294
        if control_constraints['pitch_duty_cycle']['flag']:
1✔
295
            if self.modeling['OpenFAST']['flag'] != True:
×
296
                raise Exception('Please turn on the call to OpenFAST if you are trying to optimize pitch_duty_cycle constraints.')
×
297
            wt_opt.model.add_constraint('aeroelastic.pitch_duty_cycle',
×
298
                upper = control_constraints['pitch_duty_cycle']['max'])
299

300
        # OpenFAST failure
301
        if self.opt['constraints']['openfast_failed']['flag']:
1✔
UNCOV
302
            if self.modeling['OpenFAST']['flag'] != True:
×
303
                raise Exception('Please turn on the call to OpenFAST if you are trying to optimize with openfast_failed constraint.')
×
UNCOV
304
            wt_opt.model.add_constraint('aeroelastic.openfast_failed',upper = 1.)
×
305

306
        # Max offset
307
        if self.opt['constraints']['floating']['Max_Offset']['flag']:
1✔
308
            if not any(self.level_flags):
×
309
                raise Exception('Please turn on the call to OpenFAST or RAFT if you are trying to optimize with openfast_failed constraint.')
×
310
            wt_opt.model.add_constraint(
×
311
                f'{self.floating_solve_component}.Max_Offset',
312
                upper = self.opt['constraints']['floating']['Max_Offset']['max']
313
                )
314
                
315
        # Tower constraints
316
        tower_opt = self.opt["design_variables"]["tower"]
1✔
317
        tower_constr = self.opt["constraints"]["tower"]
1✔
318
        if tower_constr["global_buckling"]["flag"] and self.modeling['OpenFAST']['flag']:
1✔
319
            # Remove generic WISDEM one
320
            name = 'towerse.post.constr_global_buckling'
×
321
            if name in wt_opt.model._responses:
×
322
                wt_opt.model._responses.pop( name )
×
323
            if name in wt_opt.model._static_responses:
×
324
                wt_opt.model._static_responses.pop( name )
×
325
                
326
            wt_opt.model.add_constraint("towerse_post.constr_global_buckling", upper=1.0)
×
327
        
328
        if tower_constr["shell_buckling"]["flag"] and self.modeling['OpenFAST']['flag']:
1✔
329
            # Remove generic WISDEM one
330
            name = 'towerse.post.constr_shell_buckling'
×
331
            if name in wt_opt.model._responses:
×
332
                wt_opt.model._responses.pop( name )
×
333
            if name in wt_opt.model._static_responses:
×
334
                wt_opt.model._static_responses.pop( name )
×
335
                
336
            wt_opt.model.add_constraint("towerse_post.constr_shell_buckling", upper=1.0)
×
337
        
338
        if tower_constr["stress"]["flag"] and self.modeling['OpenFAST']['flag']:
1✔
339
            # Remove generic WISDEM one
340
            name = 'towerse.post.constr_stress'
×
341
            if name in wt_opt.model._responses:
×
342
                wt_opt.model._responses.pop( name )
×
343
            if name in wt_opt.model._static_responses:
×
344
                wt_opt.model._static_responses.pop( name )
×
345
                
346
            wt_opt.model.add_constraint("towerse_post.constr_stress", upper=1.0)
×
347

348
        # Damage constraints
349
        damage_constraints = self.opt['constraints']['damage']
1✔
350
        if damage_constraints['tower_base']['flag'] and (self.modeling['OpenFAST_Linear']['flag'] or self.modeling['OpenFAST']['flag']):
1✔
UNCOV
351
            if self.modeling['OpenFAST']['flag'] != True:
×
352
                raise Exception('Please turn on the call to OpenFAST if you are trying to optimize with tower_base damage constraint.')
×
353

UNCOV
354
            tower_base_damage_max = damage_constraints['tower_base']['max']
×
UNCOV
355
            if damage_constraints['tower_base']['log']:
×
UNCOV
356
                tower_base_damage_max = np.log(tower_base_damage_max)
×
357

UNCOV
358
            wt_opt.model.add_constraint('aeroelastic.damage_tower_base',upper = tower_base_damage_max)
×
359

360
        return wt_opt
1✔
361

362

363
    def set_initial_weis(self, wt_opt):
1✔
364

365
        if self.modeling["flags"]["blade"]:
1✔
366
            blade_constr = self.opt["constraints"]["blade"]
1✔
367
            wt_opt["rlds_post.constr.max_strainU_spar"] = blade_constr["strains_spar_cap_ss"]["max"]
1✔
368
            wt_opt["rlds_post.constr.max_strainL_spar"] = blade_constr["strains_spar_cap_ps"]["max"]
1✔
369
            wt_opt["stall_check_of.stall_margin"] = blade_constr["stall"]["margin"] * 180.0 / np.pi
1✔
370
            wt_opt["tcons_post.max_allowable_td_ratio"] = blade_constr["tip_deflection"]["margin"]
1✔
371

372
        return wt_opt
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc