With unpivot you can do this but it will not account for new columns every year. Assuming adjusting query every year is not a big deal you can just write unpivot:
SELECT id,
code,
substring(estimateYear, CHARINDEX('_',estimateYear,1)+1,4) as [year],
estimate,
stderr,
numsrc
FROM mytable p
UNPIVOT
(estimate FOR estimateYear IN
(estimate_1996, estimate_1998, estimate_2000)) as estimate
UNPIVOT
(stderr FOR stderrYear IN
(stderr_1996, stderr_1998, stderr_2000)) as stderr
UNPIVOT
(numsrc FOR numsrcYear IN
(numsrc_1996, numsrc_1998, numsrc_2000)) as stderr
where substring(estimateYear, CHARINDEX('_',estimateYear,1)+1,4) = substring(stderrYear, CHARINDEX('_',stderrYear,1)+1,4)
and substring(estimateYear, CHARINDEX('_',estimateYear,1)+1,4) = substring(numsrcYear, CHARINDEX('_',numsrcYear,1)+1,4);
this only gives you 3 first values but the pattern is pretty repetitive: add new UNPIVOT
section for rest of the columns and new where condition at the end.
If you want this to be resilient to new columns being added at the expense of performance (that will be not that great with the unpivot itself) you can make this query dynamic:
declare @query as nvarchar(4000)
;with allcoumnswithyear as (
select SUBSTRING(name,1,charindex('_',name) - 1) valueName, SUBSTRING(name,charindex('_',name) + 1, 4) year, name
from sys.columns c
where c.object_id = object_id('mytable')
and name like '%[_]%' and name not in ('created_at','country_territory')
) , unpivotedValue as (
select
'UNPIVOT ('+valueName + ' FOR ' + valueName + 'Year IN ('+ STRING_AGG(name, ', ') +')) as ' + valueName unpivotPart,
'substring(estimateYear, CHARINDEX(''_'',estimateYear,1)+1,4) = substring('+valueName+'Year, CHARINDEX(''_'','+valueName+'Year,1)+1,4)' wherePart,
valueName
from allcoumnswithyear
group by valueName)
select
@query='select id, code, substring(estimateYear, CHARINDEX(''_'',estimateYear,1)+1,4) as [year],'
+ string_agg(valueName, ', ')
+ ' FROM mytable p '
+ string_agg(unpivotPart, ' ')
+ ' where '
+ string_agg(wherePart, ' and ')
from unpivotedValue
exec sp_executesql @query
basically this code writes the select statement from before each time it is executed and than executes the query.
STRING_AGG was introduced in sql server 2017 if you need this in older version you can substitute that with stuff and xml:
SELECT
STUFF(
(SELECT ',' + name AS [text()]
from sys.columns c
where c.object_id = object_id('mytable')
FOR XML PATH('')), 1, 1, NULL) AS aggregatedtablenames;